retro-ciphers 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ .venv
2
+ .pytest_cache
3
+ __pycache__
4
+ src/retro_ciphers/run.py
@@ -0,0 +1,7 @@
1
+ Copyright (c) 2026 Sarthak Patil
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: retro-ciphers
3
+ Version: 1.0.0
4
+ Summary: Classical ciphers implementation in python
5
+ Project-URL: Homepage, https://crypto.sarthak.co.in
6
+ Project-URL: Documentation, https://github.com/sarthac/retro-ciphers
7
+ Project-URL: Repository, https://github.com/sarthac/retro-ciphers.git
8
+ Project-URL: Issues, https://github.com/sarthac/retro-ciphers/issues
9
+ Author-email: Sarthak Patil <mail@sarthak.co.in>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: caesar,ciphers,classical ciphers,cryptography,historical ciphers,retro ciphers,vigenere
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Natural Language :: English
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Security :: Cryptography
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.12
23
+ Description-Content-Type: text/markdown
24
+
25
+ # Retro Ciphers
26
+
27
+ **Retro Ciphers** is implementations of classical and historical ciphers. It is intended for educational purposes, cryptography enthusiasts, or anyone interested in the history of hidden messages.
28
+
29
+ This package encompasses both **Monoalphabetic** and **Polyalphabetic** substitution ciphers,to include modern interpretations as well as strict, historically accurate 15th-century variants.
30
+
31
+ ## Installation
32
+
33
+ You can easily install `retro-ciphers` via pip:
34
+
35
+ ```bash
36
+ pip install retro-ciphers
37
+ ```
38
+
39
+ *(Requires Python 3.12 or higher)*
40
+
41
+ ## Features
42
+
43
+ `retro-ciphers` provides clean, object-oriented API access to the following historical ciphers:
44
+
45
+ ### Monoalphabetic Ciphers
46
+ - **Atbash**: The classic Hebrew reversal cipher.
47
+ - **Caesar / Shift / ROT13**: Classical shift ciphers with custom shift lengths.
48
+ - **Mixed Alphabet**: Key-based shift mechanisms mapping the standard alphabet.
49
+ - **Simple Substitution**: Create completely custom scrambled mappings.
50
+ - **Baconian Cipher**: Francis Bacon's steganographic, binary-like cipher (supports both classic 24-letter and modern 26-letter alphabets).
51
+ - **Polybius Square**: The classical ancient Greek fractionating cipher (coordinates).
52
+
53
+ ### Polyalphabetic Ciphers
54
+ - **Alberti Cipher**: The first polyalphabetic cipher! Supports both standard English 26-character modern modes AND the historically accurate 1467 Latin 24/24 Character Disks implementation seamlessly!
55
+ - **Trithemius Cipher**: Johannes Trithemius's tabula recta system.
56
+ - **Vigenère Cipher**: The famous, unbroken mathematical improvement using table offsets.
57
+ - **Beaufort Cipher**: A variant of Vigenère using a reversed tabula recta mechanism.
58
+ - **Autokey Cipher**: An extension where the plaintext itself becomes part of the key.
59
+
60
+ ## Quick Start
61
+
62
+ The API is simple: initialize your chosen cipher, then use `.cipher()` to encrypt and `.decipher()` to decrypt text.
63
+
64
+ ### Monoalphabetic Examples
65
+
66
+ ```python
67
+ from retro_ciphers.mono import Caesar, Atbash, Baconian
68
+
69
+ # Caesar Cipher
70
+ caesar = Caesar() # Default shift of 3
71
+ encrypted = caesar.cipher("Hello World!")
72
+ # >>> "Khoor Zruog!"
73
+
74
+ # Atbash Cipher
75
+ atbash = Atbash()
76
+ print(atbash.cipher("Classical Cryptography"))
77
+ # >>> "Xozhhzxzo Xibkgltizksb"
78
+
79
+ # Baconian Cipher
80
+ bacon = Baconian(modern_implementation=True)
81
+ print(bacon.cipher("Hide"))
82
+ # >>> "AABBBABAAAAABABAABAA"
83
+ ```
84
+
85
+ ### Polyalphabetic Examples
86
+
87
+ ```python
88
+ from retro_ciphers.poly import Vigenere, Alberti
89
+
90
+ # Vigenère Cipher
91
+ vigenere = Vigenere("LEMON")
92
+ encrypted = vigenere.cipher("ATTACK AT DAWN")
93
+ print(encrypted)
94
+ # >>> "LXFOPV EF RNHR"
95
+
96
+ decrypted = vigenere.decipher(encrypted)
97
+ print(decrypted)
98
+ # >>> "ATTACK AT DAWN"
99
+
100
+ # Alberti Cipher (Historical 1467 Mode)
101
+ # Uses the original Latin outer ("ABCDEFGILMNOPQRSTVXZ1234") and inner mappings
102
+ alberti = Alberti(key="a", modern_implementation=False)
103
+ secret = alberti.cipher("ABCDEFGHI")
104
+ print(secret)
105
+ ```
106
+
107
+ ## Extra arguments
108
+
109
+ - `omit_non_alpha`: Available in the `cipher()` methods across ciphers (such as `Alberti` and `Autokey`). Accepts a boolean value (default: `False`). If `True`, it removes special symbols such as punctuation from the ciphertext. If `False`, it safely passes them through so they are kept intact.
110
+ - `Alberti(key, frequency=50, modern_implementation=True)`:
111
+ - `modern_implementation`: By default (`True`), the cipher uses the standard modern English A-Z alphabet. If you pass `False`, it enforces the historically accurate 1467 24-character Latin disks.
112
+ - `frequency`: By default (`50`), the Alberti cipher automatically changes its mapping disk (indicating via a new outer key) every 50 characters to improve security against cryptanalysis. You can increase or decrease this rate.
113
+ - `Baconian(modern_implementation=True)`: By default (`True`), uses the full 26-letter alphabet. If `False`, it mimics Bacon's original 24-letter alphabet where 'I' & 'J' and 'U' & 'V' map to the same sequences.
114
+
115
+ ## License
116
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
@@ -0,0 +1,92 @@
1
+ # Retro Ciphers
2
+
3
+ **Retro Ciphers** is implementations of classical and historical ciphers. It is intended for educational purposes, cryptography enthusiasts, or anyone interested in the history of hidden messages.
4
+
5
+ This package encompasses both **Monoalphabetic** and **Polyalphabetic** substitution ciphers,to include modern interpretations as well as strict, historically accurate 15th-century variants.
6
+
7
+ ## Installation
8
+
9
+ You can easily install `retro-ciphers` via pip:
10
+
11
+ ```bash
12
+ pip install retro-ciphers
13
+ ```
14
+
15
+ *(Requires Python 3.12 or higher)*
16
+
17
+ ## Features
18
+
19
+ `retro-ciphers` provides clean, object-oriented API access to the following historical ciphers:
20
+
21
+ ### Monoalphabetic Ciphers
22
+ - **Atbash**: The classic Hebrew reversal cipher.
23
+ - **Caesar / Shift / ROT13**: Classical shift ciphers with custom shift lengths.
24
+ - **Mixed Alphabet**: Key-based shift mechanisms mapping the standard alphabet.
25
+ - **Simple Substitution**: Create completely custom scrambled mappings.
26
+ - **Baconian Cipher**: Francis Bacon's steganographic, binary-like cipher (supports both classic 24-letter and modern 26-letter alphabets).
27
+ - **Polybius Square**: The classical ancient Greek fractionating cipher (coordinates).
28
+
29
+ ### Polyalphabetic Ciphers
30
+ - **Alberti Cipher**: The first polyalphabetic cipher! Supports both standard English 26-character modern modes AND the historically accurate 1467 Latin 24/24 Character Disks implementation seamlessly!
31
+ - **Trithemius Cipher**: Johannes Trithemius's tabula recta system.
32
+ - **Vigenère Cipher**: The famous, unbroken mathematical improvement using table offsets.
33
+ - **Beaufort Cipher**: A variant of Vigenère using a reversed tabula recta mechanism.
34
+ - **Autokey Cipher**: An extension where the plaintext itself becomes part of the key.
35
+
36
+ ## Quick Start
37
+
38
+ The API is simple: initialize your chosen cipher, then use `.cipher()` to encrypt and `.decipher()` to decrypt text.
39
+
40
+ ### Monoalphabetic Examples
41
+
42
+ ```python
43
+ from retro_ciphers.mono import Caesar, Atbash, Baconian
44
+
45
+ # Caesar Cipher
46
+ caesar = Caesar() # Default shift of 3
47
+ encrypted = caesar.cipher("Hello World!")
48
+ # >>> "Khoor Zruog!"
49
+
50
+ # Atbash Cipher
51
+ atbash = Atbash()
52
+ print(atbash.cipher("Classical Cryptography"))
53
+ # >>> "Xozhhzxzo Xibkgltizksb"
54
+
55
+ # Baconian Cipher
56
+ bacon = Baconian(modern_implementation=True)
57
+ print(bacon.cipher("Hide"))
58
+ # >>> "AABBBABAAAAABABAABAA"
59
+ ```
60
+
61
+ ### Polyalphabetic Examples
62
+
63
+ ```python
64
+ from retro_ciphers.poly import Vigenere, Alberti
65
+
66
+ # Vigenère Cipher
67
+ vigenere = Vigenere("LEMON")
68
+ encrypted = vigenere.cipher("ATTACK AT DAWN")
69
+ print(encrypted)
70
+ # >>> "LXFOPV EF RNHR"
71
+
72
+ decrypted = vigenere.decipher(encrypted)
73
+ print(decrypted)
74
+ # >>> "ATTACK AT DAWN"
75
+
76
+ # Alberti Cipher (Historical 1467 Mode)
77
+ # Uses the original Latin outer ("ABCDEFGILMNOPQRSTVXZ1234") and inner mappings
78
+ alberti = Alberti(key="a", modern_implementation=False)
79
+ secret = alberti.cipher("ABCDEFGHI")
80
+ print(secret)
81
+ ```
82
+
83
+ ## Extra arguments
84
+
85
+ - `omit_non_alpha`: Available in the `cipher()` methods across ciphers (such as `Alberti` and `Autokey`). Accepts a boolean value (default: `False`). If `True`, it removes special symbols such as punctuation from the ciphertext. If `False`, it safely passes them through so they are kept intact.
86
+ - `Alberti(key, frequency=50, modern_implementation=True)`:
87
+ - `modern_implementation`: By default (`True`), the cipher uses the standard modern English A-Z alphabet. If you pass `False`, it enforces the historically accurate 1467 24-character Latin disks.
88
+ - `frequency`: By default (`50`), the Alberti cipher automatically changes its mapping disk (indicating via a new outer key) every 50 characters to improve security against cryptanalysis. You can increase or decrease this rate.
89
+ - `Baconian(modern_implementation=True)`: By default (`True`), uses the full 26-letter alphabet. If `False`, it mimics Bacon's original 24-letter alphabet where 'I' & 'J' and 'U' & 'V' map to the same sequences.
90
+
91
+ ## License
92
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["hatchling >= 1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "retro-ciphers"
7
+ version = "1.0.0"
8
+ authors = [
9
+ { name="Sarthak Patil", email="mail@sarthak.co.in" },
10
+ ]
11
+ description = "Classical ciphers implementation in python"
12
+ requires-python = ">=3.12"
13
+ dependencies = []
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.12",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Topic :: Security :: Cryptography",
20
+ "Intended Audience :: Developers",
21
+ "Development Status :: 4 - Beta",
22
+ "Natural Language :: English",
23
+ "Typing :: Typed",
24
+ ]
25
+ license = "MIT"
26
+ license-file = "LICENSE"
27
+ readme = "README.md"
28
+ keywords = ["retro ciphers", "ciphers", "cryptography", "caesar", "vigenere", "classical ciphers", "historical ciphers"]
29
+
30
+ [project.urls]
31
+ Homepage = "https://crypto.sarthak.co.in"
32
+ Documentation = "https://github.com/sarthac/retro-ciphers"
33
+ Repository = "https://github.com/sarthac/retro-ciphers.git"
34
+ Issues = "https://github.com/sarthac/retro-ciphers/issues"
@@ -0,0 +1,3 @@
1
+ # from .base import *
2
+ from .mono import *
3
+ from .poly import *
@@ -0,0 +1,142 @@
1
+ from abc import ABC, abstractmethod
2
+ import string
3
+ from typing import override, Sequence
4
+ from itertools import cycle
5
+
6
+
7
+ class Substitution(ABC):
8
+ @abstractmethod
9
+ def cipher(self, text: str) -> str:
10
+ pass
11
+
12
+ @abstractmethod
13
+ def decipher(self, text: str) -> str:
14
+ pass
15
+
16
+ def __call__(self, text: str) -> str:
17
+ """Allows the cipher object to be called directly to encrypt text."""
18
+ return self.cipher(text)
19
+
20
+
21
+ class MonoalphabeticSubstitution(Substitution):
22
+ """
23
+ A base class for creating monoalphabetic substitution ciphers.
24
+ """
25
+
26
+ def __init__(self, cipher_alphabet: Sequence[str]):
27
+ """
28
+ Takes a 26-character sequence and automatically builds
29
+ a dual-case mapping dictionary to preserve original formatting.
30
+ """
31
+ # 1. Build the lowercase mapping
32
+ lower_base = string.ascii_lowercase
33
+ lower_cipher = [char.lower() for char in cipher_alphabet]
34
+ lower_map = dict(zip(lower_base, lower_cipher))
35
+
36
+ # 2. Build the uppercase mapping
37
+ upper_base = string.ascii_uppercase
38
+ upper_cipher = [char.upper() for char in cipher_alphabet]
39
+ upper_map = dict(zip(upper_base, upper_cipher))
40
+
41
+ # 3. Merge them into a single 52-pair dictionary!
42
+ self.mapping: dict = lower_map | upper_map
43
+ self.reverse_mapping: dict = {v: k for k, v in self.mapping.items()}
44
+
45
+ @override
46
+ def cipher(self, text: str, omit_non_alpha: bool = False) -> str:
47
+ result: str = ""
48
+
49
+ for char in text:
50
+ if char.isalpha():
51
+ result += self.mapping[char]
52
+ elif not omit_non_alpha or (char in string.whitespace):
53
+ result += char
54
+
55
+ return result
56
+
57
+ @override
58
+ def decipher(self, text: str) -> str:
59
+ return "".join(self.reverse_mapping.get(char, char) for char in text)
60
+
61
+ def __str__(self) -> str:
62
+ base = string.ascii_letters
63
+ cipher_mapping = "".join(self.mapping.values())
64
+
65
+ return (
66
+ f"--- {self.__class__.__name__} Cipher ---\n"
67
+ f"Plain: {base}\n"
68
+ f"Cipher: {cipher_mapping}\n"
69
+ )
70
+
71
+ def __eq__(self, other: object) -> bool:
72
+ if not isinstance(other, MonoalphabeticSubstitution):
73
+ return NotImplemented
74
+ # Two ciphers are equal if their cipher_alphabet dictionaries are exactly the same
75
+ return self.mapping == other.mapping
76
+
77
+
78
+ class PolyalphabeticSubstitution(Substitution):
79
+ def __init__(self, key: str):
80
+ # Call the generation method as a standard function
81
+ self.tabula_recta = self._generate_table()
82
+ if not key:
83
+ raise ValueError("Key must not be empty.")
84
+ self.key = key.upper()
85
+ if not any(char.isalpha() for char in self.key):
86
+ raise ValueError("Key must contain at least one letter.")
87
+
88
+ def _generate_table(self) -> list[list[str]]:
89
+ """Create a square table of alphabets (Vigenère by default)."""
90
+ tabula_recta: list[list[str]] = []
91
+ for i in range(26):
92
+ tabula_recta.append(
93
+ list(string.ascii_uppercase[i:] + string.ascii_uppercase[:i])
94
+ )
95
+ return tabula_recta
96
+
97
+ def _get_key_sequence(self) -> list[int]:
98
+ """Maps the keyword to a sequence of shifts."""
99
+ clean_key = [char.upper() for char in self.key if char.isalpha()]
100
+ if not clean_key:
101
+ raise ValueError("Key must contain at least one letter.")
102
+ return [ord(char) - 65 for char in clean_key]
103
+
104
+ @override
105
+ def cipher(self, text: str, omit_non_alpha: bool = False) -> str:
106
+ result: str = ""
107
+ key_cycle = cycle(self._get_key_sequence())
108
+
109
+ for char in text.upper():
110
+ if char.isalpha():
111
+ key_char = next(key_cycle)
112
+ row = key_char
113
+ # convert text_char to int then -65 as the ascii upper letters start at 65, to get the index of the column.
114
+ column = ord(char) - 65
115
+ result += self.tabula_recta[row][column]
116
+
117
+ elif not omit_non_alpha or (char in string.whitespace):
118
+ result += char
119
+
120
+ return result
121
+
122
+ @override
123
+ def decipher(self, text: str) -> str:
124
+ result: str = ""
125
+ key_cycle = cycle(self._get_key_sequence())
126
+
127
+ for char in text.upper():
128
+ if char.isalpha():
129
+ key_char = next(key_cycle)
130
+ column = self.tabula_recta[key_char].index(char)
131
+ result += self.tabula_recta[0][column]
132
+ else:
133
+ result += char
134
+ return result
135
+
136
+ def __str__(self) -> str:
137
+ key = self.key
138
+
139
+ return f"--- {self.__class__.__name__} Cipher ---\n" f"Key: {key!r}\n"
140
+
141
+ def __repr__(self) -> str:
142
+ return f"{self.__class__.__name__}(key={self.key!r})"
@@ -0,0 +1,265 @@
1
+ from .base import MonoalphabeticSubstitution
2
+ import string
3
+ from typing import override
4
+ import random
5
+
6
+
7
+ """
8
+ The following ciphers are implemented:
9
+ - MixedAlphabet: A substitution cipher with a mixed alphabet generated from a keyword.
10
+ - Atbash: A simple substitution cipher where the alphabet is reversed.
11
+ - SimpleSubstitution: A substitution cipher with a randomly generated or user-defined cipher alphabet.
12
+ - Rotate: A substitution cipher that rotates the alphabet by a given shift.
13
+ - Caesar: A specific instance of the Rotate cipher with a shift of 3.
14
+ - Rot13: A specific instance of the Rotate cipher with a shift of 13.
15
+ - Baconian: A substitution cipher that uses a 5-character binary representation for each letter.
16
+ - PolybiusSquare: A substitution cipher that uses a 5x5 grid to represent each letter.
17
+ """
18
+
19
+
20
+ class Atbash(MonoalphabeticSubstitution):
21
+ def __init__(self):
22
+ super().__init__(string.ascii_lowercase[::-1])
23
+
24
+
25
+ class Shift(MonoalphabeticSubstitution):
26
+ def __init__(self, shift: int):
27
+ # The modulo ensures shifts larger than 26 wrap around safely
28
+ base = string.ascii_lowercase
29
+ self.shift = shift % len(base)
30
+ cipher_alphabet: str = base[self.shift :] + base[: self.shift]
31
+ super().__init__(cipher_alphabet)
32
+
33
+ def __repr__(self) -> str:
34
+ return f"{self.__class__.__name__}(shift={self.shift})"
35
+
36
+
37
+ class Caesar(Shift):
38
+ def __init__(self):
39
+ super().__init__(shift=3)
40
+
41
+
42
+ class Rot13(Shift):
43
+ def __init__(self):
44
+ super().__init__(shift=13)
45
+
46
+
47
+ class MixedAlphabet(MonoalphabeticSubstitution):
48
+ """
49
+ A substitution cipher with a mixed alphabet generated from a keyword.
50
+
51
+ The cipher alphabet is created by taking the unique letters of the keyword,
52
+ followed by the remaining letters of the alphabet in their normal order.
53
+ """
54
+
55
+ def __init__(self, keyword: str) -> None:
56
+ # removing duplicate letter in the keyword to make the cipher_alphanet 26 chars
57
+ self.keyword = "".join(filter(str.isalpha, keyword.lower()))
58
+ # make keyword unique so the cipher alphanet does not include dublicates and it should 26 exact
59
+ clean_keyword = list(dict.fromkeys(self.keyword))
60
+ cipher_alphabet = clean_keyword
61
+ for letter in string.ascii_lowercase:
62
+ if letter not in cipher_alphabet:
63
+ cipher_alphabet.append(letter)
64
+
65
+ super().__init__(cipher_alphabet)
66
+
67
+ def __repr__(self) -> str:
68
+ return f"{self.__class__.__name__}(keyword={self.keyword!r})"
69
+
70
+
71
+ class SimpleSubstitution(MonoalphabeticSubstitution):
72
+ """
73
+ A substitution cipher with a randomly generated or user-defined cipher alphabet.
74
+ """
75
+
76
+ # user need to know the cipher_alphabet as it is a key to cipher and decipher.
77
+ # user provide one or generate one using a static method.
78
+ def __init__(self, cipher_alphabet: str) -> None:
79
+ if (
80
+ len(dict.fromkeys(c.lower() for c in cipher_alphabet)) != 26
81
+ or len(cipher_alphabet) != 26
82
+ ):
83
+ raise ValueError(
84
+ "cipher_alphabets must be unqiue and 26 char long OR generate one by executing 'SimpleSubstitution.generate_cipher_alphabet()'"
85
+ )
86
+ self.cipher_alphabet = cipher_alphabet
87
+ super().__init__(self.cipher_alphabet)
88
+
89
+ @staticmethod
90
+ def generate_cipher_alphabet() -> str:
91
+ """
92
+ Generates a random cipher alphabet.
93
+
94
+ Returns:
95
+ A string representing the random cipher alphabet.
96
+ """
97
+ return "".join(random.sample(string.ascii_lowercase, k=26))
98
+
99
+ def __repr__(self) -> str:
100
+ return f"{self.__class__.__name__}(cipher_alphabet={self.cipher_alphabet!r})"
101
+
102
+
103
+ class Baconian(MonoalphabeticSubstitution):
104
+ """
105
+ A substitution cipher that uses a 5-character binary representation for each letter.
106
+ """
107
+
108
+ modern_baconian_cipher = [
109
+ "aaaaa", # a
110
+ "aaaab", # b
111
+ "aaaba", # c
112
+ "aaabb", # d
113
+ "aabaa", # e
114
+ "aabab", # f
115
+ "aabba", # g
116
+ "aabbb", # h
117
+ "abaaa", # i
118
+ "abaab", # j
119
+ "ababa", # k
120
+ "ababb", # l
121
+ "abbaa", # m
122
+ "abbab", # n
123
+ "abbba", # o
124
+ "abbbb", # p
125
+ "baaaa", # q
126
+ "baaab", # r
127
+ "baaba", # s
128
+ "baabb", # t
129
+ "babaa", # u
130
+ "babab", # v
131
+ "babba", # w
132
+ "babbb", # x
133
+ "bbaaa", # y
134
+ "bbaab", # z
135
+ ]
136
+
137
+ classic_baconian_cipher = [
138
+ "aaaaa", # A
139
+ "aaaab", # B
140
+ "aaaba", # C
141
+ "aaabb", # D
142
+ "aabaa", # E
143
+ "aabab", # F
144
+ "aabba", # G
145
+ "aabbb", # H
146
+ "abaaa", # I / J
147
+ "abaaa", # I / J
148
+ "abaab", # K
149
+ "ababa", # L
150
+ "ababb", # M
151
+ "abbaa", # N
152
+ "abbab", # O
153
+ "abbba", # P
154
+ "abbbb", # Q
155
+ "baaaa", # R
156
+ "baaab", # S
157
+ "baaba", # T
158
+ "baabb", # U / V
159
+ "baabb", # U / V
160
+ "babaa", # W
161
+ "babab", # X
162
+ "babba", # Y
163
+ "babbb", # Z
164
+ ]
165
+
166
+ def __init__(self, modern_implementation=True) -> None:
167
+ """
168
+ Initializes the Baconian cipher.
169
+
170
+ Args:
171
+ modern_implementation: Whether to use the modern or old implementation of the cipher.
172
+ """
173
+ self.modern_implementation = modern_implementation
174
+ self.cipher_alphabet = (
175
+ self.modern_baconian_cipher
176
+ if self.modern_implementation
177
+ else self.classic_baconian_cipher
178
+ )
179
+ super().__init__(self.cipher_alphabet)
180
+
181
+ @override
182
+ def decipher(self, text: str) -> str:
183
+ plain_text = ""
184
+ i = 0
185
+ # setting up the cipher word lenth
186
+ word_length = 5
187
+ while i < len(text):
188
+ if not text[i].isalpha():
189
+ plain_text += text[i]
190
+ i += 1
191
+ else:
192
+ # grabing next five chars
193
+ block = text[i : i + word_length]
194
+ # look into dictionary to decipher to single char
195
+ plain_text += self.reverse_mapping.get(block, block)
196
+ # switch index to next five chars
197
+ i += word_length
198
+ return plain_text
199
+
200
+ def __repr__(self) -> str:
201
+ return f"{self.__class__.__name__}(modern_implementation={self.modern_implementation})"
202
+
203
+
204
+ class PolybiusSquare(MonoalphabeticSubstitution):
205
+ """
206
+ A substitution cipher that uses a 5x5 grid to represent each letter.
207
+
208
+ A Polybius Square is a 5x5 grid. 5 x 5 = 25 total coordinates (from "11" to "55").
209
+ However, your self.base_alphabet (the standard English alphabet) has 26 letters.
210
+
211
+ Ancient Greeks and classic cryptographers solved this by making two letters share the exact same cell in the grid. Usually, 'I' and 'J' share a spot (though sometimes 'C' and 'K' share one).
212
+
213
+ To fix this in your code without having to rewrite your beautiful parent class, you just need to pass a 26-item list where the coordinates for 'i' and 'j' are identical.
214
+ """
215
+
216
+ cipher_alphabets = [
217
+ "11",
218
+ "12",
219
+ "13",
220
+ "14",
221
+ "15",
222
+ "21",
223
+ "22",
224
+ "23",
225
+ "24",
226
+ "24",
227
+ "25",
228
+ "31",
229
+ "32",
230
+ "33",
231
+ "34",
232
+ "35",
233
+ "41",
234
+ "42",
235
+ "43",
236
+ "44",
237
+ "45",
238
+ "51",
239
+ "52",
240
+ "53",
241
+ "54",
242
+ "55",
243
+ ]
244
+
245
+ def __init__(self) -> None:
246
+ super().__init__(self.cipher_alphabets)
247
+
248
+ @override
249
+ def decipher(self, text: str) -> str:
250
+ plain_text = ""
251
+ i = 0
252
+ # setting up the cipher word lenth
253
+ word_length = 2
254
+ while i < len(text):
255
+ if not text[i].isnumeric():
256
+ plain_text += text[i]
257
+ i += 1
258
+ else:
259
+ # grabing next two chars
260
+ block = text[i : i + word_length]
261
+ # look into dictionary to decipher to single char
262
+ plain_text += self.reverse_mapping.get(block, block)
263
+ # switch index to next two chars
264
+ i += word_length
265
+ return plain_text
@@ -0,0 +1,324 @@
1
+ from .base import Substitution, PolyalphabeticSubstitution
2
+ import string
3
+ from typing import override
4
+ from itertools import cycle
5
+ import random
6
+
7
+ """
8
+ The history of the Polyalphabetci substitution cipher
9
+
10
+ Alberti cipher
11
+
12
+ Trithemius cipher
13
+
14
+ Vigenere cipher
15
+
16
+ Beaufort cipher
17
+
18
+ Autokey cipher
19
+
20
+
21
+ ----------------------------------------------------------
22
+
23
+ Alberti cipher
24
+
25
+ - was one of the first polyalphabetic ciphers.
26
+
27
+ WIKI - https://en.wikipedia.org/wiki/Alberti_cipher
28
+
29
+ ----------------------------------------------------------
30
+
31
+ Trithemius cipher
32
+
33
+ - Grab the inspiration from the Alberti ciper but uses it own table, that make it uqnique. It creates it own key by the sequnce of the input text letters, first letter will be A, Second letter wil be B and so on, after the end of Z return to A.
34
+
35
+ WIKI - https://en.wikipedia.org/wiki/Tabula_recta#Trithemius%20cipher
36
+
37
+ ----------------------------------------------------------
38
+
39
+ Vigenera cipher
40
+
41
+ - an important extension to Trithemius's method, Need a key; not just a sequence of letter as a key, for example: LEMON.
42
+
43
+ WIKI - https://en.wikipedia.org/wiki/Tabula_recta#Improvements and https://en.wikipedia.org/wiki/Vigen%C3%A8re_cipher
44
+
45
+ ----------------------------------------------------------
46
+
47
+ Beaufort cipher
48
+
49
+ - Similar to the Vigenère cipher, with a slightly modified enciphering mechanism and tableau.
50
+ - The Beaufort cipher is based on the Beaufort square which is essentially the same as a Vigenère square but in reverse order starting with the letter "Z" in the first row,[3] where the first row and the last column serve the same purpose.
51
+
52
+ WIKI - https://en.wikipedia.org/wiki/Beaufort_cipher
53
+
54
+ ----------------------------------------------------------
55
+
56
+ Autokey cipher
57
+
58
+ - starts with a relatively-short keyword, the primer, and appends the message to it. For example, if the keyword is QUEENLY and the message is attack at dawn, then the key would be QUEENLYATTACKATDAWN
59
+
60
+ WIKI - https://en.wikipedia.org/wiki/Autokey_cipher
61
+
62
+ """
63
+
64
+
65
+ class Alberti(Substitution):
66
+ """
67
+ Uses a disk that has two disk stack on each other to form a disk,
68
+
69
+ 1. Outer disk is Capital lettes, not moveable
70
+ 2. Inner disk is small letters, moveable (possible to rotate)
71
+
72
+ To cipher or decipher a text, both parties—the sender and reciver agree on the key that is respect to inner disk(smaller letter),
73
+
74
+ In Alberti's original design, the Outer Disk is the Plaintext and the Inner Disk is the Ciphertext.
75
+
76
+
77
+ 1). How to Encrypt (Outer -> Inner)
78
+
79
+ Because the outer disk represents your readable English, you always start looking there when encrypting.
80
+
81
+ 1. Align: Map the inner 'g' to the outer 'D'.(here 'g' is the key, both sender and reciver need to know, 'D' is the outer disk only specify that the 'g' map to 'D', if reciver stumble upon next new capital letter, it is time to map the 'g' with new capital letter)
82
+ 2. Signal: Write down 'D' in your ciphertext so the receiver knows the starting position.
83
+ 3. Cipher: Look for your Plaintext letter on the Outer Disk. Find the letter directly below it on the Inner Disk and write that down.
84
+ 4. Switch: Decide to change the key. Map inner 'g' to outer 'M'.
85
+ 5. Signal & Cipher: Write down 'M' in your ciphertext, and then continue looking at the Outer Disk and writing down the new matching letters from the Inner Disk.
86
+
87
+ 2). How to Decrypt (Inner -> Outer)
88
+
89
+ When you receive the scrambled message, you are looking at the ciphertext, so you have to read it in reverse.
90
+
91
+ 1. Read the Signal: You see the first letter is 'D'.
92
+ 2. Align: You map your inner 'g' to the outer 'D'.
93
+ 3. Decipher: Look for the scrambled Ciphertext letter on the Inner Disk. Find the letter directly above it on the Outer Disk and write that down to reveal the plain English.
94
+ 4. Read the Next Signal: You hit the letter 'M' in the ciphertext.
95
+ 5. Switch & Decipher: You rotate your inner 'g' to the outer 'M', and continue finding the ciphertext on the Inner Disk and translating it to the Outer Disk.
96
+
97
+ """
98
+
99
+ """
100
+ modern_implementation: Whether to use the modern or old implementation of the cipher.
101
+ if moder_imlemenation = False, you should only bring plaintext as in 15th century latin alphabets, this is for the historical purposes.
102
+ """
103
+
104
+ def __init__(
105
+ self, key: str, frequency: int = 50, modern_implementation: bool = True
106
+ ):
107
+ self.key = key
108
+ self.frequency = frequency
109
+ self.disk: dict[str, str] = {}
110
+ self.modern_implementation = modern_implementation
111
+
112
+ if self.modern_implementation:
113
+ # The Modern 26-Letter English Tool
114
+ self.outer_disk_alphabets = string.ascii_uppercase
115
+ self.inner_disk_alphabets = "qwertyuiopasdfghjklzxcvbnm"
116
+ else:
117
+ # The Historical 1467 Latin Replica
118
+ self.outer_disk_alphabets = "ABCDEFGILMNOPQRSTVXZ1234"
119
+ self.inner_disk_alphabets = "gklnprtuz&xysomqihfdbace"
120
+
121
+ if self.key not in self.inner_disk_alphabets:
122
+ raise ValueError(f"Key '{self.key}' must be in the inner disk alphabets")
123
+
124
+ @override
125
+ def cipher(self, text: str, omit_non_alpha: bool = False) -> str:
126
+ result: str = ""
127
+
128
+ # generate intial disk
129
+ gen = self.generate()
130
+ self.disk = gen["disk"]
131
+ outer_key = gen["outer_key"]
132
+
133
+ result += outer_key
134
+ for i, char in enumerate(text.upper()):
135
+ if char in self.disk:
136
+ result += self.disk[char]
137
+ elif not omit_non_alpha or (char in string.whitespace):
138
+ result += char
139
+
140
+ # time to produce new mapping, to make it harder for cryptanalysis
141
+ if i > 0 and i % self.frequency == 0:
142
+ gen = self.generate()
143
+ self.disk = gen["disk"]
144
+ outer_key = gen["outer_key"]
145
+ result += outer_key
146
+
147
+ return result
148
+
149
+ @override
150
+ def decipher(self, text: str) -> str:
151
+ result: str = ""
152
+ for char in text:
153
+ # Checks if the Capital char found, if found create a disk
154
+ if char in self.outer_disk_alphabets:
155
+ self.disk = self.create_disk(outer_key=char)
156
+
157
+ # If char is small letter, it mean use the existing disk to decipher it.
158
+ elif char in self.inner_disk_alphabets:
159
+ if not self.disk:
160
+ raise KeyError(
161
+ "Disk not initialized because ciphertext does not start with an outer key."
162
+ )
163
+ result += self.disk[char]
164
+
165
+ # Dont omit non-alphabet chars
166
+ else:
167
+ result += char
168
+
169
+ return result
170
+
171
+ def generate(self):
172
+ """
173
+ Generate a disk, change the outer_key to produce new disk to make the cipher harder cryptanalysis.
174
+ """
175
+ # Safely pick a random outer key without IndexErrors
176
+ outer_key = random.choice(self.outer_disk_alphabets)
177
+ disk = self.create_disk(outer_key=outer_key)
178
+
179
+ return {
180
+ "outer_key": outer_key,
181
+ "disk": disk,
182
+ }
183
+
184
+ def create_disk(self, outer_key: str):
185
+ """
186
+ Utility method to produce disk based on the outer_key and inner_key that is self.key
187
+ """
188
+ inner_index = self.inner_disk_alphabets.index(self.key)
189
+ inner_disk_alphabets = (
190
+ self.inner_disk_alphabets[inner_index:]
191
+ + self.inner_disk_alphabets[:inner_index]
192
+ )
193
+
194
+ outer_key_index = self.outer_disk_alphabets.index(outer_key)
195
+ outer_disk_alphabets = (
196
+ self.outer_disk_alphabets[outer_key_index:]
197
+ + self.outer_disk_alphabets[:outer_key_index]
198
+ )
199
+
200
+ disk = dict(zip(outer_disk_alphabets, inner_disk_alphabets)) | dict(
201
+ zip(inner_disk_alphabets, outer_disk_alphabets)
202
+ )
203
+
204
+ return disk
205
+
206
+ def __str__(self) -> str:
207
+ mode = "Modern A-Z" if self.modern_implementation else "Historical 1467"
208
+ return (
209
+ f"--- {self.__class__.__name__} Cipher ---\n"
210
+ f"Key: {self.key!r}\n"
211
+ f"Spin Freq: {self.frequency}\n"
212
+ f"Mode: {mode}\n"
213
+ )
214
+
215
+ def __repr__(self) -> str:
216
+ return f"AlbertiCipher(key={self.key!r}, frequency={self.frequency}, modern_implementation={self.modern_implementation})"
217
+
218
+
219
+ class Trithemius(PolyalphabeticSubstitution):
220
+ def __init__(self):
221
+ super().__init__(key=string.ascii_uppercase)
222
+
223
+ @override
224
+ def decipher(self, text: str) -> str:
225
+ result: str = ""
226
+ key_cycle = cycle(self._get_key_sequence())
227
+
228
+ for text_char in text.upper():
229
+ if text_char.isalpha():
230
+ key_char = next(key_cycle)
231
+ column = (ord(text_char) - 65) - key_char
232
+ result += self.tabula_recta[0][column]
233
+ else:
234
+ result += text_char
235
+ return result
236
+
237
+ @override
238
+ def __str__(self) -> str:
239
+ return f"{self.__class__.__name__}\n"
240
+
241
+ @override
242
+ def __repr__(self) -> str:
243
+ return f"{self.__class__.__name__}()"
244
+
245
+
246
+ class Vigenere(PolyalphabeticSubstitution):
247
+ def __init__(self, key: str):
248
+ super().__init__(key)
249
+
250
+
251
+ class Beaufort(PolyalphabeticSubstitution):
252
+ def __init__(self, key: str):
253
+ super().__init__(key)
254
+
255
+ @override
256
+ def _generate_table(self) -> list[list[str]]:
257
+ """Create a square table of alphabets for beaufort cipher, as it uses it own table implmentation."""
258
+ tabula_recta: list[list[str]] = []
259
+ for i in range(26):
260
+ tabula_recta.append(
261
+ list(string.ascii_uppercase[i::-1] + string.ascii_uppercase[:i:-1])
262
+ )
263
+ return tabula_recta
264
+
265
+ @override
266
+ def decipher(self, text: str) -> str:
267
+ # self-reciprocal
268
+ return self.cipher(text)
269
+
270
+
271
+ class Autokey(PolyalphabeticSubstitution):
272
+ def __init__(self, key: str):
273
+ super().__init__(key)
274
+
275
+ @override
276
+ def cipher(self, text: str, omit_non_alpha: bool = False) -> str:
277
+ result: str = ""
278
+ # We start with our primer (e.g., the shifts for 'LEMON')
279
+ key_sequence = self._get_key_sequence()
280
+ key_index = 0
281
+
282
+ for char in text.upper():
283
+ if char.isalpha():
284
+ # 1. Grab the current shift from our growing list
285
+ current_key_shift = key_sequence[key_index]
286
+
287
+ # 2. Encrypt the letter using the base class table
288
+ column = ord(char) - 65
289
+ result += self.tabula_recta[current_key_shift][column]
290
+
291
+ # 3. Append the PLAINTEXT letter to our key sequence!
292
+ key_sequence.append(column)
293
+ key_index += 1
294
+
295
+ elif not omit_non_alpha or (char in string.whitespace):
296
+ result += char
297
+
298
+ return result
299
+
300
+ @override
301
+ def decipher(self, text: str) -> str:
302
+ result: str = ""
303
+ # The receiver only starts with the primer ('LEMON')
304
+ key_sequence = self._get_key_sequence()
305
+ key_index = 0
306
+
307
+ for text_char in text.upper():
308
+ if text_char.isalpha():
309
+ # 1. Grab the current shift
310
+ current_key_shift = key_sequence[key_index]
311
+
312
+ # 2. Decrypt the ciphertext back to the original letter
313
+ column = self.tabula_recta[current_key_shift].index(text_char)
314
+ decrypted_char = self.tabula_recta[0][column]
315
+ result += decrypted_char
316
+
317
+ # 3. Append the DECRYPTED letter to our key sequence
318
+ # so we can use it to decrypt future letters!
319
+ key_sequence.append(column)
320
+ key_index += 1
321
+ else:
322
+ result += text_char
323
+
324
+ return result
@@ -0,0 +1,122 @@
1
+ import pytest
2
+ from src.retro_ciphers.poly import (
3
+ PolyalphabeticSubstitution, Alberti, Trithemius,
4
+ Vigenere, Beaufort, Autokey
5
+ )
6
+
7
+ class TestPolyalphabeticSubstitution:
8
+ def test_empty_key(self):
9
+ with pytest.raises(ValueError, match="Key must not be empty."):
10
+ PolyalphabeticSubstitution("")
11
+
12
+ def test_invalid_key(self):
13
+ with pytest.raises(ValueError, match="Key must contain at least one letter."):
14
+ Vigenere("123")
15
+
16
+ def test_generate_table(self):
17
+ # We need a dummy subclass since PolyalphabeticSubstitution cannot be initialized without a concrete cipher
18
+ # Wait, the base class can be initialized, it doesn't have abstract subclass barriers in python automatically unless ABC is used
19
+ cipher = PolyalphabeticSubstitution("KEY")
20
+ table = cipher._generate_table()
21
+ assert len(table) == 26
22
+ # Check first row is normal alphabet
23
+ assert "".join(table[0]) == "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
24
+ # Check second row is shifted by 1
25
+ assert "".join(table[1]) == "BCDEFGHIJKLMNOPQRSTUVWXYZA"
26
+ # Check all rows have 26 cols
27
+ assert all(len(row) == 26 for row in table)
28
+
29
+ class TestAlbertiCipher:
30
+ def test_invalid_key_initialization(self):
31
+ with pytest.raises(ValueError, match="must be in the inner disk alphabets"):
32
+ Alberti("A") # 'A' is in outer disk, not inner disk
33
+
34
+ def test_crashing_uninitialized_decipher(self):
35
+ # By default, modern_implementation=True, so "a" is valid inner disk
36
+ cipher = Alberti("a")
37
+ # 'h' is in inner disk. Attempting to decipher without outer disk char first raises KeyError.
38
+ with pytest.raises(KeyError, match="Disk not initialized"):
39
+ cipher.decipher("hello")
40
+
41
+ def test_missing_characters(self):
42
+ cipher = Alberti("a")
43
+ text = "HELLO WORLD ZEBRA! @#"
44
+ enc = cipher.cipher(text)
45
+ # H and W are not in the outer disk alphabets!
46
+ # They get dropped silently into the output, preserving them
47
+ dec = cipher.decipher(enc)
48
+ assert dec == text
49
+
50
+ def test_frequency_rotation(self):
51
+ cipher = Alberti("a", frequency=2)
52
+ # Text with spaces to trigger frequency rotation
53
+ text = "A B C D E F"
54
+ enc = cipher.cipher(text)
55
+ dec = cipher.decipher(enc)
56
+ assert dec == text
57
+
58
+ def test_historical_mode(self):
59
+ # In historical mode, inner disk is "gklnprtuz&xysomqihfdbace", 'a' is valid
60
+ cipher = Alberti("a", modern_implementation=False)
61
+ text = "ABCDEFGHI"
62
+ enc = cipher.cipher(text)
63
+ dec = cipher.decipher(enc)
64
+ assert dec == text
65
+
66
+ def test_historical_invalid_key(self):
67
+ with pytest.raises(ValueError, match="must be in the inner disk alphabets"):
68
+ Alberti("w", modern_implementation=False) # w is not in historical inner disk
69
+
70
+ class TestVigenereCipher:
71
+ def test_basic_cipher(self):
72
+ cipher = Vigenere("LEMON")
73
+ text = "ATTACKATDAWN"
74
+ enc = cipher.cipher(text)
75
+ dec = cipher.decipher(enc)
76
+ assert dec == text
77
+
78
+ def test_non_alpha_characters(self):
79
+ cipher = Vigenere("LEMON")
80
+ text = "ATTACK AT DAWN!"
81
+ enc = cipher.cipher(text)
82
+ dec = cipher.decipher(enc)
83
+ assert dec == text
84
+
85
+ def test_long_mixed_case(self):
86
+ cipher = Vigenere("SeCrEt")
87
+ # Mixed cases are converted to uppercase internally by the cipher/decipher logic
88
+ text = "This is a really long text with mixed CASES and symbols 123 !@#"
89
+ enc = cipher.cipher(text)
90
+ dec = cipher.decipher(enc)
91
+ assert dec == text.upper()
92
+
93
+ class TestTrithemiusCipher:
94
+ def test_basic_cipher(self):
95
+ cipher = Trithemius()
96
+ text = "HELLO WORLD"
97
+ enc = cipher.cipher(text)
98
+ dec = cipher.decipher(enc)
99
+ assert dec == text
100
+
101
+ class TestBeaufortCipher:
102
+ def test_basic_cipher(self):
103
+ cipher = Beaufort("FORTIFICATION")
104
+ text = "DEFENDTHEEASTWALLOFTHECASTLE"
105
+ enc = cipher.cipher(text)
106
+ dec = cipher.decipher(enc)
107
+ assert dec == text
108
+
109
+ class TestAutokeyCipher:
110
+ def test_basic_cipher(self):
111
+ cipher = Autokey("QUEENLY")
112
+ text = "ATTACKATDAWN"
113
+ enc = cipher.cipher(text)
114
+ dec = cipher.decipher(enc)
115
+ assert dec == text
116
+
117
+ def test_punctuation_continuity(self):
118
+ cipher = Autokey("KEY")
119
+ text = "HELLO, WORLD!"
120
+ enc = cipher.cipher(text)
121
+ dec = cipher.decipher(enc)
122
+ assert dec == text.upper()
@@ -0,0 +1,116 @@
1
+
2
+ import pytest
3
+
4
+ from src.retro_ciphers.mono import (
5
+ Atbash,
6
+ Shift,
7
+ Caesar,
8
+ Rot13,
9
+ MixedAlphabet,
10
+ SimpleSubstitution,
11
+ Baconian,
12
+ PolybiusSquare,
13
+ )
14
+
15
+ def test_atbash():
16
+ cipher = Atbash()
17
+ assert cipher.cipher("A") == "Z"
18
+ assert cipher.cipher("a") == "z"
19
+ assert cipher.cipher("Hello World!") == "Svool Dliow!"
20
+ assert cipher.decipher("Svool Dliow!") == "Hello World!"
21
+
22
+ def test_shift():
23
+ cipher = Shift(1)
24
+ assert cipher.cipher("abc") == "bcd"
25
+ assert cipher.cipher("Z") == "A"
26
+ assert cipher.decipher("bcd") == "abc"
27
+
28
+ # Test shift larger than 26
29
+ cipher_wrap = Shift(27)
30
+ assert cipher_wrap.cipher("abc") == "bcd"
31
+
32
+ def test_caesar():
33
+ cipher = Caesar()
34
+ assert cipher.cipher("aBc") == "dEf"
35
+ assert cipher.decipher("dEf") == "aBc"
36
+
37
+ def test_rot13():
38
+ cipher = Rot13()
39
+ assert cipher.cipher("Hello!") == "Uryyb!"
40
+ assert cipher.decipher("Uryyb!") == "Hello!"
41
+
42
+ def test_mixed_alphabet():
43
+ cipher = MixedAlphabet("ZEBRA")
44
+ # ZEBRAcdfghijklmnopqstuvwxy
45
+ # a->Z, b->E, c->B, d->R, e->A, f->c
46
+ assert cipher.cipher("abcde") == "zebra"
47
+ assert cipher.decipher("zebra") == "abcde"
48
+
49
+ # Test keyword normalization (ignoring non-alpha, handling upper/lower cases)
50
+ cipher2 = MixedAlphabet("Zz !eE bB Rr Aa!")
51
+ assert cipher2.cipher("abcde") == "zebra"
52
+ assert cipher2.decipher("zebra") == "abcde"
53
+
54
+ def test_simple_substitution():
55
+ alphabet = "zyxwvutsrqponmlkjihgfedcba" # reversed
56
+ cipher = SimpleSubstitution(alphabet)
57
+ assert cipher.cipher("abc") == "zyx"
58
+ assert cipher.decipher("zyx") == "abc"
59
+
60
+ # Unique check
61
+ with pytest.raises(ValueError):
62
+ SimpleSubstitution("A" * 26)
63
+
64
+ # Length check
65
+ with pytest.raises(ValueError):
66
+ SimpleSubstitution("abc")
67
+
68
+ # Symbols allowed in cipher alphabet (casing might fail to preserve for decipher)
69
+ symbol_alphabet = "!@#$%^&*()_+{}|:\"<>?-=[]\\;',./"[:26]
70
+ cipher_sym = SimpleSubstitution(symbol_alphabet)
71
+ assert cipher_sym.cipher("a") == "!"
72
+
73
+ # Random generation
74
+ random_alphabet = SimpleSubstitution.generate_cipher_alphabet()
75
+ assert len(set(random_alphabet)) == 26
76
+ assert len(random_alphabet) == 26
77
+
78
+ def test_baconian_modern():
79
+ cipher = Baconian(modern_implementation=True)
80
+ # H -> AABBB, e -> aabaa, l -> ababb, l -> ababb, o -> abbba
81
+ assert cipher.cipher("Hello") == "AABBBaabaaababbababbabbba"
82
+ assert cipher.decipher("AABBBaabaaababbababbabbba") == "Hello"
83
+
84
+ assert cipher.cipher("a B") == "aaaaa AAAAB"
85
+ assert cipher.decipher("aaaaa AAAAB") == "a B"
86
+
87
+ # Edge cases
88
+ assert cipher.decipher("aa!aa") == "aa!aa" # incomplete blocks fall back
89
+ assert cipher.decipher("aaaaab") == "ab" # remaining "b" falls back as is
90
+
91
+ def test_baconian_classic():
92
+ cipher = Baconian(modern_implementation=False)
93
+ # I and J are both abaaa.
94
+ assert cipher.cipher("I") == "ABAAA"
95
+ assert cipher.cipher("J") == "ABAAA"
96
+ # Decipher defaults to 'J' or 'I' because it's mapping the same token.
97
+ # With dictionary iteration either might be last, but it maps consistently.
98
+ deciphered_I = cipher.decipher("ABAAA").upper()
99
+ assert deciphered_I in ["I", "J"]
100
+
101
+ def test_polybius_square():
102
+ cipher = PolybiusSquare()
103
+ assert cipher.cipher("aB") == "1112"
104
+ assert cipher.decipher("1112") == "AB" # Polybius deciphers to uppercase
105
+ assert cipher.cipher("I") == "24"
106
+ assert cipher.cipher("J") == "24"
107
+ assert cipher.decipher("24").upper() in ["I", "J"]
108
+
109
+ # Test non-alphabetic/non-numeric correctly falls back
110
+ assert cipher.cipher("a1") == "111"
111
+
112
+ deciphered_spaced = cipher.decipher("11 24")
113
+ assert deciphered_spaced == "A I" or deciphered_spaced == "A J"
114
+
115
+ # Odd length fallback
116
+ assert cipher.decipher("111") == "A1"