fromscratch-pycrypt 0.2.0__py3-none-any.whl
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.
- fromscratch_pycrypt-0.2.0.dist-info/METADATA +225 -0
- fromscratch_pycrypt-0.2.0.dist-info/RECORD +16 -0
- fromscratch_pycrypt-0.2.0.dist-info/WHEEL +4 -0
- fromscratch_pycrypt-0.2.0.dist-info/entry_points.txt +2 -0
- fromscratch_pycrypt-0.2.0.dist-info/licenses/LICENSE +21 -0
- pycrypt/__init__.py +1 -0
- pycrypt/_version.py +24 -0
- pycrypt/caesar.py +76 -0
- pycrypt/cli.py +71 -0
- pycrypt/errors.py +13 -0
- pycrypt/hashing.py +56 -0
- pycrypt/hmac.py +67 -0
- pycrypt/kdf.py +49 -0
- pycrypt/py.typed +0 -0
- pycrypt/signatures.py +63 -0
- pycrypt/xor.py +80 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fromscratch-pycrypt
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: An educational cryptography library built from scratch in Python
|
|
5
|
+
Project-URL: Homepage, https://github.com/robcox2910/pycrypt
|
|
6
|
+
Project-URL: Repository, https://github.com/robcox2910/pycrypt
|
|
7
|
+
Project-URL: Issues, https://github.com/robcox2910/pycrypt/issues
|
|
8
|
+
Project-URL: Documentation, https://robcox2910.github.io/pycrypt/
|
|
9
|
+
Author: Rob Cox
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: cryptography,education,encryption,hashing
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Education
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Education
|
|
19
|
+
Classifier: Topic :: Security :: Cryptography
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.14
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: commitizen>=4.4.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: hypothesis>=6.170; extra == 'dev'
|
|
25
|
+
Requires-Dist: pip-audit>=2.10.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pre-commit>=4.0.1; extra == 'dev'
|
|
27
|
+
Requires-Dist: pyright>=1.1.400; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-cov>=6.0.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8.3.4; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.9.3; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# PyCrypt
|
|
34
|
+
|
|
35
|
+
**An educational cryptography library in plain Python.** The classic
|
|
36
|
+
ciphers (Caesar, XOR) are built from scratch; the modern primitives
|
|
37
|
+
(SHA-256, HMAC, PBKDF2) wrap Python's battle-tested `hashlib`/`hmac`
|
|
38
|
+
standard library so you can see how the pieces fit together.
|
|
39
|
+
|
|
40
|
+
> ⚠️ **For LEARNING, not real security.** The Caesar and XOR ciphers
|
|
41
|
+
> and the homemade-style signatures here are easy to break. Never use
|
|
42
|
+
> PyCrypt to protect real passwords, messages, or secrets -- use a
|
|
43
|
+
> vetted library like `cryptography` for that.
|
|
44
|
+
|
|
45
|
+
## What Is Cryptography?
|
|
46
|
+
|
|
47
|
+
Imagine you want to pass a secret note to your friend in class, but you don't
|
|
48
|
+
want anyone else to read it. You could write it in a secret code that only you
|
|
49
|
+
and your friend understand. That's cryptography!
|
|
50
|
+
|
|
51
|
+
Cryptography is the science of keeping information secret and safe. It's used
|
|
52
|
+
every time you:
|
|
53
|
+
|
|
54
|
+
- Send a text message
|
|
55
|
+
- Log in to a website
|
|
56
|
+
- Pay for something online
|
|
57
|
+
- Download an app
|
|
58
|
+
|
|
59
|
+
PyCrypt teaches you how all of this works by building each piece from scratch.
|
|
60
|
+
|
|
61
|
+
## Examples
|
|
62
|
+
|
|
63
|
+
### Hashing -- One-Way Fingerprints
|
|
64
|
+
|
|
65
|
+
A hash turns any text into a fixed-size "fingerprint." You can't reverse it,
|
|
66
|
+
but the same input always gives the same fingerprint.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from pycrypt.hashing import sha256
|
|
70
|
+
|
|
71
|
+
# Same input always gives the same hash
|
|
72
|
+
print(sha256("hello")) # "2cf24dba5fb0a30e..."
|
|
73
|
+
print(sha256("hello")) # "2cf24dba5fb0a30e..." (identical!)
|
|
74
|
+
|
|
75
|
+
# Even a tiny change makes a completely different hash
|
|
76
|
+
print(sha256("Hello")) # "185f8db32271fe25..." (totally different!)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Caesar Cipher -- The Secret Decoder Ring
|
|
80
|
+
|
|
81
|
+
Shift every letter by a number. A becomes D (shift 3), B becomes E, and so on.
|
|
82
|
+
It wraps around, so Z becomes C.
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from pycrypt.caesar import encrypt, decrypt, brute_force
|
|
86
|
+
|
|
87
|
+
secret = encrypt("HELLO WORLD", shift=3)
|
|
88
|
+
print(secret) # "KHOOR ZRUOG"
|
|
89
|
+
|
|
90
|
+
original = decrypt(secret, shift=3)
|
|
91
|
+
print(original) # "HELLO WORLD"
|
|
92
|
+
|
|
93
|
+
# Try all 26 possible shifts to crack it!
|
|
94
|
+
for shift, text in brute_force(secret):
|
|
95
|
+
print(f"Shift {shift}: {text}")
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### XOR Cipher -- Flipping Switches
|
|
99
|
+
|
|
100
|
+
XOR is its own inverse -- encrypt and decrypt use the exact same operation.
|
|
101
|
+
Real ciphers like AES use XOR inside, but a short repeating key like ours is
|
|
102
|
+
easy to break -- **for learning only**.
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from pycrypt.xor import xor_encrypt, xor_encrypt_bytes
|
|
106
|
+
|
|
107
|
+
encrypted = xor_encrypt("secret message", key="mykey")
|
|
108
|
+
print(encrypted) # looks like random bytes
|
|
109
|
+
|
|
110
|
+
# Decrypt by XOR-ing again with the same key
|
|
111
|
+
decrypted = xor_encrypt_bytes(encrypted, b"mykey").decode("utf-8")
|
|
112
|
+
print(decrypted) # "secret message"
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### HMAC -- Tamper-Evident Seals
|
|
116
|
+
|
|
117
|
+
HMAC is like a wax seal on an envelope. If someone changes the message, the
|
|
118
|
+
seal breaks.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from pycrypt.hmac import hmac_sign, hmac_verify
|
|
122
|
+
|
|
123
|
+
tag = hmac_sign("transfer $10", key="shared-secret")
|
|
124
|
+
print(hmac_verify("transfer $10", tag, key="shared-secret")) # True
|
|
125
|
+
print(hmac_verify("transfer $1000", tag, key="shared-secret")) # False!
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Digital Signatures -- A Shared Wax Seal
|
|
129
|
+
|
|
130
|
+
A digital signature proves who wrote a message and that it hasn't been
|
|
131
|
+
changed. Our version is like a wax seal that only you and your friend
|
|
132
|
+
know how to make: you both share one secret key, and that same key is
|
|
133
|
+
used to sign *and* to verify. (Real systems like RSA use a separate
|
|
134
|
+
private key to sign and a public key anyone can verify with.)
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
import os
|
|
138
|
+
from pycrypt.signatures import sign, verify
|
|
139
|
+
|
|
140
|
+
# You and your friend share this one secret key.
|
|
141
|
+
key = os.urandom(32)
|
|
142
|
+
|
|
143
|
+
signature = sign("I wrote this!", key)
|
|
144
|
+
print(verify("I wrote this!", signature, key)) # True
|
|
145
|
+
print(verify("I didn't write this", signature, key)) # False
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Features
|
|
149
|
+
|
|
150
|
+
- **Hashing** -- SHA-256 and MD5 fingerprints
|
|
151
|
+
- **Caesar Cipher** -- Shift-based encryption with brute-force cracking
|
|
152
|
+
- **XOR Cipher** -- Symmetric encryption using bitwise XOR
|
|
153
|
+
- **Key Derivation** -- Turn passwords into strong keys with PBKDF2
|
|
154
|
+
- **HMAC** -- Tamper-proof message authentication codes
|
|
155
|
+
- **Digital Signatures** -- Sign and verify messages with a shared secret key
|
|
156
|
+
- **CLI Tool** -- A demo that prints hashing, Caesar, XOR, and HMAC examples
|
|
157
|
+
- **100% Typed** -- Full type annotations with strict Pyright checking
|
|
158
|
+
|
|
159
|
+
## Quick Start
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
# Install with uv
|
|
163
|
+
uv add pycrypt
|
|
164
|
+
|
|
165
|
+
# Or install from source
|
|
166
|
+
git clone https://github.com/robcox2910/pycrypt.git
|
|
167
|
+
cd pycrypt
|
|
168
|
+
uv sync --all-extras
|
|
169
|
+
|
|
170
|
+
# Run the CLI demo
|
|
171
|
+
uv run pycrypt
|
|
172
|
+
|
|
173
|
+
# Run the tests
|
|
174
|
+
uv run pytest
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
<!-- docs:start -->
|
|
178
|
+
## Documentation
|
|
179
|
+
|
|
180
|
+
Full docs (with kid-friendly explanations of every idea): https://robcox2910.github.io/pycrypt/
|
|
181
|
+
<!-- docs:end -->
|
|
182
|
+
|
|
183
|
+
## Releasing
|
|
184
|
+
|
|
185
|
+
Other projects in the series ([PyStack](https://github.com/robcox2910/pystack)) use a pinned release of this one,
|
|
186
|
+
not whatever is on `main`. To publish a change for them:
|
|
187
|
+
|
|
188
|
+
1. Merge the change to `main`.
|
|
189
|
+
2. Tag it: `git tag -a vX.Y.Z -m vX.Y.Z && git push origin vX.Y.Z`.
|
|
190
|
+
A change to the public API bumps the minor version (`v0.1.0` → `v0.2.0`);
|
|
191
|
+
anything else bumps the patch (`v0.1.0` → `v0.1.1`).
|
|
192
|
+
3. In each project that uses it, edit the tag in `[tool.uv.sources]`, run
|
|
193
|
+
`uv lock --upgrade-package <name>`, and open a pull request so CI checks
|
|
194
|
+
the pair together.
|
|
195
|
+
|
|
196
|
+
<!-- series:start -->
|
|
197
|
+
## Related Projects
|
|
198
|
+
|
|
199
|
+
This project is one of a series that builds real systems "from scratch in Python" to teach how they work. See the [series home](https://robcox2910.github.io/) for the full story.
|
|
200
|
+
|
|
201
|
+
| Project | What it teaches | Repository | Docs |
|
|
202
|
+
| --- | --- | --- | --- |
|
|
203
|
+
| PyOS | Operating systems | [py-os](https://github.com/robcox2910/py-os) | [Docs](https://robcox2910.github.io/py-os/) |
|
|
204
|
+
| Pebble | Compilers and programming languages | [pebble-lang](https://github.com/robcox2910/pebble-lang) | [Docs](https://robcox2910.github.io/pebble-lang/) |
|
|
205
|
+
| PyDB | Relational databases | [pydb](https://github.com/robcox2910/pydb) | [Docs](https://robcox2910.github.io/pydb/) |
|
|
206
|
+
| PyKV | Key-value stores | [pykv](https://github.com/robcox2910/pykv) | [Docs](https://robcox2910.github.io/pykv/) |
|
|
207
|
+
| PyDocDB | Document databases | [pydoc-db](https://github.com/robcox2910/pydoc-db) | [Docs](https://robcox2910.github.io/pydoc-db/) |
|
|
208
|
+
| PyGraphDB | Graph databases | [pygraphdb](https://github.com/robcox2910/pygraphdb) | [Docs](https://robcox2910.github.io/pygraphdb/) |
|
|
209
|
+
| PyTSDB | Time-series databases | [pytsdb](https://github.com/robcox2910/pytsdb) | [Docs](https://robcox2910.github.io/pytsdb/) |
|
|
210
|
+
| PyVecDB | Vector databases | [pyvecdb](https://github.com/robcox2910/pyvecdb) | [Docs](https://robcox2910.github.io/pyvecdb/) |
|
|
211
|
+
| PyColDB | Column-family databases | [pycoldb](https://github.com/robcox2910/pycoldb) | [Docs](https://robcox2910.github.io/pycoldb/) |
|
|
212
|
+
| **PyCrypt** | **Cryptography** | [pycrypt](https://github.com/robcox2910/pycrypt) | [Docs](https://robcox2910.github.io/pycrypt/) |
|
|
213
|
+
| PyNet | Computer networking | [pynet](https://github.com/robcox2910/pynet) | [Docs](https://robcox2910.github.io/pynet/) |
|
|
214
|
+
| PyWeb | HTTP web servers | [pyweb](https://github.com/robcox2910/pyweb) | [Docs](https://robcox2910.github.io/pyweb/) |
|
|
215
|
+
| PyGit | Version control | [pygit](https://github.com/robcox2910/pygit) | [Docs](https://robcox2910.github.io/pygit/) |
|
|
216
|
+
| PySearch | Search engines | [pysearch](https://github.com/robcox2910/pysearch) | [Docs](https://robcox2910.github.io/pysearch/) |
|
|
217
|
+
| PyMQ | Message queues | [pymq](https://github.com/robcox2910/pymq) | [Docs](https://robcox2910.github.io/pymq/) |
|
|
218
|
+
| PyLLM | Language models (the brain) | [pyllm](https://github.com/robcox2910/pyllm) | [Docs](https://robcox2910.github.io/pyllm/) |
|
|
219
|
+
| PyScheduler | Job scheduling | [pyscheduler](https://github.com/robcox2910/pyscheduler) | [Docs](https://robcox2910.github.io/pyscheduler/) |
|
|
220
|
+
| PyStack | Full-stack integration | [pystack](https://github.com/robcox2910/pystack) | [Docs](https://robcox2910.github.io/pystack/) |
|
|
221
|
+
<!-- series:end -->
|
|
222
|
+
|
|
223
|
+
## License
|
|
224
|
+
|
|
225
|
+
MIT -- see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
pycrypt/__init__.py,sha256=sy4ZKLA3Wz3znIU8nbtORrrkZHrQ4akfCBcKRcElkh8,72
|
|
2
|
+
pycrypt/_version.py,sha256=s9U3X54Pdr-Jlh9GP6LBaa_VRos7qs_wtrVefUHHNIA,520
|
|
3
|
+
pycrypt/caesar.py,sha256=dMf_ZCF3bTnQH2sBg14tlfqRT_y0bUwCm3JXxhdHj28,2216
|
|
4
|
+
pycrypt/cli.py,sha256=yvnfzluEtF6nNBi1yiF0C8oSaZOxVY86TDOdBqMuvdY,2583
|
|
5
|
+
pycrypt/errors.py,sha256=jTbWRG-DLwPvoubN7jEGUkRvYpqdTiQP6DUUizVVnIo,339
|
|
6
|
+
pycrypt/hashing.py,sha256=LL4F-WYQzHtD_tsmT1kP-tXEn5vWfofQNMvz18Q5P7I,1338
|
|
7
|
+
pycrypt/hmac.py,sha256=013VIpxZhdNRiE61JHeSU8QwdOWaunz_WWEECkib7eA,1716
|
|
8
|
+
pycrypt/kdf.py,sha256=_FD_RL3XcvBpuebfEWkeiLGBqFp2Nllg6TaTBD2Ihao,1349
|
|
9
|
+
pycrypt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
pycrypt/signatures.py,sha256=kaxStD6za2fkxZu9NNinlH9FddqCpoeI51uLpOpwWTU,2205
|
|
11
|
+
pycrypt/xor.py,sha256=MUpGxASrtz_8Yoz2JKWZQLWicaI9R7jdG4Rfck1Xwwo,2553
|
|
12
|
+
fromscratch_pycrypt-0.2.0.dist-info/METADATA,sha256=5SEOI3F45-IgIPd2hypXy1bBY2ytH-cqLV4__45B8gY,9220
|
|
13
|
+
fromscratch_pycrypt-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
14
|
+
fromscratch_pycrypt-0.2.0.dist-info/entry_points.txt,sha256=xYU5q3ieiachWFTRl7RvxcXzkPLeWigpmci-j17knm8,45
|
|
15
|
+
fromscratch_pycrypt-0.2.0.dist-info/licenses/LICENSE,sha256=NtHM2ak--oE71VCcY50kcCoC1cKS0LRUsxq_xA89RNc,1064
|
|
16
|
+
fromscratch_pycrypt-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rob Cox
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pycrypt/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""PyCrypt -- educational cryptography built from scratch in Python."""
|
pycrypt/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.2.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
pycrypt/caesar.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Caesar cipher -- the secret decoder ring.
|
|
2
|
+
|
|
3
|
+
The simplest cipher: shift every letter by a fixed number. A becomes
|
|
4
|
+
D (shift 3), B becomes E, and so on. It wraps around: X becomes A.
|
|
5
|
+
|
|
6
|
+
Only the 26 letters of the English alphabet move. Everything else --
|
|
7
|
+
spaces, digits, punctuation, and letters from other alphabets like
|
|
8
|
+
"À" or "Σ" -- is left exactly as it was.
|
|
9
|
+
|
|
10
|
+
This is easy to break (only 26 possible shifts) but it's the perfect
|
|
11
|
+
starting point for understanding encryption.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
ALPHABET_SIZE = 26
|
|
15
|
+
UPPER_A = ord("A")
|
|
16
|
+
LOWER_A = ord("a")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def encrypt(plaintext: str, shift: int) -> str:
|
|
20
|
+
"""Encrypt a message using the Caesar cipher.
|
|
21
|
+
|
|
22
|
+
Shift each letter A-Z (or a-z) by the given amount. Everything else
|
|
23
|
+
(spaces, numbers, punctuation, accented letters) is left unchanged.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
plaintext: The message to encrypt.
|
|
27
|
+
shift: How many positions to shift. Any whole number works:
|
|
28
|
+
27 does the same as 1, and -1 the same as 25.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
The encrypted message.
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
result: list[str] = []
|
|
35
|
+
for char in plaintext:
|
|
36
|
+
if "A" <= char <= "Z":
|
|
37
|
+
shifted = (ord(char) - UPPER_A + shift) % ALPHABET_SIZE
|
|
38
|
+
result.append(chr(shifted + UPPER_A))
|
|
39
|
+
elif "a" <= char <= "z":
|
|
40
|
+
shifted = (ord(char) - LOWER_A + shift) % ALPHABET_SIZE
|
|
41
|
+
result.append(chr(shifted + LOWER_A))
|
|
42
|
+
else:
|
|
43
|
+
result.append(char)
|
|
44
|
+
return "".join(result)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def decrypt(ciphertext: str, shift: int) -> str:
|
|
48
|
+
"""Decrypt a Caesar cipher message.
|
|
49
|
+
|
|
50
|
+
Shift each letter backwards by the given amount.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
ciphertext: The encrypted message.
|
|
54
|
+
shift: The shift that was used to encrypt.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
The original message.
|
|
58
|
+
|
|
59
|
+
"""
|
|
60
|
+
return encrypt(ciphertext, -shift)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def brute_force(ciphertext: str) -> list[tuple[int, str]]:
|
|
64
|
+
"""Try all 26 possible shifts and return the results.
|
|
65
|
+
|
|
66
|
+
This shows why the Caesar cipher is weak -- you can break it by
|
|
67
|
+
trying every possible key.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
ciphertext: The encrypted message.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
A list of (shift, decrypted_text) tuples.
|
|
74
|
+
|
|
75
|
+
"""
|
|
76
|
+
return [(shift, decrypt(ciphertext, shift)) for shift in range(ALPHABET_SIZE)]
|
pycrypt/cli.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""PyCrypt CLI -- demonstrate cryptography concepts."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from pycrypt.caesar import decrypt as caesar_decrypt
|
|
6
|
+
from pycrypt.caesar import encrypt as caesar_encrypt
|
|
7
|
+
from pycrypt.hashing import sha256
|
|
8
|
+
from pycrypt.hmac import hmac_sign, hmac_verify
|
|
9
|
+
from pycrypt.xor import xor_decrypt, xor_encrypt
|
|
10
|
+
|
|
11
|
+
CAESAR_SHIFT = 3
|
|
12
|
+
SEPARATOR = "-" * 50
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _demo_hashing() -> None:
|
|
16
|
+
"""Demonstrate SHA-256 hashing."""
|
|
17
|
+
sys.stdout.write("1. HASHING (SHA-256)\n")
|
|
18
|
+
sys.stdout.write(SEPARATOR + "\n")
|
|
19
|
+
message = "Hello, World!"
|
|
20
|
+
digest = sha256(message)
|
|
21
|
+
sys.stdout.write(f' sha256("{message}") = {digest}\n')
|
|
22
|
+
sys.stdout.write(f' sha256("{message}") = {sha256(message)} (same!)\n')
|
|
23
|
+
sys.stdout.write(f' sha256("hello, world!") = {sha256("hello, world!")} (different!)\n\n')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _demo_caesar() -> None:
|
|
27
|
+
"""Demonstrate Caesar cipher encryption."""
|
|
28
|
+
sys.stdout.write("2. CAESAR CIPHER\n")
|
|
29
|
+
sys.stdout.write(SEPARATOR + "\n")
|
|
30
|
+
plaintext = "HELLO WORLD"
|
|
31
|
+
encrypted = caesar_encrypt(plaintext, CAESAR_SHIFT)
|
|
32
|
+
decrypted = caesar_decrypt(encrypted, CAESAR_SHIFT)
|
|
33
|
+
sys.stdout.write(f" Plaintext: {plaintext}\n")
|
|
34
|
+
sys.stdout.write(f" Encrypted: {encrypted} (shift {CAESAR_SHIFT})\n")
|
|
35
|
+
sys.stdout.write(f" Decrypted: {decrypted}\n\n")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _demo_xor() -> None:
|
|
39
|
+
"""Demonstrate XOR encryption."""
|
|
40
|
+
sys.stdout.write("3. XOR CIPHER\n")
|
|
41
|
+
sys.stdout.write(SEPARATOR + "\n")
|
|
42
|
+
message = "secret"
|
|
43
|
+
key = "mykey"
|
|
44
|
+
encrypted = xor_encrypt(message, key)
|
|
45
|
+
decrypted = xor_decrypt(encrypted, key)
|
|
46
|
+
sys.stdout.write(f" Plaintext: {message}\n")
|
|
47
|
+
sys.stdout.write(f' Encrypted: {encrypted.hex()} (key "{key}")\n')
|
|
48
|
+
sys.stdout.write(f" Decrypted: {decrypted} (XOR again with the same key)\n\n")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _demo_hmac() -> None:
|
|
52
|
+
"""Demonstrate HMAC signing and verification."""
|
|
53
|
+
sys.stdout.write("4. HMAC (Message Authentication)\n")
|
|
54
|
+
sys.stdout.write(SEPARATOR + "\n")
|
|
55
|
+
message = "transfer $10"
|
|
56
|
+
key = "shared-secret"
|
|
57
|
+
tag = hmac_sign(message, key)
|
|
58
|
+
sys.stdout.write(f" Message: {message}\n")
|
|
59
|
+
sys.stdout.write(f" HMAC tag: {tag}\n")
|
|
60
|
+
sys.stdout.write(f" Verify original: {hmac_verify(message, tag, key)}\n")
|
|
61
|
+
sys.stdout.write(f" Verify tampered: {hmac_verify('transfer $1000', tag, key)}\n\n")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main() -> None:
|
|
65
|
+
"""Run all cryptography demos."""
|
|
66
|
+
sys.stdout.write("PyCrypt -- Cryptography from scratch\n\n")
|
|
67
|
+
_demo_hashing()
|
|
68
|
+
_demo_caesar()
|
|
69
|
+
_demo_xor()
|
|
70
|
+
_demo_hmac()
|
|
71
|
+
sys.stdout.write("Learn more: https://robcox2910.github.io/pycrypt/\n")
|
pycrypt/errors.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Custom exceptions for PyCrypt.
|
|
2
|
+
|
|
3
|
+
Clear error messages help you understand what went wrong -- especially
|
|
4
|
+
important when dealing with secret messages!
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PyCryptError(Exception):
|
|
9
|
+
"""Base exception for all PyCrypt errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VerificationError(PyCryptError):
|
|
13
|
+
"""Raise when a signature or HMAC verification fails."""
|
pycrypt/hashing.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Hashing -- one-way fingerprints for data.
|
|
2
|
+
|
|
3
|
+
A hash function is like a meat grinder: put any data in, get a
|
|
4
|
+
fixed-size fingerprint out. You can't reverse it, but the same input
|
|
5
|
+
always gives the same fingerprint.
|
|
6
|
+
|
|
7
|
+
We use SHA-256 (used by Bitcoin, TLS, and most of the internet) from
|
|
8
|
+
Python's built-in ``hashlib``. Writing SHA-256 itself from scratch is a
|
|
9
|
+
big project of its own -- here we focus on what hashes are *for*.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
|
|
14
|
+
SHA256_HEX_LENGTH = 64
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def sha256(data: str) -> str:
|
|
18
|
+
"""Calculate the SHA-256 hash of a string.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
data: The text to hash.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
A 64-character hexadecimal hash string.
|
|
25
|
+
|
|
26
|
+
"""
|
|
27
|
+
return hashlib.sha256(data.encode("utf-8")).hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sha256_bytes(data: bytes) -> str:
|
|
31
|
+
"""Calculate the SHA-256 hash of raw bytes.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
data: The bytes to hash.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
A 64-character hexadecimal hash string.
|
|
38
|
+
|
|
39
|
+
"""
|
|
40
|
+
return hashlib.sha256(data).hexdigest()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def md5(data: str) -> str:
|
|
44
|
+
"""Calculate the MD5 hash of a string.
|
|
45
|
+
|
|
46
|
+
MD5 is broken for security but useful for checksums and teaching.
|
|
47
|
+
Never use MD5 for passwords or security.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
data: The text to hash.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
A 32-character hexadecimal hash string.
|
|
54
|
+
|
|
55
|
+
"""
|
|
56
|
+
return hashlib.md5(data.encode("utf-8")).hexdigest() # noqa: S324
|
pycrypt/hmac.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""HMAC -- tamper-evident seals for messages.
|
|
2
|
+
|
|
3
|
+
HMAC (Hash-based Message Authentication Code) combines a message with
|
|
4
|
+
a secret key to produce a tag. If the message changes, the tag won't
|
|
5
|
+
match -- like a broken seal on a jar tells you someone opened it.
|
|
6
|
+
|
|
7
|
+
Both sender and receiver need the same secret key.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac as _hmac
|
|
12
|
+
|
|
13
|
+
from pycrypt.errors import VerificationError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def hmac_sign(message: str, key: str) -> str:
|
|
17
|
+
"""Create an HMAC tag for a message.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
message: The message to authenticate.
|
|
21
|
+
key: The shared secret key.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
A hex-encoded HMAC tag.
|
|
25
|
+
|
|
26
|
+
"""
|
|
27
|
+
tag = _hmac.new(
|
|
28
|
+
key.encode("utf-8"),
|
|
29
|
+
message.encode("utf-8"),
|
|
30
|
+
hashlib.sha256,
|
|
31
|
+
)
|
|
32
|
+
return tag.hexdigest()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def hmac_verify(message: str, tag: str, key: str) -> bool:
|
|
36
|
+
"""Verify that a message matches its HMAC tag.
|
|
37
|
+
|
|
38
|
+
Uses constant-time comparison to prevent timing attacks.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
message: The message to verify.
|
|
42
|
+
tag: The HMAC tag to check against.
|
|
43
|
+
key: The shared secret key.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
True if the message is authentic and unmodified.
|
|
47
|
+
|
|
48
|
+
"""
|
|
49
|
+
expected = hmac_sign(message, key)
|
|
50
|
+
return _hmac.compare_digest(expected, tag)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def hmac_verify_or_raise(message: str, tag: str, key: str) -> None:
|
|
54
|
+
"""Verify a message or raise VerificationError.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
message: The message to verify.
|
|
58
|
+
tag: The HMAC tag to check.
|
|
59
|
+
key: The shared secret key.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
VerificationError: If verification fails.
|
|
63
|
+
|
|
64
|
+
"""
|
|
65
|
+
if not hmac_verify(message, tag, key):
|
|
66
|
+
msg = "HMAC verification failed -- message may have been tampered with"
|
|
67
|
+
raise VerificationError(msg)
|
pycrypt/kdf.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Key derivation -- turning passwords into strong keys.
|
|
2
|
+
|
|
3
|
+
A password like "pizza123" is too short and predictable for encryption.
|
|
4
|
+
Key derivation mixes it with salt (random data) and many rounds of
|
|
5
|
+
hashing to produce a long, strong, unpredictable key.
|
|
6
|
+
|
|
7
|
+
We use PBKDF2 (Password-Based Key Derivation Function 2), the same
|
|
8
|
+
algorithm used by Wi-Fi (WPA2), macOS login, and many others.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
DEFAULT_KEY_LENGTH = 32 # 256 bits
|
|
15
|
+
DEFAULT_ROUNDS = 100_000
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def derive_key(
|
|
19
|
+
password: str,
|
|
20
|
+
salt: bytes | None = None,
|
|
21
|
+
key_length: int = DEFAULT_KEY_LENGTH,
|
|
22
|
+
rounds: int = DEFAULT_ROUNDS,
|
|
23
|
+
) -> tuple[bytes, bytes]:
|
|
24
|
+
"""Derive a cryptographic key from a password.
|
|
25
|
+
|
|
26
|
+
Uses PBKDF2-HMAC-SHA256 with a random salt.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
password: The password to derive from.
|
|
30
|
+
salt: Random salt bytes (generated if not provided).
|
|
31
|
+
key_length: Length of the derived key in bytes.
|
|
32
|
+
rounds: Number of hash iterations (more = slower + safer).
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
A tuple of (derived_key, salt). Store the salt alongside
|
|
36
|
+
the key so you can re-derive it later.
|
|
37
|
+
|
|
38
|
+
"""
|
|
39
|
+
if salt is None:
|
|
40
|
+
salt = os.urandom(16)
|
|
41
|
+
|
|
42
|
+
key = hashlib.pbkdf2_hmac(
|
|
43
|
+
"sha256",
|
|
44
|
+
password.encode("utf-8"),
|
|
45
|
+
salt,
|
|
46
|
+
iterations=rounds,
|
|
47
|
+
dklen=key_length,
|
|
48
|
+
)
|
|
49
|
+
return key, salt
|
pycrypt/py.typed
ADDED
|
File without changes
|
pycrypt/signatures.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Digital signatures -- a wax seal only you and your friend can make.
|
|
2
|
+
|
|
3
|
+
FOR LEARNING ONLY -- never use this to protect real secrets.
|
|
4
|
+
|
|
5
|
+
Imagine a wax seal that only you and your best friend know how to
|
|
6
|
+
press -- because you both share the same secret stamp. When your
|
|
7
|
+
friend gets a letter with that seal, they know it's really from you
|
|
8
|
+
(authenticity) and that no one changed it on the way (integrity).
|
|
9
|
+
|
|
10
|
+
Our educational version works exactly like that shared stamp: the
|
|
11
|
+
*same* secret key is used to both make (sign) and check (verify) the
|
|
12
|
+
seal. Under the hood we reuse HMAC, so the "seal" really is an HMAC
|
|
13
|
+
tag. Real signature systems like RSA and ECDSA use a *pair* of keys --
|
|
14
|
+
a private one to sign and a public one anyone can verify with -- so
|
|
15
|
+
the whole world can check a signature without knowing your secret.
|
|
16
|
+
That needs advanced math we'll learn later.
|
|
17
|
+
|
|
18
|
+
The key ideas are the same:
|
|
19
|
+
- Only someone with the shared key can create a valid signature
|
|
20
|
+
- Verification checks that the signature matches the message
|
|
21
|
+
- Changing the message breaks the signature
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import hmac as _hmac
|
|
25
|
+
|
|
26
|
+
from pycrypt.hmac import hmac_sign
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def sign(message: str, key: bytes) -> str:
|
|
30
|
+
"""Sign a message -- press the shared wax seal.
|
|
31
|
+
|
|
32
|
+
Create a signature that proves the message is authentic and
|
|
33
|
+
hasn't been changed. Only someone with the key can produce a
|
|
34
|
+
valid signature. Internally this is an HMAC tag over the message.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
message: The message to sign.
|
|
38
|
+
key: The secret key (shared between signer and verifier).
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The signature as a hex string.
|
|
42
|
+
|
|
43
|
+
"""
|
|
44
|
+
return hmac_sign(message, key.hex())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def verify(message: str, signature: str, key: bytes) -> bool:
|
|
48
|
+
"""Verify a signature -- check the wax seal is genuine.
|
|
49
|
+
|
|
50
|
+
Recompute what the signature should be and check if it matches.
|
|
51
|
+
Uses constant-time comparison to prevent timing attacks.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
message: The message that was supposedly signed.
|
|
55
|
+
signature: The hex signature to verify.
|
|
56
|
+
key: The secret key (same key used for signing).
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
True if the signature is valid.
|
|
60
|
+
|
|
61
|
+
"""
|
|
62
|
+
expected = sign(message, key)
|
|
63
|
+
return _hmac.compare_digest(signature, expected)
|
pycrypt/xor.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""XOR cipher -- flipping switches to hide data.
|
|
2
|
+
|
|
3
|
+
FOR LEARNING ONLY -- never use this to protect real secrets.
|
|
4
|
+
|
|
5
|
+
XOR is its own inverse: encrypt and decrypt use the same operation.
|
|
6
|
+
Each byte of the message is XOR'd with a byte from the key. The key
|
|
7
|
+
repeats if it's shorter than the message.
|
|
8
|
+
|
|
9
|
+
Why is that unsafe? Because the key *repeats*, the same pattern of
|
|
10
|
+
switch-flips happens again and again, and patterns are a codebreaker's
|
|
11
|
+
best friend. If an attacker can guess even a few letters of the message
|
|
12
|
+
(every letter starts with "Dear..."), XOR-ing those guesses with the
|
|
13
|
+
secret text hands them that part of the key -- and the key repeats.
|
|
14
|
+
|
|
15
|
+
XOR itself is a genuine building block: real ciphers like AES and
|
|
16
|
+
ChaCha20 use it inside. What makes them safe is how they produce a long,
|
|
17
|
+
never-repeating key stream -- which is the hard part we skip here.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from pycrypt.errors import PyCryptError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def xor_encrypt(data: str, key: str) -> bytes:
|
|
24
|
+
"""Encrypt text using XOR with a repeating key.
|
|
25
|
+
|
|
26
|
+
To turn the result back into text, use :func:`xor_decrypt` -- it does
|
|
27
|
+
the very same XOR again, then reads the bytes back as text.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
data: The text to encrypt.
|
|
31
|
+
key: The encryption key.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The XOR'd result as bytes.
|
|
35
|
+
|
|
36
|
+
"""
|
|
37
|
+
data_bytes = data.encode("utf-8")
|
|
38
|
+
key_bytes = key.encode("utf-8")
|
|
39
|
+
return _xor_bytes(data_bytes, key_bytes)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def xor_decrypt(ciphertext: bytes, key: str) -> str:
|
|
43
|
+
"""Decrypt bytes made by :func:`xor_encrypt`, back into text.
|
|
44
|
+
|
|
45
|
+
XOR is its own undo button: flipping the same switches twice puts them
|
|
46
|
+
all back where they started. So decrypting is just XOR-ing again with
|
|
47
|
+
the same key.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
ciphertext: The bytes returned by ``xor_encrypt``.
|
|
51
|
+
key: The same key that was used to encrypt.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The original text (unreadable gibberish if the key is wrong).
|
|
55
|
+
|
|
56
|
+
"""
|
|
57
|
+
return _xor_bytes(ciphertext, key.encode("utf-8")).decode("utf-8", errors="replace")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def xor_encrypt_bytes(data: bytes, key: bytes) -> bytes:
|
|
61
|
+
"""Encrypt (or decrypt) raw bytes using XOR with a repeating key.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
data: The bytes to encrypt.
|
|
65
|
+
key: The key bytes.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The XOR'd result.
|
|
69
|
+
|
|
70
|
+
"""
|
|
71
|
+
return _xor_bytes(data, key)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _xor_bytes(data: bytes, key: bytes) -> bytes:
|
|
75
|
+
"""XOR each byte of data with a repeating key."""
|
|
76
|
+
if len(key) == 0:
|
|
77
|
+
msg = "Key must not be empty"
|
|
78
|
+
raise PyCryptError(msg)
|
|
79
|
+
key_len = len(key)
|
|
80
|
+
return bytes(b ^ key[i % key_len] for i, b in enumerate(data))
|