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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pycrypt = pycrypt.cli:main
@@ -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))