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.
- retro_ciphers-1.0.0/.gitignore +4 -0
- retro_ciphers-1.0.0/LICENSE +7 -0
- retro_ciphers-1.0.0/PKG-INFO +116 -0
- retro_ciphers-1.0.0/README.md +92 -0
- retro_ciphers-1.0.0/pyproject.toml +34 -0
- retro_ciphers-1.0.0/src/retro_ciphers/__init__.py +3 -0
- retro_ciphers-1.0.0/src/retro_ciphers/base.py +142 -0
- retro_ciphers-1.0.0/src/retro_ciphers/mono.py +265 -0
- retro_ciphers-1.0.0/src/retro_ciphers/poly.py +324 -0
- retro_ciphers-1.0.0/tests/test_polyalphabetic_cipher.py +122 -0
- retro_ciphers-1.0.0/tests/test_substitution_cipher.py +116 -0
|
@@ -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,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"
|