substitutionciphers 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.
- substitutionciphers-1.0.0/.gitignore +203 -0
- substitutionciphers-1.0.0/PKG-INFO +122 -0
- substitutionciphers-1.0.0/README.md +108 -0
- substitutionciphers-1.0.0/__init__.py +0 -0
- substitutionciphers-1.0.0/pyproject.toml +21 -0
- substitutionciphers-1.0.0/src/substitutionciphers/__init__.py +3 -0
- substitutionciphers-1.0.0/src/substitutionciphers/griffinere.py +145 -0
- substitutionciphers-1.0.0/tests/test_griffinere.py +160 -0
@@ -0,0 +1,203 @@
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
2
|
+
__pycache__/
|
3
|
+
*.py[codz]
|
4
|
+
*$py.class
|
5
|
+
|
6
|
+
# C extensions
|
7
|
+
*.so
|
8
|
+
|
9
|
+
# Distribution / packaging
|
10
|
+
.Python
|
11
|
+
build/
|
12
|
+
develop-eggs/
|
13
|
+
dist/
|
14
|
+
downloads/
|
15
|
+
eggs/
|
16
|
+
.eggs/
|
17
|
+
lib/
|
18
|
+
lib64/
|
19
|
+
parts/
|
20
|
+
sdist/
|
21
|
+
var/
|
22
|
+
wheels/
|
23
|
+
share/python-wheels/
|
24
|
+
*.egg-info/
|
25
|
+
.installed.cfg
|
26
|
+
*.egg
|
27
|
+
MANIFEST
|
28
|
+
|
29
|
+
# PyInstaller
|
30
|
+
# Usually these files are written by a python script from a template
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
32
|
+
*.manifest
|
33
|
+
*.spec
|
34
|
+
|
35
|
+
# Installer logs
|
36
|
+
pip-log.txt
|
37
|
+
pip-delete-this-directory.txt
|
38
|
+
|
39
|
+
# Unit test / coverage reports
|
40
|
+
htmlcov/
|
41
|
+
.tox/
|
42
|
+
.nox/
|
43
|
+
.coverage
|
44
|
+
.coverage.*
|
45
|
+
.cache
|
46
|
+
nosetests.xml
|
47
|
+
coverage.xml
|
48
|
+
*.cover
|
49
|
+
*.py.cover
|
50
|
+
.hypothesis/
|
51
|
+
.pytest_cache/
|
52
|
+
cover/
|
53
|
+
|
54
|
+
# Translations
|
55
|
+
*.mo
|
56
|
+
*.pot
|
57
|
+
|
58
|
+
# Django stuff:
|
59
|
+
*.log
|
60
|
+
local_settings.py
|
61
|
+
db.sqlite3
|
62
|
+
db.sqlite3-journal
|
63
|
+
|
64
|
+
# Flask stuff:
|
65
|
+
instance/
|
66
|
+
.webassets-cache
|
67
|
+
|
68
|
+
# Scrapy stuff:
|
69
|
+
.scrapy
|
70
|
+
|
71
|
+
# Sphinx documentation
|
72
|
+
docs/_build/
|
73
|
+
|
74
|
+
# PyBuilder
|
75
|
+
.pybuilder/
|
76
|
+
target/
|
77
|
+
|
78
|
+
# Jupyter Notebook
|
79
|
+
.ipynb_checkpoints
|
80
|
+
|
81
|
+
# IPython
|
82
|
+
profile_default/
|
83
|
+
ipython_config.py
|
84
|
+
|
85
|
+
# pyenv
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
88
|
+
# .python-version
|
89
|
+
|
90
|
+
# pipenv
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
94
|
+
# install all needed dependencies.
|
95
|
+
#Pipfile.lock
|
96
|
+
|
97
|
+
# UV
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
100
|
+
# commonly ignored for libraries.
|
101
|
+
#uv.lock
|
102
|
+
|
103
|
+
# poetry
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
106
|
+
# commonly ignored for libraries.
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
108
|
+
#poetry.lock
|
109
|
+
#poetry.toml
|
110
|
+
|
111
|
+
# pdm
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
115
|
+
#pdm.lock
|
116
|
+
#pdm.toml
|
117
|
+
.pdm-python
|
118
|
+
.pdm-build/
|
119
|
+
|
120
|
+
# pixi
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
122
|
+
#pixi.lock
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
125
|
+
.pixi
|
126
|
+
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
128
|
+
__pypackages__/
|
129
|
+
|
130
|
+
# Celery stuff
|
131
|
+
celerybeat-schedule
|
132
|
+
celerybeat.pid
|
133
|
+
|
134
|
+
# SageMath parsed files
|
135
|
+
*.sage.py
|
136
|
+
|
137
|
+
# Environments
|
138
|
+
.env
|
139
|
+
.envrc
|
140
|
+
.venv
|
141
|
+
env/
|
142
|
+
venv/
|
143
|
+
ENV/
|
144
|
+
env.bak/
|
145
|
+
venv.bak/
|
146
|
+
|
147
|
+
# Spyder project settings
|
148
|
+
.spyderproject
|
149
|
+
.spyproject
|
150
|
+
|
151
|
+
# Rope project settings
|
152
|
+
.ropeproject
|
153
|
+
|
154
|
+
# mkdocs documentation
|
155
|
+
/site
|
156
|
+
|
157
|
+
# mypy
|
158
|
+
.mypy_cache/
|
159
|
+
.dmypy.json
|
160
|
+
dmypy.json
|
161
|
+
|
162
|
+
# Pyre type checker
|
163
|
+
.pyre/
|
164
|
+
|
165
|
+
# pytype static type analyzer
|
166
|
+
.pytype/
|
167
|
+
|
168
|
+
# Cython debug symbols
|
169
|
+
cython_debug/
|
170
|
+
|
171
|
+
# PyCharm
|
172
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
173
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
174
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
175
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
176
|
+
#.idea/
|
177
|
+
|
178
|
+
# Abstra
|
179
|
+
# Abstra is an AI-powered process automation framework.
|
180
|
+
# Ignore directories containing user credentials, local state, and settings.
|
181
|
+
# Learn more at https://abstra.io/docs
|
182
|
+
.abstra/
|
183
|
+
|
184
|
+
# Visual Studio Code
|
185
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
186
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
187
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
188
|
+
# you could uncomment the following to ignore the entire vscode folder
|
189
|
+
# .vscode/
|
190
|
+
|
191
|
+
# Ruff stuff:
|
192
|
+
.ruff_cache/
|
193
|
+
|
194
|
+
# PyPI configuration file
|
195
|
+
.pypirc
|
196
|
+
|
197
|
+
# Marimo
|
198
|
+
marimo/_static/
|
199
|
+
marimo/_lsp/
|
200
|
+
__marimo__/
|
201
|
+
|
202
|
+
# Streamlit
|
203
|
+
.streamlit/secrets.toml
|
@@ -0,0 +1,122 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: substitutionciphers
|
3
|
+
Version: 1.0.0
|
4
|
+
Summary: The Griffinere cipher is a custom encryption algorithm in C# designed for reversible, base64-normalized encryption using a repeating key. Inspired by the Vigenère cipher, it adds configurable alphabet support, input validation, and padding-based encryption length enforcement.
|
5
|
+
Project-URL: Homepage, https://github.com/RileyG00/Ciphers
|
6
|
+
Project-URL: Source, https://github.com/RileyG00/Ciphers/tree/add-python
|
7
|
+
Author-email: Riley Griffin <riley.griffin00@outlook.com>
|
8
|
+
License: MIT
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
11
|
+
Classifier: Topic :: Security :: Cryptography
|
12
|
+
Requires-Python: >=3.9
|
13
|
+
Description-Content-Type: text/markdown
|
14
|
+
|
15
|
+
# Griffinere Cipher 🔐 — Python Edition
|
16
|
+
|
17
|
+
The **Griffinere** cipher is a reversible, Base‑64‑normalised encryption algorithm implemented in pure **Python**.
|
18
|
+
Inspired by the classic Vigenère cipher, it adds:
|
19
|
+
|
20
|
+
* **Configurable alphabets** (use any character set you like)
|
21
|
+
* **Input validation** for safer usage
|
22
|
+
* **Padding‑based length enforcement** so encrypted strings meet a minimum length
|
23
|
+
|
24
|
+
---
|
25
|
+
|
26
|
+
## 📦 Installation
|
27
|
+
|
28
|
+
```bash
|
29
|
+
pip install substitutionciphers
|
30
|
+
```
|
31
|
+
|
32
|
+
In your code:
|
33
|
+
|
34
|
+
```python
|
35
|
+
from griffinere import Griffinere
|
36
|
+
```
|
37
|
+
|
38
|
+
---
|
39
|
+
|
40
|
+
## ✨ Features
|
41
|
+
|
42
|
+
* 🔐 Encrypts & decrypts alphanumeric or **custom‑alphabet** strings
|
43
|
+
* 🧩 Define your **own alphabet** (emoji? Cyrillic? go ahead!)
|
44
|
+
* 📏 Optional **minimum‑length** padding for fixed‑width ciphertext
|
45
|
+
* ✅ Strong validation of both alphabet and key integrity
|
46
|
+
* 🧪 Unit‑tested with **pytest**
|
47
|
+
|
48
|
+
---
|
49
|
+
|
50
|
+
## 🧰 Usage
|
51
|
+
|
52
|
+
### 1 Create a cipher
|
53
|
+
|
54
|
+
#### 1.1 Default alphabet
|
55
|
+
|
56
|
+
```python
|
57
|
+
key = "YourSecureKey"
|
58
|
+
cipher = Griffinere(key)
|
59
|
+
```
|
60
|
+
|
61
|
+
The built‑in alphabet is:
|
62
|
+
|
63
|
+
```
|
64
|
+
A‑Z a‑z 0‑9
|
65
|
+
```
|
66
|
+
|
67
|
+
#### 1.2 Custom alphabet
|
68
|
+
|
69
|
+
```python
|
70
|
+
custom_alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ12345"
|
71
|
+
key = "YOURKEY"
|
72
|
+
cipher = Griffinere(key, custom_alphabet)
|
73
|
+
```
|
74
|
+
|
75
|
+
##### Alphabet rules
|
76
|
+
|
77
|
+
1. **Must not contain `.`** (dot)
|
78
|
+
2. **All characters must be unique**
|
79
|
+
3. **Every character in the key must appear in the alphabet**
|
80
|
+
|
81
|
+
---
|
82
|
+
|
83
|
+
### 2 Encrypt & decrypt
|
84
|
+
|
85
|
+
#### 2.1 Encrypt a string
|
86
|
+
|
87
|
+
```python
|
88
|
+
plain_text = "Hello World 123"
|
89
|
+
encrypted = cipher.encrypt_string(plain_text)
|
90
|
+
# e.g. 'LUKsbK8 OK9ybKJ FC3z'
|
91
|
+
```
|
92
|
+
|
93
|
+
#### 2.2 Encrypt with a minimum length
|
94
|
+
|
95
|
+
```python
|
96
|
+
encrypted = cipher.encrypt_string(plain_text, minimum_response_length=24)
|
97
|
+
# e.g. 'cm9JbAxsIJg.LUKsbK8 OK9ybKJ FC3z.Fw'
|
98
|
+
```
|
99
|
+
|
100
|
+
#### 3.1 Decrypt
|
101
|
+
|
102
|
+
```python
|
103
|
+
decrypted = cipher.decrypt_string(encrypted)
|
104
|
+
assert decrypted == plain_text
|
105
|
+
```
|
106
|
+
|
107
|
+
---
|
108
|
+
|
109
|
+
## ⚠️ Exceptions & validation
|
110
|
+
|
111
|
+
| Condition | Exception |
|
112
|
+
| ----------------------------------------------- | ------------ |
|
113
|
+
| Alphabet contains `.` | `ValueError` |
|
114
|
+
| Duplicate characters in alphabet | `ValueError` |
|
115
|
+
| Key contains characters not present in alphabet | `ValueError` |
|
116
|
+
| `minimum_response_length` < 1 | `ValueError` |
|
117
|
+
|
118
|
+
---
|
119
|
+
|
120
|
+
## 📄 License
|
121
|
+
|
122
|
+
MIT License © 2025 Riley Griffin
|
@@ -0,0 +1,108 @@
|
|
1
|
+
# Griffinere Cipher 🔐 — Python Edition
|
2
|
+
|
3
|
+
The **Griffinere** cipher is a reversible, Base‑64‑normalised encryption algorithm implemented in pure **Python**.
|
4
|
+
Inspired by the classic Vigenère cipher, it adds:
|
5
|
+
|
6
|
+
* **Configurable alphabets** (use any character set you like)
|
7
|
+
* **Input validation** for safer usage
|
8
|
+
* **Padding‑based length enforcement** so encrypted strings meet a minimum length
|
9
|
+
|
10
|
+
---
|
11
|
+
|
12
|
+
## 📦 Installation
|
13
|
+
|
14
|
+
```bash
|
15
|
+
pip install substitutionciphers
|
16
|
+
```
|
17
|
+
|
18
|
+
In your code:
|
19
|
+
|
20
|
+
```python
|
21
|
+
from griffinere import Griffinere
|
22
|
+
```
|
23
|
+
|
24
|
+
---
|
25
|
+
|
26
|
+
## ✨ Features
|
27
|
+
|
28
|
+
* 🔐 Encrypts & decrypts alphanumeric or **custom‑alphabet** strings
|
29
|
+
* 🧩 Define your **own alphabet** (emoji? Cyrillic? go ahead!)
|
30
|
+
* 📏 Optional **minimum‑length** padding for fixed‑width ciphertext
|
31
|
+
* ✅ Strong validation of both alphabet and key integrity
|
32
|
+
* 🧪 Unit‑tested with **pytest**
|
33
|
+
|
34
|
+
---
|
35
|
+
|
36
|
+
## 🧰 Usage
|
37
|
+
|
38
|
+
### 1 Create a cipher
|
39
|
+
|
40
|
+
#### 1.1 Default alphabet
|
41
|
+
|
42
|
+
```python
|
43
|
+
key = "YourSecureKey"
|
44
|
+
cipher = Griffinere(key)
|
45
|
+
```
|
46
|
+
|
47
|
+
The built‑in alphabet is:
|
48
|
+
|
49
|
+
```
|
50
|
+
A‑Z a‑z 0‑9
|
51
|
+
```
|
52
|
+
|
53
|
+
#### 1.2 Custom alphabet
|
54
|
+
|
55
|
+
```python
|
56
|
+
custom_alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ12345"
|
57
|
+
key = "YOURKEY"
|
58
|
+
cipher = Griffinere(key, custom_alphabet)
|
59
|
+
```
|
60
|
+
|
61
|
+
##### Alphabet rules
|
62
|
+
|
63
|
+
1. **Must not contain `.`** (dot)
|
64
|
+
2. **All characters must be unique**
|
65
|
+
3. **Every character in the key must appear in the alphabet**
|
66
|
+
|
67
|
+
---
|
68
|
+
|
69
|
+
### 2 Encrypt & decrypt
|
70
|
+
|
71
|
+
#### 2.1 Encrypt a string
|
72
|
+
|
73
|
+
```python
|
74
|
+
plain_text = "Hello World 123"
|
75
|
+
encrypted = cipher.encrypt_string(plain_text)
|
76
|
+
# e.g. 'LUKsbK8 OK9ybKJ FC3z'
|
77
|
+
```
|
78
|
+
|
79
|
+
#### 2.2 Encrypt with a minimum length
|
80
|
+
|
81
|
+
```python
|
82
|
+
encrypted = cipher.encrypt_string(plain_text, minimum_response_length=24)
|
83
|
+
# e.g. 'cm9JbAxsIJg.LUKsbK8 OK9ybKJ FC3z.Fw'
|
84
|
+
```
|
85
|
+
|
86
|
+
#### 3.1 Decrypt
|
87
|
+
|
88
|
+
```python
|
89
|
+
decrypted = cipher.decrypt_string(encrypted)
|
90
|
+
assert decrypted == plain_text
|
91
|
+
```
|
92
|
+
|
93
|
+
---
|
94
|
+
|
95
|
+
## ⚠️ Exceptions & validation
|
96
|
+
|
97
|
+
| Condition | Exception |
|
98
|
+
| ----------------------------------------------- | ------------ |
|
99
|
+
| Alphabet contains `.` | `ValueError` |
|
100
|
+
| Duplicate characters in alphabet | `ValueError` |
|
101
|
+
| Key contains characters not present in alphabet | `ValueError` |
|
102
|
+
| `minimum_response_length` < 1 | `ValueError` |
|
103
|
+
|
104
|
+
---
|
105
|
+
|
106
|
+
## 📄 License
|
107
|
+
|
108
|
+
MIT License © 2025 Riley Griffin
|
File without changes
|
@@ -0,0 +1,21 @@
|
|
1
|
+
[build-system]
|
2
|
+
requires = ["hatchling>=1.18"]
|
3
|
+
build-backend = "hatchling.build"
|
4
|
+
|
5
|
+
[project]
|
6
|
+
name = "substitutionciphers"
|
7
|
+
version = "1.0.0"
|
8
|
+
description = "The Griffinere cipher is a custom encryption algorithm in C# designed for reversible, base64-normalized encryption using a repeating key. Inspired by the Vigenère cipher, it adds configurable alphabet support, input validation, and padding-based encryption length enforcement."
|
9
|
+
authors = [{ name = "Riley Griffin", email = "riley.griffin00@outlook.com" }]
|
10
|
+
license = { text = "MIT" }
|
11
|
+
requires-python = ">=3.9"
|
12
|
+
readme = "README.md"
|
13
|
+
classifiers = [
|
14
|
+
"Programming Language :: Python :: 3",
|
15
|
+
"License :: OSI Approved :: MIT License",
|
16
|
+
"Topic :: Security :: Cryptography",
|
17
|
+
]
|
18
|
+
|
19
|
+
[project.urls]
|
20
|
+
Homepage = "https://github.com/RileyG00/Ciphers"
|
21
|
+
Source = "https://github.com/RileyG00/Ciphers/tree/add-python"
|
@@ -0,0 +1,145 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import base64
|
4
|
+
import math
|
5
|
+
from dataclasses import dataclass, field
|
6
|
+
from typing import Dict, List
|
7
|
+
|
8
|
+
|
9
|
+
@dataclass(slots=True)
|
10
|
+
class Griffinere:
|
11
|
+
key: str
|
12
|
+
alphabet: str | None = None
|
13
|
+
|
14
|
+
_alphabet: List[str] = field(init=False, repr=False)
|
15
|
+
_alphabet_length: int = field(init=False, repr=False)
|
16
|
+
_alphabet_position_map: Dict[str, int] = field(init=False, repr=False)
|
17
|
+
_key_chars: List[str] = field(init=False, repr=False)
|
18
|
+
|
19
|
+
def __post_init__(self) -> None:
|
20
|
+
default_alphabet = (
|
21
|
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
22
|
+
"abcdefghijklmnopqrstuvwxyz"
|
23
|
+
"0123456789"
|
24
|
+
)
|
25
|
+
alphabet_str = self.alphabet or default_alphabet
|
26
|
+
self._alphabet = self._validate_alphabet(alphabet_str, self.key)
|
27
|
+
self._alphabet_length = len(self._alphabet)
|
28
|
+
self._alphabet_position_map = {ch: idx for idx, ch in enumerate(self._alphabet)}
|
29
|
+
self._key_chars = list(self.key)
|
30
|
+
|
31
|
+
def encrypt_string(self, plain_text: str, minimum_response_length: int | None = None) -> str:
|
32
|
+
if not plain_text or plain_text.isspace():
|
33
|
+
return ""
|
34
|
+
if minimum_response_length is None:
|
35
|
+
return self._encrypt_segments(plain_text)
|
36
|
+
if minimum_response_length < 1:
|
37
|
+
raise ValueError("minimum_response_length must be greater than zero")
|
38
|
+
need_to_add = minimum_response_length - len(plain_text)
|
39
|
+
if need_to_add <= 0:
|
40
|
+
return self._encrypt_segments(plain_text)
|
41
|
+
pull_from_front = math.ceil(need_to_add / 1.25)
|
42
|
+
pull_from_back = need_to_add - pull_from_front
|
43
|
+
contiguous = plain_text.replace(" ", "") or plain_text
|
44
|
+
string_to_front = self._cycle_take(contiguous, pull_from_front, True)
|
45
|
+
string_to_back = self._cycle_take(contiguous, pull_from_back, False)
|
46
|
+
fragments_front = f"{self._encrypt_segments(string_to_front[::-1])}." if string_to_front else ""
|
47
|
+
fragments_back = f".{self._encrypt_segments(string_to_back)}" if string_to_back else ""
|
48
|
+
core = self._encrypt_segments(plain_text)
|
49
|
+
return f"{fragments_front}{core}{fragments_back}"
|
50
|
+
|
51
|
+
def decrypt_string(self, cipher_text: str) -> str:
|
52
|
+
if not cipher_text or cipher_text.isspace():
|
53
|
+
return ""
|
54
|
+
if "." in cipher_text:
|
55
|
+
parts = cipher_text.split(".")
|
56
|
+
if len(parts) > 2:
|
57
|
+
cipher_text = parts[1]
|
58
|
+
return self._decrypt_segments(cipher_text)
|
59
|
+
|
60
|
+
@staticmethod
|
61
|
+
def _validate_alphabet(alphabet: str, key: str) -> List[str]:
|
62
|
+
if "." in alphabet:
|
63
|
+
raise ValueError("Alphabet must not contain '.'")
|
64
|
+
unique: List[str] = []
|
65
|
+
seen = set()
|
66
|
+
for ch in alphabet:
|
67
|
+
if ch in seen:
|
68
|
+
raise ValueError(f"Duplicate character '{ch}' in provided alphabet.")
|
69
|
+
seen.add(ch)
|
70
|
+
unique.append(ch)
|
71
|
+
for ch in key:
|
72
|
+
if ch not in seen:
|
73
|
+
raise ValueError(f"Alphabet does not contain the character '{ch}' supplied in the key.")
|
74
|
+
return unique
|
75
|
+
|
76
|
+
@staticmethod
|
77
|
+
def _cycle_take(source: str, count: int, front: bool) -> str:
|
78
|
+
if count <= 0 or not source:
|
79
|
+
return ""
|
80
|
+
result: List[str] = []
|
81
|
+
length = len(source)
|
82
|
+
idx = 0
|
83
|
+
while len(result) < count:
|
84
|
+
result.append(source[idx % length] if front else source[-1 - (idx % length)])
|
85
|
+
idx += 1
|
86
|
+
return "".join(result)
|
87
|
+
|
88
|
+
def _encrypt_segments(self, text: str) -> str:
|
89
|
+
return " ".join(self._encrypt_word(word) if word else "" for word in text.split(" "))
|
90
|
+
|
91
|
+
def _decrypt_segments(self, text: str) -> str:
|
92
|
+
return " ".join(self._decrypt_word(word) if word else "" for word in text.split(" "))
|
93
|
+
|
94
|
+
def _encrypt_word(self, word: str) -> str:
|
95
|
+
segment_chars = self._to_base64_char_list(word)
|
96
|
+
key_chars = self._get_key(segment_chars)
|
97
|
+
encrypted = [self._shift_positive(kc, sc) for kc, sc in zip(key_chars, segment_chars)]
|
98
|
+
return "".join(encrypted)
|
99
|
+
|
100
|
+
def _decrypt_word(self, word: str) -> str:
|
101
|
+
segment_chars = list(word)
|
102
|
+
key_chars = self._get_key(segment_chars)
|
103
|
+
decrypted = [self._shift_negative(kc, sc) for kc, sc in zip(key_chars, segment_chars)]
|
104
|
+
return self._from_base64_char_list(decrypted)
|
105
|
+
|
106
|
+
def _shift_positive(self, key_char: str, text_char: str) -> str:
|
107
|
+
key_pos = self._alphabet_position_map.get(key_char)
|
108
|
+
text_pos = self._alphabet_position_map.get(text_char)
|
109
|
+
if key_pos is None or text_pos is None:
|
110
|
+
return text_char
|
111
|
+
return self._alphabet[(key_pos + text_pos) % self._alphabet_length]
|
112
|
+
|
113
|
+
def _shift_negative(self, key_char: str, text_char: str) -> str:
|
114
|
+
key_pos = self._alphabet_position_map.get(key_char)
|
115
|
+
text_pos = self._alphabet_position_map.get(text_char)
|
116
|
+
if key_pos is None or text_pos is None:
|
117
|
+
return text_char
|
118
|
+
return self._alphabet[(text_pos - key_pos + self._alphabet_length) % self._alphabet_length]
|
119
|
+
|
120
|
+
def _get_key(self, segment: List[str]) -> List[str]:
|
121
|
+
if not segment:
|
122
|
+
return []
|
123
|
+
key = list(self._key_chars)
|
124
|
+
while len(key) < len(segment):
|
125
|
+
key.extend(self._key_chars)
|
126
|
+
return key[: len(segment)]
|
127
|
+
|
128
|
+
@staticmethod
|
129
|
+
def _to_base64_char_list(text: str) -> List[str]:
|
130
|
+
if text is None:
|
131
|
+
raise ValueError("text cannot be None")
|
132
|
+
if text == "":
|
133
|
+
return []
|
134
|
+
encoded = base64.b64encode(text.encode()).decode().rstrip("=")
|
135
|
+
return list(encoded)
|
136
|
+
|
137
|
+
@staticmethod
|
138
|
+
def _from_base64_char_list(chars: List[str]) -> str:
|
139
|
+
if not chars:
|
140
|
+
return ""
|
141
|
+
encoded = "".join(chars)
|
142
|
+
padding_needed = (-len(encoded)) % 4
|
143
|
+
encoded += "=" * padding_needed
|
144
|
+
decoded_bytes = base64.b64decode(encoded)
|
145
|
+
return decoded_bytes.decode()
|
@@ -0,0 +1,160 @@
|
|
1
|
+
import sys
|
2
|
+
from pathlib import Path
|
3
|
+
import pytest
|
4
|
+
|
5
|
+
SRC_DIR = Path(__file__).resolve().parents[1] / "src"
|
6
|
+
sys.path.insert(0, str(SRC_DIR))
|
7
|
+
|
8
|
+
from substitutionciphers import Griffinere
|
9
|
+
|
10
|
+
|
11
|
+
# ────────────────────────────────────────────────
|
12
|
+
# Basic round‑trip checks
|
13
|
+
# ────────────────────────────────────────────────
|
14
|
+
|
15
|
+
def test_encrypt_and_decrypt_roundtrip():
|
16
|
+
key = "N3bhd1u6gh6Uh88H083envHwuUSec72i"
|
17
|
+
plaintext = "This is a test of the encryption."
|
18
|
+
cipher = Griffinere(key)
|
19
|
+
|
20
|
+
encrypted = cipher.encrypt_string(plaintext)
|
21
|
+
decrypted = cipher.decrypt_string(encrypted)
|
22
|
+
|
23
|
+
assert decrypted == plaintext
|
24
|
+
|
25
|
+
|
26
|
+
def test_encrypt_and_decrypt_with_custom_alphabet():
|
27
|
+
key = "N3bhd1u6gh6Uh88H083envHwuUSec72i"
|
28
|
+
alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
29
|
+
plaintext = "Hello World 123!"
|
30
|
+
cipher = Griffinere(key, alphabet) # type: ignore[arg-type]
|
31
|
+
|
32
|
+
encrypted = cipher.encrypt_string(plaintext)
|
33
|
+
decrypted = cipher.decrypt_string(encrypted)
|
34
|
+
|
35
|
+
assert decrypted == plaintext
|
36
|
+
|
37
|
+
|
38
|
+
# ────────────────────────────────────────────────
|
39
|
+
# Length‑padding variant
|
40
|
+
# ────────────────────────────────────────────────
|
41
|
+
|
42
|
+
def test_encrypt_string_with_minimum_length():
|
43
|
+
key = "N3bhd1u6gh6Uh88H083envHwuUSec72i"
|
44
|
+
plaintext = "Short text"
|
45
|
+
cipher = Griffinere(key)
|
46
|
+
|
47
|
+
encrypted = cipher.encrypt_string(plaintext, 64)
|
48
|
+
|
49
|
+
assert len(encrypted) >= 64
|
50
|
+
|
51
|
+
|
52
|
+
def test_encrypt_string_with_empty_text():
|
53
|
+
cipher = Griffinere("VkiKMyvu7PT3UV08xZr9X1AA5WiZDzDm")
|
54
|
+
|
55
|
+
assert cipher.encrypt_string("") == ""
|
56
|
+
|
57
|
+
|
58
|
+
# ────────────────────────────────────────────────
|
59
|
+
# Stress‑cases: very long keys / plaintexts
|
60
|
+
# ────────────────────────────────────────────────
|
61
|
+
|
62
|
+
def test_encrypt_string_with_really_long_key_and_message():
|
63
|
+
long_key = (
|
64
|
+
"LEWQcmPaCv9b8HNHJQFuqxDRDCJnQbcXmhQR3wwTuFhSPRUGBSJnj2GrTBSKj3tJTnnSrVC57DHhnik7EUVL8427EQRM6KHxJWenq1Jiy6qzRDchQt5B57izp744yZ0UtK5hngr9cq8kYDJnctwCc3TMk5awiw2HrhwyunyF3hEPk5bfhGmWZE61reeaC7SwH2iRZF9KYdHEwLQ8u1gV72KfPhMLvtca78ff4FcY7W5GeNZbMySUhU4GytTzU4PEHwtkQjRgcAqb7yxjaZT787t0wPZjTiyvdmVCreNm0C7exCFXpR6a4NC7QBQgimCaSWyj1cKZ9xTTML7Wrm6xZD0v5vHSiVKmN79tUpkPPD6TuV73RaTnPcHzqT8YpnujGtJ1jqvGVT6dRdLtbATth1wtLcmnMx5Mc0jLbp6hKicYjVEu7BJyv2mxYcaeyWQvXmj81zPEdnJ3wFz4ngXmT1XiRZwucAt2HMpxq3QaRaNGdA1y759dZqhueFbZn8G4"
|
65
|
+
)
|
66
|
+
|
67
|
+
original_text = (
|
68
|
+
"ikdbr10dbLGm7xtMLkgVhBYVjmkrfAmARyNJXLLbUmvVSTnLMyFWw2vk4tZippWWJGJwhUq9dK6aD5FNJHyje4yzCTiMqjJ26wttnxSbgbNpXAuXKFUECNzDwFj5Dcf1JhqjeA9X6bfTBjY975jSYqrNNje1u1tBNTVjwq3qeMtWVFz9Bj2PxZhWuU99K1R8tedU48uRzjJWdvd18ZSVbwyrTMbGn77FPDAXQirbHiKwcwqXemMVq6tyec7Yc986KNVixV93Da4Z2jS3ERN66WHjhVwMm5yyb9KN81eiCNYWfJZdyp6mBAX2dNuNeBLQr4xP5LNdAFVWg2nn42t9aJNGh1Ep0yr1cGLBcYNXgMwPMqBtJnSLFphhi82zM3YhSeTLbSNchLzjJXu0A5ZhHqddPWc5BmnxtDeZ5tw6uTSy76au4MdTTqR3HcXeAVPuE9fxWSDwxEvh7gRCUBC3bkn7rdUtH8fRJFNLdyYNrNN2SM6C66rdHrhg71d6rGuG"
|
69
|
+
)
|
70
|
+
|
71
|
+
cipher = Griffinere(long_key)
|
72
|
+
|
73
|
+
encrypted = cipher.encrypt_string(original_text)
|
74
|
+
decrypted = cipher.decrypt_string(encrypted)
|
75
|
+
|
76
|
+
assert decrypted == original_text
|
77
|
+
|
78
|
+
|
79
|
+
def test_encrypt_string_with_really_long_key_and_custom_alphabet():
|
80
|
+
long_key = (
|
81
|
+
"a{D{BhT(e&V{4zzpQ=Mjw(Hv5epZt;#wf,A!nNTbeMbdA2x%?NwD3kJ@@$)]/*-q/5x3)/T=_JTzRY$4(ggH!d45CK9R8Vm+y&i8N_Ki+PZ4DA[Cj[fxZ02w%:MV"
|
82
|
+
)
|
83
|
+
alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!\"#$%&'()*+,-/:;<=>?@[]^_`{|}~"
|
84
|
+
original_text = "Testing encryption."
|
85
|
+
|
86
|
+
cipher = Griffinere(long_key, alphabet) # type: ignore[arg-type]
|
87
|
+
|
88
|
+
encrypted = cipher.encrypt_string(original_text)
|
89
|
+
decrypted = cipher.decrypt_string(encrypted)
|
90
|
+
|
91
|
+
assert decrypted == original_text
|
92
|
+
|
93
|
+
|
94
|
+
# ────────────────────────────────────────────────
|
95
|
+
# Escaped / whitespace edge‑cases
|
96
|
+
# ────────────────────────────────────────────────
|
97
|
+
|
98
|
+
def test_encrypt_string_with_escaped_characters():
|
99
|
+
cipher = Griffinere("EuMchtXtJFKhA5H8fGduYPXQEcZJKEAe")
|
100
|
+
original_text = "Testing\nSpecial\tCharacters"
|
101
|
+
|
102
|
+
encrypted = cipher.encrypt_string(original_text)
|
103
|
+
decrypted = cipher.decrypt_string(encrypted)
|
104
|
+
|
105
|
+
assert decrypted == original_text
|
106
|
+
|
107
|
+
|
108
|
+
def test_encrypt_string_with_double_space_character():
|
109
|
+
cipher = Griffinere("dHiNt8C8JY1RhZ26mtYCHByr0WzzfTLm")
|
110
|
+
original_text = "Testing Double Triple Space"
|
111
|
+
|
112
|
+
encrypted = cipher.encrypt_string(original_text)
|
113
|
+
decrypted = cipher.decrypt_string(encrypted)
|
114
|
+
|
115
|
+
assert decrypted == original_text
|
116
|
+
|
117
|
+
|
118
|
+
# ────────────────────────────────────────────────
|
119
|
+
# Constructor validation checks
|
120
|
+
# ────────────────────────────────────────────────
|
121
|
+
|
122
|
+
def test_constructor_with_invalid_alphabet_should_throw():
|
123
|
+
invalid_alphabet = "abc.defghijklmf" # contains '.'
|
124
|
+
key = "A39a3hiirMFAafY1iRBucZxY86AzCeMZ"
|
125
|
+
|
126
|
+
with pytest.raises(ValueError) as exc:
|
127
|
+
Griffinere(key, invalid_alphabet)
|
128
|
+
|
129
|
+
|
130
|
+
|
131
|
+
|
132
|
+
def test_constructor_with_duplicate_alphabet_chars_should_throw():
|
133
|
+
invalid_alphabet = "aabcdefg" # duplicate 'a'
|
134
|
+
key = "abcdefg"
|
135
|
+
|
136
|
+
with pytest.raises(ValueError) as exc:
|
137
|
+
Griffinere(key, invalid_alphabet)
|
138
|
+
|
139
|
+
|
140
|
+
# ────────────────────────────────────────────────
|
141
|
+
# Decryption padding / minimum‑length error cases
|
142
|
+
# ────────────────────────────────────────────────
|
143
|
+
|
144
|
+
def test_decrypt_string_with_dot_prefix_should_still_return_plaintext():
|
145
|
+
key = "dShHPpUQTihcn7ju1wjYTAD1dvbrPKdT"
|
146
|
+
plain_text = ".Padding test case."
|
147
|
+
cipher = Griffinere(key)
|
148
|
+
|
149
|
+
encrypted = cipher.encrypt_string(plain_text, 64)
|
150
|
+
decrypted = cipher.decrypt_string(encrypted)
|
151
|
+
|
152
|
+
assert decrypted == plain_text
|
153
|
+
|
154
|
+
def test_encrypt_string_with_invalid_minimum_length():
|
155
|
+
key = "dShHPpUQTihcn7ju1wjYTAD1dvbrPKdT"
|
156
|
+
|
157
|
+
cipher = Griffinere(key)
|
158
|
+
|
159
|
+
with pytest.raises(ValueError) as exc:
|
160
|
+
encrypted = cipher.encrypt_string("EncryptWithMinLength", 0)
|