alt-python-pysypt 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.
- alt_python_pysypt-1.0.0/.gitignore +30 -0
- alt_python_pysypt-1.0.0/PKG-INFO +7 -0
- alt_python_pysypt-1.0.0/README.md +261 -0
- alt_python_pysypt-1.0.0/pyproject.toml +22 -0
- alt_python_pysypt-1.0.0/pysypt/__init__.py +31 -0
- alt_python_pysypt-1.0.0/pysypt/digester.py +176 -0
- alt_python_pysypt-1.0.0/pysypt/encryptor.py +336 -0
- alt_python_pysypt-1.0.0/pysypt/jasypt.py +79 -0
- alt_python_pysypt-1.0.0/tests/test_jasypt.py +220 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
|
|
2
|
+
# ── GSD baseline (auto-generated) ──
|
|
3
|
+
.gsd
|
|
4
|
+
.DS_Store
|
|
5
|
+
Thumbs.db
|
|
6
|
+
*.swp
|
|
7
|
+
*.swo
|
|
8
|
+
*~
|
|
9
|
+
.idea/
|
|
10
|
+
.vscode/
|
|
11
|
+
*.code-workspace
|
|
12
|
+
.env
|
|
13
|
+
.env.*
|
|
14
|
+
!.env.example
|
|
15
|
+
node_modules/
|
|
16
|
+
.next/
|
|
17
|
+
dist/
|
|
18
|
+
build/
|
|
19
|
+
__pycache__/
|
|
20
|
+
*.pyc
|
|
21
|
+
.venv/
|
|
22
|
+
venv/
|
|
23
|
+
target/
|
|
24
|
+
vendor/
|
|
25
|
+
*.log
|
|
26
|
+
coverage/
|
|
27
|
+
.cache/
|
|
28
|
+
tmp/
|
|
29
|
+
.bg_shell
|
|
30
|
+
/.bg-shell/
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# pysypt
|
|
2
|
+
|
|
3
|
+
[](https://www.python.org/)
|
|
4
|
+
[](https://www.python.org/downloads/)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
Jasypt-compatible password-based encryption (PBE) and iterated-hash digest for
|
|
8
|
+
Python. Interoperable with Spring Boot applications that use `ENC(...)` encrypted
|
|
9
|
+
configuration values.
|
|
10
|
+
|
|
11
|
+
Port of [`@alt-javascript/jasypt`](https://github.com/alt-javascript/jasypt) to
|
|
12
|
+
Python.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
uv add pysypt # or: pip install pysypt
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Requires Python 3.12+ and `cryptography` >= 42.
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from pysypt import Jasypt
|
|
26
|
+
|
|
27
|
+
jasypt = Jasypt()
|
|
28
|
+
|
|
29
|
+
# Encrypt and decrypt
|
|
30
|
+
ciphertext = jasypt.encrypt("admin", "mySecretKey")
|
|
31
|
+
plaintext = jasypt.decrypt(ciphertext, "mySecretKey")
|
|
32
|
+
# plaintext == "admin"
|
|
33
|
+
|
|
34
|
+
# One-way digest
|
|
35
|
+
stored = jasypt.digest("admin")
|
|
36
|
+
jasypt.matches("admin", stored) # True
|
|
37
|
+
jasypt.matches("wrong", stored) # False
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## API
|
|
41
|
+
|
|
42
|
+
### `Jasypt`
|
|
43
|
+
|
|
44
|
+
High-level facade. Each method constructs a fresh `Encryptor` or `Digester`
|
|
45
|
+
internally — the `Jasypt` class is stateless and thread-safe.
|
|
46
|
+
|
|
47
|
+
#### `jasypt.encrypt(message, password, algorithm="PBEWITHMD5ANDDES", iterations=1000, salt=None)`
|
|
48
|
+
|
|
49
|
+
Encrypts a plaintext string. Returns a base64-encoded ciphertext with the salt
|
|
50
|
+
prepended.
|
|
51
|
+
|
|
52
|
+
Returns `None` if `message` is empty or `None`.
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
jasypt.encrypt("admin", "secret")
|
|
56
|
+
# => "nsbC5r0ymz740/aURtuRWw=="
|
|
57
|
+
|
|
58
|
+
jasypt.encrypt("admin", "secret", algorithm="PBEWITHHMACSHA256ANDAES_256")
|
|
59
|
+
# => "K3q8z..." (AES-256-CBC, PBKDF2-SHA256)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
#### `jasypt.decrypt(encrypted_message, password="", algorithm="PBEWITHMD5ANDDES", iterations=1000, salt=None)`
|
|
63
|
+
|
|
64
|
+
Decrypts a base64-encoded ciphertext. The salt is extracted from the ciphertext
|
|
65
|
+
automatically.
|
|
66
|
+
|
|
67
|
+
Returns `None` if `encrypted_message` is empty or `None`.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
jasypt.decrypt("nsbC5r0ymz740/aURtuRWw==", "secret")
|
|
71
|
+
# => "admin"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
#### `jasypt.digest(message, salt=None, iterations=1000, algorithm="SHA-256")`
|
|
75
|
+
|
|
76
|
+
Produces a one-way hash. Returns `base64(salt_bytes + hash_bytes)`.
|
|
77
|
+
|
|
78
|
+
Returns `None` if `message` is empty or `None`.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
stored = jasypt.digest("admin")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
#### `jasypt.matches(message, stored_digest, salt=None, iterations=1000, algorithm="SHA-256")`
|
|
85
|
+
|
|
86
|
+
Verifies a plaintext message against a stored digest. Uses constant-time
|
|
87
|
+
comparison.
|
|
88
|
+
|
|
89
|
+
Returns `None` if `message` is empty or `None`.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
stored = jasypt.digest("admin")
|
|
93
|
+
jasypt.matches("admin", stored) # True
|
|
94
|
+
jasypt.matches("wrong", stored) # False
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
### `Encryptor`
|
|
100
|
+
|
|
101
|
+
Low-level class for direct control over encryption parameters.
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from pysypt import Encryptor
|
|
105
|
+
|
|
106
|
+
enc = Encryptor(
|
|
107
|
+
algorithm="PBEWITHHMACSHA256ANDAES_256",
|
|
108
|
+
iterations=10000,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
ciphertext = enc.encrypt("admin", "secret")
|
|
112
|
+
plaintext = enc.decrypt(ciphertext, "secret")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
#### Constructor
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
Encryptor(algorithm="PBEWITHMD5ANDDES", salt=None, iterations=1000)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
| Parameter | Type | Default | Description |
|
|
122
|
+
|---|---|---|---|
|
|
123
|
+
| `algorithm` | `str` | `PBEWITHMD5ANDDES` | PBE algorithm name (see table below) |
|
|
124
|
+
| `salt` | `bytes \| None` | random | Salt bytes. `None` generates a random salt of the correct length. |
|
|
125
|
+
| `iterations` | `int` | `1000` | KDF iteration count |
|
|
126
|
+
|
|
127
|
+
#### Methods
|
|
128
|
+
|
|
129
|
+
| Method | Description |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `set_algorithm(algorithm)` | Change the algorithm. Raises `ValueError` for unsupported names. |
|
|
132
|
+
| `set_salt(salt)` | Set the salt. Accepts `bytes`, `str` (UTF-8 encoded), or `None` (random). Short salts are zero-padded; long salts are truncated to the required length. |
|
|
133
|
+
| `set_iterations(iterations)` | Set the iteration count. |
|
|
134
|
+
| `encrypt(payload, password, salt=None, iterations=None)` | Encrypt. Returns base64 string. |
|
|
135
|
+
| `decrypt(payload, password, iterations=None)` | Decrypt. Returns plaintext string. |
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
### `Digester`
|
|
140
|
+
|
|
141
|
+
Low-level class for direct control over digest parameters.
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from pysypt import Digester
|
|
145
|
+
|
|
146
|
+
d = Digester(algorithm="SHA-512", iterations=5000)
|
|
147
|
+
d.set_salt("fixedsalt")
|
|
148
|
+
|
|
149
|
+
stored = d.digest("admin")
|
|
150
|
+
is_match = d.matches("admin", stored) # True
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
#### Constructor
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
Digester(algorithm="SHA-256", salt=None, iterations=1000)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
| Parameter | Type | Default | Description |
|
|
160
|
+
|---|---|---|---|
|
|
161
|
+
| `algorithm` | `str` | `SHA-256` | Digest algorithm name (see table below) |
|
|
162
|
+
| `salt` | `str \| None` | random per digest | Fixed salt string. If `None`, a random 8-byte salt is generated per call. |
|
|
163
|
+
| `iterations` | `int` | `1000` | Hash iteration count |
|
|
164
|
+
|
|
165
|
+
#### Methods
|
|
166
|
+
|
|
167
|
+
| Method | Description |
|
|
168
|
+
|---|---|
|
|
169
|
+
| `set_algorithm(algorithm)` | Change the algorithm. Raises `ValueError` for unsupported names. |
|
|
170
|
+
| `set_salt(salt)` | Set a fixed salt. |
|
|
171
|
+
| `set_iterations(iterations)` | Set the iteration count. |
|
|
172
|
+
| `digest(message, salt=None, iterations=None)` | Produce `base64(salt + hash)`. |
|
|
173
|
+
| `matches(message, stored_digest, salt=None, iterations=None)` | Constant-time verification. |
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Supported Algorithms
|
|
178
|
+
|
|
179
|
+
### Encryption
|
|
180
|
+
|
|
181
|
+
| Algorithm | Type | Notes |
|
|
182
|
+
|---|---|---|
|
|
183
|
+
| `PBEWITHMD5ANDDES` | PBE1 | Default. EVP_BytesToKey KDF + DES-CBC. See [ADR-005](../../docs/decisions/ADR-005-pbe1-des-emulation.md). |
|
|
184
|
+
| `PBEWITHMD5ANDTRIPLEDES` | PBE1 | EVP_BytesToKey KDF + 3DES-CBC |
|
|
185
|
+
| `PBEWITHSHA1ANDDESEDE` | PBE1 | EVP_BytesToKey KDF (SHA-1) + 3DES-CBC |
|
|
186
|
+
| `PBEWITHHMACSHA1ANDAES_128` | PBE2 | PBKDF2-SHA1 + AES-128-CBC |
|
|
187
|
+
| `PBEWITHHMACSHA1ANDAES_256` | PBE2 | PBKDF2-SHA1 + AES-256-CBC |
|
|
188
|
+
| `PBEWITHHMACSHA224ANDAES_128` | PBE2 | PBKDF2-SHA224 + AES-128-CBC |
|
|
189
|
+
| `PBEWITHHMACSHA224ANDAES_256` | PBE2 | PBKDF2-SHA224 + AES-256-CBC |
|
|
190
|
+
| `PBEWITHHMACSHA256ANDAES_128` | PBE2 | PBKDF2-SHA256 + AES-128-CBC |
|
|
191
|
+
| `PBEWITHHMACSHA256ANDAES_256` | PBE2 | PBKDF2-SHA256 + AES-256-CBC (**recommended**) |
|
|
192
|
+
| `PBEWITHHMACSHA384ANDAES_128` | PBE2 | PBKDF2-SHA384 + AES-128-CBC |
|
|
193
|
+
| `PBEWITHHMACSHA384ANDAES_256` | PBE2 | PBKDF2-SHA384 + AES-256-CBC |
|
|
194
|
+
| `PBEWITHHMACSHA512ANDAES_128` | PBE2 | PBKDF2-SHA512 + AES-128-CBC |
|
|
195
|
+
| `PBEWITHHMACSHA512ANDAES_256` | PBE2 | PBKDF2-SHA512 + AES-256-CBC |
|
|
196
|
+
|
|
197
|
+
**PBE1** uses an iterative MD5/SHA-1 KDF (EVP_BytesToKey-style) with an 8-byte
|
|
198
|
+
salt prepended to the ciphertext.
|
|
199
|
+
|
|
200
|
+
**PBE2** uses PBKDF2 with a 16-byte salt and a random 16-byte IV, both prepended
|
|
201
|
+
to the ciphertext.
|
|
202
|
+
|
|
203
|
+
RC2 and RC4 variants are not supported — see
|
|
204
|
+
[ADR-006](../../docs/decisions/ADR-006-rc2-rc4-omitted.md).
|
|
205
|
+
|
|
206
|
+
### Digest
|
|
207
|
+
|
|
208
|
+
| Algorithm | Available by default |
|
|
209
|
+
|---|---|
|
|
210
|
+
| `MD5` | ✅ |
|
|
211
|
+
| `SHA-1` | ✅ |
|
|
212
|
+
| `SHA-224` | ✅ |
|
|
213
|
+
| `SHA-256` | ✅ (default) |
|
|
214
|
+
| `SHA-384` | ✅ |
|
|
215
|
+
| `SHA-512` | ✅ |
|
|
216
|
+
| `SHA-512/224` | ✅ |
|
|
217
|
+
| `SHA-512/256` | ✅ |
|
|
218
|
+
| `SHA3-224` | ✅ |
|
|
219
|
+
| `SHA3-256` | ✅ |
|
|
220
|
+
| `SHA3-384` | ✅ |
|
|
221
|
+
| `SHA3-512` | ✅ |
|
|
222
|
+
| `MD2` | Rarely available |
|
|
223
|
+
|
|
224
|
+
`Digester.SUPPORTED_ALGORITHMS` reflects only algorithms available in the current
|
|
225
|
+
OpenSSL build. `Digester.set_algorithm("MD2")` raises `ValueError` if MD2 is
|
|
226
|
+
unavailable.
|
|
227
|
+
|
|
228
|
+
## Wire Format
|
|
229
|
+
|
|
230
|
+
Both classes produce self-describing base64 ciphertext — the salt (and IV for
|
|
231
|
+
PBE2) is stored inline so decryption requires only the password:
|
|
232
|
+
|
|
233
|
+
```
|
|
234
|
+
PBE1: base64( salt[8] + ciphertext )
|
|
235
|
+
PBE2: base64( salt[16] + iv[16] + ciphertext )
|
|
236
|
+
Digest: base64( salt[8] + hash_bytes )
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Java Interoperability
|
|
240
|
+
|
|
241
|
+
PBE2 algorithms (`PBEWITHHMACSHA*ANDAES_*`) are fully interoperable with Java
|
|
242
|
+
jasypt. If you encrypt a value in Java using `PBEWITHHMACSHA256ANDAES_256` and
|
|
243
|
+
the same password, `pysypt` will decrypt it correctly.
|
|
244
|
+
|
|
245
|
+
PBE1 DES (`PBEWITHMD5ANDDES`) is **not** byte-for-byte compatible with Java due
|
|
246
|
+
to the TripleDES-EDE emulation (ADR-005). Use a PBE2 algorithm for cross-language
|
|
247
|
+
scenarios.
|
|
248
|
+
|
|
249
|
+
## Troubleshooting
|
|
250
|
+
|
|
251
|
+
**`ValueError: Unsupported algorithm: MYALGO`**
|
|
252
|
+
The algorithm name is not in the supported list. Check spelling and case — names
|
|
253
|
+
must match exactly (e.g. `PBEWITHHMACSHA256ANDAES_256`).
|
|
254
|
+
|
|
255
|
+
**Decryption produces garbled text**
|
|
256
|
+
The password or algorithm does not match what was used to encrypt. Both the
|
|
257
|
+
encryptor and decryptor must use the same algorithm and password.
|
|
258
|
+
|
|
259
|
+
**`ValueError: Invalid padding bytes`**
|
|
260
|
+
The ciphertext is corrupt, truncated, or was encrypted with a different algorithm.
|
|
261
|
+
This can also occur if the base64 string was URL-encoded and not decoded first.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "alt-python-pysypt"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Jasypt-compatible PBE encryption and digest for Python"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"cryptography>=42.0",
|
|
8
|
+
"alt-python-common",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[build-system]
|
|
12
|
+
requires = ["hatchling"]
|
|
13
|
+
build-backend = "hatchling.build"
|
|
14
|
+
|
|
15
|
+
[tool.hatch.build.targets.wheel]
|
|
16
|
+
packages = ["pysypt"]
|
|
17
|
+
|
|
18
|
+
[tool.uv.sources]
|
|
19
|
+
alt-python-common = { workspace = true }
|
|
20
|
+
|
|
21
|
+
[tool.pytest.ini_options]
|
|
22
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pysypt — Jasypt-compatible PBE encryption and digest for Python.
|
|
3
|
+
|
|
4
|
+
Mirrors the JS @alt-javascript/jasypt package.
|
|
5
|
+
|
|
6
|
+
Quick start::
|
|
7
|
+
|
|
8
|
+
from pysypt import Jasypt, Encryptor, Digester
|
|
9
|
+
|
|
10
|
+
jasypt = Jasypt()
|
|
11
|
+
ciphertext = jasypt.encrypt("admin", "mypassword")
|
|
12
|
+
plaintext = jasypt.decrypt(ciphertext, "mypassword")
|
|
13
|
+
|
|
14
|
+
stored = jasypt.digest("admin")
|
|
15
|
+
assert jasypt.matches("admin", stored) is True
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
__author__ = "Craig Parravicini"
|
|
19
|
+
__collaborators__ = ["Claude (Anthropic)"]
|
|
20
|
+
|
|
21
|
+
from pysypt.jasypt import Jasypt
|
|
22
|
+
from pysypt.encryptor import Encryptor, SUPPORTED_ALGORITHMS
|
|
23
|
+
from pysypt.digester import Digester, SUPPORTED_ALGORITHMS as SUPPORTED_DIGEST_ALGORITHMS
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Jasypt",
|
|
27
|
+
"Encryptor",
|
|
28
|
+
"Digester",
|
|
29
|
+
"SUPPORTED_ALGORITHMS",
|
|
30
|
+
"SUPPORTED_DIGEST_ALGORITHMS",
|
|
31
|
+
]
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pysypt.digester — Jasypt-compatible iterated-hash digest.
|
|
3
|
+
|
|
4
|
+
Output format: base64(salt_bytes + hash_bytes)
|
|
5
|
+
- Random salt is prepended when no fixed salt is provided.
|
|
6
|
+
- matches() extracts the salt from the stored digest for verification.
|
|
7
|
+
|
|
8
|
+
Algorithm map mirrors the JS Digester ALGO_MAP.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
__author__ = "Craig Parravicini"
|
|
14
|
+
__collaborators__ = ["Claude (Anthropic)"]
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import hmac as _hmac
|
|
18
|
+
import os
|
|
19
|
+
import base64
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
from common import is_empty
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Algorithm table
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
_ALGO_MAP: dict[str, str] = {
|
|
29
|
+
"MD2": "md2", # may not be available
|
|
30
|
+
"MD5": "md5",
|
|
31
|
+
"SHA-1": "sha1",
|
|
32
|
+
"SHA-224": "sha224",
|
|
33
|
+
"SHA-256": "sha256",
|
|
34
|
+
"SHA-384": "sha384",
|
|
35
|
+
"SHA-512": "sha512",
|
|
36
|
+
"SHA-512/224": "sha512_224",
|
|
37
|
+
"SHA-512/256": "sha512_256",
|
|
38
|
+
"SHA3-224": "sha3_224",
|
|
39
|
+
"SHA3-256": "sha3_256",
|
|
40
|
+
"SHA3-384": "sha3_384",
|
|
41
|
+
"SHA3-512": "sha3_512",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_AVAILABLE = set(hashlib.algorithms_available)
|
|
45
|
+
|
|
46
|
+
# Normalise hashlib naming differences (sha512_224 vs sha512-224)
|
|
47
|
+
def _normalise_algo(name: str) -> Optional[str]:
|
|
48
|
+
"""Return the hashlib name if available, else None."""
|
|
49
|
+
if name in _AVAILABLE:
|
|
50
|
+
return name
|
|
51
|
+
# Try underscored variant
|
|
52
|
+
alt = name.replace("-", "_")
|
|
53
|
+
if alt in _AVAILABLE:
|
|
54
|
+
return alt
|
|
55
|
+
# Try dashed variant
|
|
56
|
+
alt2 = name.replace("_", "-")
|
|
57
|
+
if alt2 in _AVAILABLE:
|
|
58
|
+
return alt2
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolve(algo_key: str) -> Optional[str]:
|
|
63
|
+
raw = _ALGO_MAP.get(algo_key)
|
|
64
|
+
if raw is None:
|
|
65
|
+
return None
|
|
66
|
+
return _normalise_algo(raw)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
SUPPORTED_ALGORITHMS: list[str] = [k for k in _ALGO_MAP if _resolve(k) is not None]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Digester
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
class Digester:
|
|
77
|
+
"""
|
|
78
|
+
Jasypt-compatible iterated-hash digester.
|
|
79
|
+
|
|
80
|
+
Default: SHA-256, 1000 iterations, random 8-byte salt.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
DEFAULT_SALT_SIZE = 8
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
algorithm: str = "SHA-256",
|
|
88
|
+
salt: Optional[str] = None,
|
|
89
|
+
iterations: int = 1000,
|
|
90
|
+
) -> None:
|
|
91
|
+
self.set_algorithm(algorithm)
|
|
92
|
+
self.salt: Optional[str] = salt
|
|
93
|
+
self.salt_size = self.DEFAULT_SALT_SIZE
|
|
94
|
+
self.iterations = iterations
|
|
95
|
+
|
|
96
|
+
def set_algorithm(self, algorithm: str) -> None:
|
|
97
|
+
resolved = _resolve(algorithm)
|
|
98
|
+
if resolved is None:
|
|
99
|
+
raise ValueError(f"Unsupported digest algorithm: {algorithm}")
|
|
100
|
+
self.algorithm = algorithm
|
|
101
|
+
self._hashlib_algo = resolved
|
|
102
|
+
|
|
103
|
+
def set_salt(self, salt: Optional[str]) -> None:
|
|
104
|
+
self.salt = salt
|
|
105
|
+
|
|
106
|
+
def set_iterations(self, iterations: int) -> None:
|
|
107
|
+
self.iterations = iterations
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
# Core compute
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def _compute(self, salt_bytes: bytes, message: str, iterations: int) -> bytes:
|
|
114
|
+
"""
|
|
115
|
+
Iterated hash: first = Hash(salt + message), subsequent = Hash(digest).
|
|
116
|
+
Mirrors the JS Digester._compute().
|
|
117
|
+
"""
|
|
118
|
+
msg = message.encode("utf-8")
|
|
119
|
+
digest = hashlib.new(self._hashlib_algo, salt_bytes + msg).digest()
|
|
120
|
+
for _ in range(1, iterations):
|
|
121
|
+
digest = hashlib.new(self._hashlib_algo, digest).digest()
|
|
122
|
+
return digest
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
# Public API
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def digest(
|
|
129
|
+
self,
|
|
130
|
+
message: str,
|
|
131
|
+
salt: Optional[str] = None,
|
|
132
|
+
iterations: Optional[int] = None,
|
|
133
|
+
) -> str:
|
|
134
|
+
"""
|
|
135
|
+
Digest a message. Returns base64(salt_bytes + hash_bytes).
|
|
136
|
+
"""
|
|
137
|
+
if not is_empty(salt):
|
|
138
|
+
salt_bytes = salt.encode("utf-8") # type: ignore[union-attr]
|
|
139
|
+
elif not is_empty(self.salt):
|
|
140
|
+
salt_bytes = self.salt.encode("utf-8") # type: ignore[union-attr]
|
|
141
|
+
else:
|
|
142
|
+
salt_bytes = os.urandom(self.salt_size)
|
|
143
|
+
|
|
144
|
+
_iters = iterations if iterations is not None else self.iterations
|
|
145
|
+
digest_bytes = self._compute(salt_bytes, message, _iters)
|
|
146
|
+
return base64.b64encode(salt_bytes + digest_bytes).decode("ascii")
|
|
147
|
+
|
|
148
|
+
def matches(
|
|
149
|
+
self,
|
|
150
|
+
message: str,
|
|
151
|
+
stored_digest: str,
|
|
152
|
+
salt: Optional[str] = None,
|
|
153
|
+
iterations: Optional[int] = None,
|
|
154
|
+
) -> bool:
|
|
155
|
+
"""
|
|
156
|
+
Verify message against a stored digest.
|
|
157
|
+
|
|
158
|
+
For random-salt digests, the salt is extracted from the first salt_size
|
|
159
|
+
bytes of the decoded stored value. For fixed-salt digests, that salt is
|
|
160
|
+
used directly.
|
|
161
|
+
"""
|
|
162
|
+
stored_bytes = base64.b64decode(stored_digest)
|
|
163
|
+
|
|
164
|
+
if not is_empty(salt):
|
|
165
|
+
salt_bytes = salt.encode("utf-8") # type: ignore[union-attr]
|
|
166
|
+
elif not is_empty(self.salt):
|
|
167
|
+
salt_bytes = self.salt.encode("utf-8") # type: ignore[union-attr]
|
|
168
|
+
else:
|
|
169
|
+
salt_bytes = stored_bytes[: self.salt_size]
|
|
170
|
+
|
|
171
|
+
expected = stored_bytes[len(salt_bytes) :]
|
|
172
|
+
_iters = iterations if iterations is not None else self.iterations
|
|
173
|
+
computed = self._compute(salt_bytes, message, _iters)
|
|
174
|
+
|
|
175
|
+
# Constant-time comparison
|
|
176
|
+
return _hmac.compare_digest(computed, expected)
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pysypt.encryptor — Jasypt-compatible PBE encryption.
|
|
3
|
+
|
|
4
|
+
Supports the same algorithm table as the JS alt-javascript/jasypt Encryptor:
|
|
5
|
+
|
|
6
|
+
PBE1 (EVP_BytesToKey-style KDF + DES / 3DES):
|
|
7
|
+
PBEWITHMD5ANDDES, PBEWITHMD5ANDTRIPLEDES, PBEWITHSHA1ANDDESEDE
|
|
8
|
+
|
|
9
|
+
PBE2 (PBKDF2 + AES-CBC):
|
|
10
|
+
PBEWITHHMACSHA{1,224,256,384,512}ANDAES_{128,256}
|
|
11
|
+
|
|
12
|
+
Wire format (base64-encoded):
|
|
13
|
+
PBE1 / PBE1N: salt(8) + ciphertext
|
|
14
|
+
PBE2: salt(16) + iv(16) + ciphertext
|
|
15
|
+
|
|
16
|
+
All algorithms operate in CBC mode. RC2 / RC4 variants from the JS version
|
|
17
|
+
are intentionally omitted — they are removed from modern OpenSSL and have no
|
|
18
|
+
safe analogue in the cryptography package.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
__author__ = "Craig Parravicini"
|
|
24
|
+
__collaborators__ = ["Claude (Anthropic)"]
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, modes
|
|
30
|
+
from cryptography.hazmat.primitives.ciphers import algorithms as std_algorithms
|
|
31
|
+
from cryptography.hazmat.primitives import hashes, padding as sym_padding
|
|
32
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
33
|
+
from cryptography.hazmat.backends import default_backend
|
|
34
|
+
|
|
35
|
+
# TripleDES moved to decrepit in cryptography 44+; fall back gracefully
|
|
36
|
+
try:
|
|
37
|
+
from cryptography.hazmat.decrepit.ciphers.algorithms import TripleDES as _TripleDES
|
|
38
|
+
except ImportError:
|
|
39
|
+
_TripleDES = std_algorithms.TripleDES # type: ignore[attr-defined]
|
|
40
|
+
import hashlib
|
|
41
|
+
import base64
|
|
42
|
+
|
|
43
|
+
from common import is_empty
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Algorithm table
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
_PBE1_SALT_LEN = 8
|
|
50
|
+
_PBE2_SALT_LEN = 16
|
|
51
|
+
_PBE2_IV_LEN = 16
|
|
52
|
+
|
|
53
|
+
_HASH_MAP = {
|
|
54
|
+
"md5": hashes.MD5,
|
|
55
|
+
"sha1": hashes.SHA1,
|
|
56
|
+
"sha224": hashes.SHA224,
|
|
57
|
+
"sha256": hashes.SHA256,
|
|
58
|
+
"sha384": hashes.SHA384,
|
|
59
|
+
"sha512": hashes.SHA512,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Each entry: type, hash/hmac name, key_len (bytes), iv_len (bytes)
|
|
63
|
+
# PBE1 uses DES/3DES; PBE2 uses AES-CBC via PBKDF2
|
|
64
|
+
_ALGO_CONFIG: dict[str, dict[str, Any]] = {
|
|
65
|
+
# PBE1 — EVP_BytesToKey KDF + DES
|
|
66
|
+
"PBEWITHMD5ANDDES": {
|
|
67
|
+
"type": "pbe1",
|
|
68
|
+
"hash": "md5",
|
|
69
|
+
"cipher": "des-cbc",
|
|
70
|
+
"key_len": 8,
|
|
71
|
+
"iv_len": 8,
|
|
72
|
+
},
|
|
73
|
+
# PBE1 — EVP_BytesToKey KDF + 3DES
|
|
74
|
+
"PBEWITHMD5ANDTRIPLEDES": {
|
|
75
|
+
"type": "pbe1",
|
|
76
|
+
"hash": "md5",
|
|
77
|
+
"cipher": "3des-cbc",
|
|
78
|
+
"key_len": 24,
|
|
79
|
+
"iv_len": 8,
|
|
80
|
+
},
|
|
81
|
+
"PBEWITHSHA1ANDDESEDE": {
|
|
82
|
+
"type": "pbe1",
|
|
83
|
+
"hash": "sha1",
|
|
84
|
+
"cipher": "3des-cbc",
|
|
85
|
+
"key_len": 24,
|
|
86
|
+
"iv_len": 8,
|
|
87
|
+
},
|
|
88
|
+
# PBE2 — PBKDF2 + AES-CBC
|
|
89
|
+
"PBEWITHHMACSHA1ANDAES_128": {"type": "pbe2", "hmac": "sha1", "key_len": 16},
|
|
90
|
+
"PBEWITHHMACSHA1ANDAES_256": {"type": "pbe2", "hmac": "sha1", "key_len": 32},
|
|
91
|
+
"PBEWITHHMACSHA224ANDAES_128": {"type": "pbe2", "hmac": "sha224", "key_len": 16},
|
|
92
|
+
"PBEWITHHMACSHA224ANDAES_256": {"type": "pbe2", "hmac": "sha224", "key_len": 32},
|
|
93
|
+
"PBEWITHHMACSHA256ANDAES_128": {"type": "pbe2", "hmac": "sha256", "key_len": 16},
|
|
94
|
+
"PBEWITHHMACSHA256ANDAES_256": {"type": "pbe2", "hmac": "sha256", "key_len": 32},
|
|
95
|
+
"PBEWITHHMACSHA384ANDAES_128": {"type": "pbe2", "hmac": "sha384", "key_len": 16},
|
|
96
|
+
"PBEWITHHMACSHA384ANDAES_256": {"type": "pbe2", "hmac": "sha384", "key_len": 32},
|
|
97
|
+
"PBEWITHHMACSHA512ANDAES_128": {"type": "pbe2", "hmac": "sha512", "key_len": 16},
|
|
98
|
+
"PBEWITHHMACSHA512ANDAES_256": {"type": "pbe2", "hmac": "sha512", "key_len": 32},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
SUPPORTED_ALGORITHMS = list(_ALGO_CONFIG.keys())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Internal helpers
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def _pkcs7_pad(data: bytes, block_size: int) -> bytes:
|
|
109
|
+
padder = sym_padding.PKCS7(block_size * 8).padder()
|
|
110
|
+
return padder.update(data) + padder.finalize()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _pkcs7_unpad(data: bytes, block_size: int) -> bytes:
|
|
114
|
+
unpadder = sym_padding.PKCS7(block_size * 8).unpadder()
|
|
115
|
+
return unpadder.update(data) + unpadder.finalize()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _evp_bytes_to_key(
|
|
119
|
+
hash_name: str, password: bytes, salt: bytes, iterations: int, key_len: int, iv_len: int
|
|
120
|
+
) -> tuple[bytes, bytes]:
|
|
121
|
+
"""
|
|
122
|
+
OpenSSL EVP_BytesToKey-compatible KDF.
|
|
123
|
+
|
|
124
|
+
Produces successive hash blocks: H_i = Hash^n(H_{i-1} || password || salt)
|
|
125
|
+
Returns (key[:key_len], key[key_len:key_len+iv_len])
|
|
126
|
+
"""
|
|
127
|
+
total = key_len + iv_len
|
|
128
|
+
result = b""
|
|
129
|
+
prev = b""
|
|
130
|
+
while len(result) < total:
|
|
131
|
+
block = prev + password + salt
|
|
132
|
+
for _ in range(iterations):
|
|
133
|
+
block = hashlib.new(hash_name, block).digest()
|
|
134
|
+
result += block
|
|
135
|
+
prev = block
|
|
136
|
+
key = result[:key_len]
|
|
137
|
+
iv = result[key_len : key_len + iv_len]
|
|
138
|
+
return key, iv
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _pbkdf2_key(
|
|
142
|
+
hmac_name: str, password: bytes, salt: bytes, iterations: int, key_len: int
|
|
143
|
+
) -> bytes:
|
|
144
|
+
"""PBKDF2 key derivation using the cryptography library."""
|
|
145
|
+
hash_cls = _HASH_MAP[hmac_name]
|
|
146
|
+
kdf = PBKDF2HMAC(
|
|
147
|
+
algorithm=hash_cls(),
|
|
148
|
+
length=key_len,
|
|
149
|
+
salt=salt,
|
|
150
|
+
iterations=iterations,
|
|
151
|
+
backend=default_backend(),
|
|
152
|
+
)
|
|
153
|
+
return kdf.derive(password)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# Encryptor
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
class Encryptor:
|
|
161
|
+
"""
|
|
162
|
+
Jasypt-compatible PBE encryptor.
|
|
163
|
+
|
|
164
|
+
Default algorithm: PBEWITHMD5ANDDES (same as Java jasypt default).
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
def __init__(
|
|
168
|
+
self,
|
|
169
|
+
algorithm: str = "PBEWITHMD5ANDDES",
|
|
170
|
+
salt: bytes | None = None,
|
|
171
|
+
iterations: int = 1000,
|
|
172
|
+
) -> None:
|
|
173
|
+
self.set_algorithm(algorithm)
|
|
174
|
+
cfg = _ALGO_CONFIG[self.algorithm]
|
|
175
|
+
salt_len = _PBE2_SALT_LEN if cfg["type"] == "pbe2" else _PBE1_SALT_LEN
|
|
176
|
+
self.salt: bytes = salt if salt is not None else os.urandom(salt_len)
|
|
177
|
+
self.iterations = iterations
|
|
178
|
+
|
|
179
|
+
def set_algorithm(self, algorithm: str) -> None:
|
|
180
|
+
norm = algorithm.upper()
|
|
181
|
+
if norm not in _ALGO_CONFIG:
|
|
182
|
+
raise ValueError(f"Unsupported algorithm: {algorithm}")
|
|
183
|
+
self.algorithm = norm
|
|
184
|
+
|
|
185
|
+
def set_salt(self, salt: bytes | str | None) -> None:
|
|
186
|
+
cfg = _ALGO_CONFIG[self.algorithm]
|
|
187
|
+
salt_len = _PBE2_SALT_LEN if cfg["type"] == "pbe2" else _PBE1_SALT_LEN
|
|
188
|
+
if salt is None:
|
|
189
|
+
self.salt = os.urandom(salt_len)
|
|
190
|
+
return
|
|
191
|
+
if isinstance(salt, str):
|
|
192
|
+
b = salt.encode("utf-8")
|
|
193
|
+
else:
|
|
194
|
+
b = bytes(salt)
|
|
195
|
+
# Empty → random; short → zero-pad to required length; long → truncate
|
|
196
|
+
if len(b) == 0:
|
|
197
|
+
self.salt = os.urandom(salt_len)
|
|
198
|
+
elif len(b) < salt_len:
|
|
199
|
+
self.salt = b.ljust(salt_len, b"\x00")
|
|
200
|
+
else:
|
|
201
|
+
self.salt = b[:salt_len]
|
|
202
|
+
|
|
203
|
+
def set_iterations(self, iterations: int) -> None:
|
|
204
|
+
self.iterations = iterations or 1000
|
|
205
|
+
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
# Encrypt
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
def encrypt(
|
|
211
|
+
self,
|
|
212
|
+
payload: str,
|
|
213
|
+
password: str,
|
|
214
|
+
salt: bytes | None = None,
|
|
215
|
+
iterations: int | None = None,
|
|
216
|
+
) -> str:
|
|
217
|
+
cfg = _ALGO_CONFIG[self.algorithm]
|
|
218
|
+
_salt = salt if salt is not None else self.salt
|
|
219
|
+
_iters = iterations if iterations is not None else self.iterations
|
|
220
|
+
pwd_bytes = (password or "").encode("utf-8")
|
|
221
|
+
data = payload.encode("utf-8")
|
|
222
|
+
|
|
223
|
+
if cfg["type"] == "pbe1":
|
|
224
|
+
return self._encrypt_pbe1(cfg, _salt, _iters, pwd_bytes, data)
|
|
225
|
+
# pbe2
|
|
226
|
+
return self._encrypt_pbe2(cfg, _salt, _iters, pwd_bytes, data)
|
|
227
|
+
|
|
228
|
+
def _encrypt_pbe1(
|
|
229
|
+
self,
|
|
230
|
+
cfg: dict,
|
|
231
|
+
salt: bytes,
|
|
232
|
+
iterations: int,
|
|
233
|
+
password: bytes,
|
|
234
|
+
data: bytes,
|
|
235
|
+
) -> str:
|
|
236
|
+
key, iv = _evp_bytes_to_key(cfg["hash"], password, salt, iterations, cfg["key_len"], cfg["iv_len"])
|
|
237
|
+
ciphertext = self._pbe1_cipher_encrypt(cfg["cipher"], key, iv, data)
|
|
238
|
+
return base64.b64encode(salt + ciphertext).decode("ascii")
|
|
239
|
+
|
|
240
|
+
def _encrypt_pbe2(
|
|
241
|
+
self,
|
|
242
|
+
cfg: dict,
|
|
243
|
+
salt: bytes,
|
|
244
|
+
iterations: int,
|
|
245
|
+
password: bytes,
|
|
246
|
+
data: bytes,
|
|
247
|
+
) -> str:
|
|
248
|
+
iv = os.urandom(_PBE2_IV_LEN)
|
|
249
|
+
key = _pbkdf2_key(cfg["hmac"], password, salt, iterations, cfg["key_len"])
|
|
250
|
+
padded = _pkcs7_pad(data, 16)
|
|
251
|
+
cipher = Cipher(std_algorithms.AES(key), modes.CBC(iv), backend=default_backend())
|
|
252
|
+
enc = cipher.encryptor()
|
|
253
|
+
ciphertext = enc.update(padded) + enc.finalize()
|
|
254
|
+
return base64.b64encode(salt + iv + ciphertext).decode("ascii")
|
|
255
|
+
|
|
256
|
+
# ------------------------------------------------------------------
|
|
257
|
+
# Decrypt
|
|
258
|
+
# ------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
def decrypt(
|
|
261
|
+
self,
|
|
262
|
+
payload: str,
|
|
263
|
+
password: str,
|
|
264
|
+
iterations: int | None = None,
|
|
265
|
+
) -> str:
|
|
266
|
+
cfg = _ALGO_CONFIG[self.algorithm]
|
|
267
|
+
_iters = iterations if iterations is not None else self.iterations
|
|
268
|
+
pwd_bytes = (password or "").encode("utf-8")
|
|
269
|
+
buf = base64.b64decode(payload)
|
|
270
|
+
|
|
271
|
+
if cfg["type"] == "pbe1":
|
|
272
|
+
return self._decrypt_pbe1(cfg, buf, _iters, pwd_bytes)
|
|
273
|
+
return self._decrypt_pbe2(cfg, buf, _iters, pwd_bytes)
|
|
274
|
+
|
|
275
|
+
def _decrypt_pbe1(
|
|
276
|
+
self,
|
|
277
|
+
cfg: dict,
|
|
278
|
+
buf: bytes,
|
|
279
|
+
iterations: int,
|
|
280
|
+
password: bytes,
|
|
281
|
+
) -> str:
|
|
282
|
+
salt = buf[:_PBE1_SALT_LEN]
|
|
283
|
+
ciphertext = buf[_PBE1_SALT_LEN:]
|
|
284
|
+
key, iv = _evp_bytes_to_key(cfg["hash"], password, salt, iterations, cfg["key_len"], cfg["iv_len"])
|
|
285
|
+
plaintext = self._pbe1_cipher_decrypt(cfg["cipher"], key, iv, ciphertext)
|
|
286
|
+
return plaintext.decode("utf-8")
|
|
287
|
+
|
|
288
|
+
def _decrypt_pbe2(
|
|
289
|
+
self,
|
|
290
|
+
cfg: dict,
|
|
291
|
+
buf: bytes,
|
|
292
|
+
iterations: int,
|
|
293
|
+
password: bytes,
|
|
294
|
+
) -> str:
|
|
295
|
+
salt = buf[:_PBE2_SALT_LEN]
|
|
296
|
+
iv = buf[_PBE2_SALT_LEN : _PBE2_SALT_LEN + _PBE2_IV_LEN]
|
|
297
|
+
ciphertext = buf[_PBE2_SALT_LEN + _PBE2_IV_LEN :]
|
|
298
|
+
key = _pbkdf2_key(cfg["hmac"], password, salt, iterations, cfg["key_len"])
|
|
299
|
+
cipher = Cipher(std_algorithms.AES(key), modes.CBC(iv), backend=default_backend())
|
|
300
|
+
dec = cipher.decryptor()
|
|
301
|
+
padded = dec.update(ciphertext) + dec.finalize()
|
|
302
|
+
return _pkcs7_unpad(padded, 16).decode("utf-8")
|
|
303
|
+
|
|
304
|
+
# ------------------------------------------------------------------
|
|
305
|
+
# Internal cipher wrappers
|
|
306
|
+
# ------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
def _pbe1_cipher_encrypt(self, cipher_name: str, key: bytes, iv: bytes, data: bytes) -> bytes:
|
|
309
|
+
if cipher_name == "des-cbc":
|
|
310
|
+
# Single DES: cryptography library has no DES primitive; emulate using
|
|
311
|
+
# TripleDES-EDE with k1=k2=k3 (same 8-byte key repeated 3 times).
|
|
312
|
+
padded = _pkcs7_pad(data, 8)
|
|
313
|
+
c = Cipher(_TripleDES(key * 3), modes.CBC(iv), backend=default_backend())
|
|
314
|
+
enc = c.encryptor()
|
|
315
|
+
return enc.update(padded) + enc.finalize()
|
|
316
|
+
elif cipher_name == "3des-cbc":
|
|
317
|
+
padded = _pkcs7_pad(data, 8)
|
|
318
|
+
c = Cipher(_TripleDES(key), modes.CBC(iv), backend=default_backend())
|
|
319
|
+
enc = c.encryptor()
|
|
320
|
+
return enc.update(padded) + enc.finalize()
|
|
321
|
+
else:
|
|
322
|
+
raise ValueError(f"Unknown PBE1 cipher: {cipher_name}")
|
|
323
|
+
|
|
324
|
+
def _pbe1_cipher_decrypt(self, cipher_name: str, key: bytes, iv: bytes, data: bytes) -> bytes:
|
|
325
|
+
if cipher_name == "des-cbc":
|
|
326
|
+
c = Cipher(_TripleDES(key * 3), modes.CBC(iv), backend=default_backend())
|
|
327
|
+
dec = c.decryptor()
|
|
328
|
+
padded = dec.update(data) + dec.finalize()
|
|
329
|
+
return _pkcs7_unpad(padded, 8)
|
|
330
|
+
elif cipher_name == "3des-cbc":
|
|
331
|
+
c = Cipher(_TripleDES(key), modes.CBC(iv), backend=default_backend())
|
|
332
|
+
dec = c.decryptor()
|
|
333
|
+
padded = dec.update(data) + dec.finalize()
|
|
334
|
+
return _pkcs7_unpad(padded, 8)
|
|
335
|
+
else:
|
|
336
|
+
raise ValueError(f"Unknown PBE1 cipher: {cipher_name}")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pysypt.jasypt — Thin facade matching the JS Jasypt class API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
__author__ = "Craig Parravicini"
|
|
8
|
+
__collaborators__ = ["Claude (Anthropic)"]
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from common import is_empty
|
|
13
|
+
from pysypt.encryptor import Encryptor
|
|
14
|
+
from pysypt.digester import Digester
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Jasypt:
|
|
18
|
+
"""
|
|
19
|
+
Facade providing encrypt / decrypt / digest / matches — mirrors the JS Jasypt class.
|
|
20
|
+
|
|
21
|
+
Each call constructs a fresh Encryptor or Digester with the provided options
|
|
22
|
+
so this class is stateless and thread-safe.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def encrypt(
|
|
26
|
+
self,
|
|
27
|
+
message: str,
|
|
28
|
+
password: str,
|
|
29
|
+
algorithm: str = "PBEWITHMD5ANDDES",
|
|
30
|
+
iterations: int = 1000,
|
|
31
|
+
salt: Optional[bytes] = None,
|
|
32
|
+
) -> Optional[str]:
|
|
33
|
+
"""Encrypt a plaintext message. Returns None for empty input."""
|
|
34
|
+
if is_empty(message):
|
|
35
|
+
return None
|
|
36
|
+
enc = Encryptor(algorithm=algorithm, salt=salt, iterations=iterations)
|
|
37
|
+
return enc.encrypt(message, password)
|
|
38
|
+
|
|
39
|
+
def decrypt(
|
|
40
|
+
self,
|
|
41
|
+
encrypted_message: Optional[str],
|
|
42
|
+
password: str = "",
|
|
43
|
+
algorithm: str = "PBEWITHMD5ANDDES",
|
|
44
|
+
iterations: int = 1000,
|
|
45
|
+
salt: Optional[bytes] = None,
|
|
46
|
+
) -> Optional[str]:
|
|
47
|
+
"""Decrypt an encrypted message. Returns None for empty input."""
|
|
48
|
+
if is_empty(encrypted_message):
|
|
49
|
+
return None
|
|
50
|
+
enc = Encryptor(algorithm=algorithm, salt=salt, iterations=iterations)
|
|
51
|
+
return enc.decrypt(encrypted_message, password) # type: ignore[arg-type]
|
|
52
|
+
|
|
53
|
+
def digest(
|
|
54
|
+
self,
|
|
55
|
+
message: str,
|
|
56
|
+
salt: Optional[str] = None,
|
|
57
|
+
iterations: int = 1000,
|
|
58
|
+
algorithm: str = "SHA-256",
|
|
59
|
+
) -> Optional[str]:
|
|
60
|
+
"""One-way digest a message. Returns None for empty input."""
|
|
61
|
+
if is_empty(message):
|
|
62
|
+
return None
|
|
63
|
+
digester = Digester(algorithm=algorithm, iterations=iterations)
|
|
64
|
+
return digester.digest(message, salt=salt)
|
|
65
|
+
|
|
66
|
+
def matches(
|
|
67
|
+
self,
|
|
68
|
+
message: str,
|
|
69
|
+
stored_digest: str,
|
|
70
|
+
salt: Optional[str] = None,
|
|
71
|
+
iterations: int = 1000,
|
|
72
|
+
algorithm: str = "SHA-256",
|
|
73
|
+
) -> Optional[bool]:
|
|
74
|
+
"""Verify plaintext against a stored digest. Returns None for empty message."""
|
|
75
|
+
if is_empty(message):
|
|
76
|
+
return None
|
|
77
|
+
return Digester(algorithm=algorithm, iterations=iterations).matches(
|
|
78
|
+
message, stored_digest, salt=salt
|
|
79
|
+
)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tests/test_jasypt.py — pysypt test suite.
|
|
3
|
+
|
|
4
|
+
Mirrors the JS test/jasypt.test.js coverage:
|
|
5
|
+
- empty input handling
|
|
6
|
+
- encrypt/decrypt round-trips for all supported PBE algorithms
|
|
7
|
+
- digest / matches for all supported hash algorithms
|
|
8
|
+
- custom salt and iterations
|
|
9
|
+
- unsupported algorithm errors
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from pysypt import Jasypt, Encryptor, Digester, SUPPORTED_ALGORITHMS, SUPPORTED_DIGEST_ALGORITHMS
|
|
17
|
+
|
|
18
|
+
PASSWORD = "G0CvDz7oJn60"
|
|
19
|
+
MESSAGE = "admin"
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Empty-input guards
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
def test_encrypt_empty_returns_none():
|
|
26
|
+
j = Jasypt()
|
|
27
|
+
assert j.encrypt("", PASSWORD) is None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_decrypt_none_returns_none():
|
|
31
|
+
j = Jasypt()
|
|
32
|
+
assert j.decrypt(None) is None # type: ignore[arg-type]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_decrypt_empty_string_returns_none():
|
|
36
|
+
j = Jasypt()
|
|
37
|
+
assert j.decrypt("") is None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_digest_empty_returns_none():
|
|
41
|
+
j = Jasypt()
|
|
42
|
+
assert j.digest("") is None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_matches_empty_returns_none():
|
|
46
|
+
j = Jasypt()
|
|
47
|
+
stored = Jasypt().digest(MESSAGE)
|
|
48
|
+
assert j.matches("", stored) is None # type: ignore[arg-type]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Basic encrypt / decrypt round-trip (default PBEWITHMD5ANDDES)
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def test_encrypt_decrypt_default():
|
|
56
|
+
j = Jasypt()
|
|
57
|
+
encrypted = j.encrypt(MESSAGE, PASSWORD)
|
|
58
|
+
assert encrypted is not None
|
|
59
|
+
assert j.decrypt(encrypted, PASSWORD) == MESSAGE
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Encryptor class direct usage
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def test_encryptor_set_salt_and_iterations():
|
|
67
|
+
enc = Encryptor()
|
|
68
|
+
enc.set_salt(b"")
|
|
69
|
+
enc.set_iterations(100)
|
|
70
|
+
ciphertext = enc.encrypt(MESSAGE, PASSWORD)
|
|
71
|
+
assert enc.decrypt(ciphertext, PASSWORD) == MESSAGE
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_encryptor_unsupported_algorithm_raises():
|
|
75
|
+
enc = Encryptor()
|
|
76
|
+
with pytest.raises(ValueError, match="Unsupported algorithm"):
|
|
77
|
+
enc.set_algorithm("INVALID")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# PBE1 DES / 3DES
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
@pytest.mark.parametrize("algorithm", [
|
|
85
|
+
"PBEWITHMD5ANDDES",
|
|
86
|
+
"PBEWITHMD5ANDTRIPLEDES",
|
|
87
|
+
"PBEWITHSHA1ANDDESEDE",
|
|
88
|
+
])
|
|
89
|
+
def test_pbe1_round_trip(algorithm: str):
|
|
90
|
+
j = Jasypt()
|
|
91
|
+
encrypted = j.encrypt(MESSAGE, PASSWORD, algorithm=algorithm)
|
|
92
|
+
assert j.decrypt(encrypted, PASSWORD, algorithm=algorithm) == MESSAGE
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# PBE2 AES-CBC (PBKDF2)
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
_AES_ALGORITHMS = [
|
|
100
|
+
"PBEWITHHMACSHA1ANDAES_128",
|
|
101
|
+
"PBEWITHHMACSHA1ANDAES_256",
|
|
102
|
+
"PBEWITHHMACSHA224ANDAES_128",
|
|
103
|
+
"PBEWITHHMACSHA224ANDAES_256",
|
|
104
|
+
"PBEWITHHMACSHA256ANDAES_128",
|
|
105
|
+
"PBEWITHHMACSHA256ANDAES_256",
|
|
106
|
+
"PBEWITHHMACSHA384ANDAES_128",
|
|
107
|
+
"PBEWITHHMACSHA384ANDAES_256",
|
|
108
|
+
"PBEWITHHMACSHA512ANDAES_128",
|
|
109
|
+
"PBEWITHHMACSHA512ANDAES_256",
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@pytest.mark.parametrize("algorithm", _AES_ALGORITHMS)
|
|
114
|
+
def test_pbe2_aes_round_trip(algorithm: str):
|
|
115
|
+
j = Jasypt()
|
|
116
|
+
encrypted = j.encrypt(MESSAGE, PASSWORD, algorithm=algorithm)
|
|
117
|
+
assert j.decrypt(encrypted, PASSWORD, algorithm=algorithm) == MESSAGE
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# SUPPORTED_ALGORITHMS list coverage
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def test_all_supported_algorithms_round_trip():
|
|
125
|
+
j = Jasypt()
|
|
126
|
+
for algo in SUPPORTED_ALGORITHMS:
|
|
127
|
+
encrypted = j.encrypt(MESSAGE, PASSWORD, algorithm=algo)
|
|
128
|
+
assert j.decrypt(encrypted, PASSWORD, algorithm=algo) == MESSAGE, algo
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# Digester
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def test_digester_default_sha256():
|
|
136
|
+
d = Digester()
|
|
137
|
+
stored = d.digest(MESSAGE)
|
|
138
|
+
assert d.matches(MESSAGE, stored) is True
|
|
139
|
+
assert d.matches("wrong", stored) is False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_jasypt_digest_and_matches():
|
|
143
|
+
j = Jasypt()
|
|
144
|
+
stored = j.digest(MESSAGE)
|
|
145
|
+
assert j.matches(MESSAGE, stored) is True
|
|
146
|
+
assert j.matches("wrong", stored) is False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@pytest.mark.parametrize("algorithm", SUPPORTED_DIGEST_ALGORITHMS)
|
|
150
|
+
def test_digester_all_algorithms(algorithm: str):
|
|
151
|
+
d = Digester()
|
|
152
|
+
d.set_algorithm(algorithm)
|
|
153
|
+
stored = d.digest(MESSAGE)
|
|
154
|
+
assert d.matches(MESSAGE, stored) is True
|
|
155
|
+
assert d.matches("wrong", stored) is False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_digester_custom_salt_and_iterations():
|
|
159
|
+
d = Digester()
|
|
160
|
+
d.set_algorithm("SHA-512")
|
|
161
|
+
d.set_salt("123456789012345")
|
|
162
|
+
d.set_iterations(500)
|
|
163
|
+
stored = d.digest(MESSAGE)
|
|
164
|
+
assert d.matches(MESSAGE, stored) is True
|
|
165
|
+
assert d.matches("other", stored) is False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_digester_unsupported_algorithm_raises():
|
|
169
|
+
d = Digester()
|
|
170
|
+
with pytest.raises(ValueError, match="Unsupported digest algorithm"):
|
|
171
|
+
d.set_algorithm("AES")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# Encryptor salt handling
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def test_encryptor_string_salt():
|
|
179
|
+
"""set_salt accepts a string and encodes it as UTF-8."""
|
|
180
|
+
enc = Encryptor()
|
|
181
|
+
enc.set_salt("myfix")
|
|
182
|
+
# Encrypt twice with same string salt — same ciphertext
|
|
183
|
+
c1 = enc.encrypt(MESSAGE, PASSWORD)
|
|
184
|
+
enc2 = Encryptor()
|
|
185
|
+
enc2.set_salt("myfix")
|
|
186
|
+
c2 = enc2.encrypt(MESSAGE, PASSWORD)
|
|
187
|
+
assert enc.decrypt(c1, PASSWORD) == MESSAGE
|
|
188
|
+
assert enc2.decrypt(c2, PASSWORD) == MESSAGE
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_encryptor_none_salt_generates_random():
|
|
192
|
+
enc = Encryptor()
|
|
193
|
+
enc.set_salt(None)
|
|
194
|
+
c1 = enc.encrypt(MESSAGE, PASSWORD)
|
|
195
|
+
enc2 = Encryptor()
|
|
196
|
+
enc2.set_salt(None)
|
|
197
|
+
c2 = enc2.encrypt(MESSAGE, PASSWORD)
|
|
198
|
+
# Each encrypt uses a fresh random salt — ciphertexts differ
|
|
199
|
+
assert c1 != c2
|
|
200
|
+
assert enc.decrypt(c1, PASSWORD) == MESSAGE
|
|
201
|
+
assert enc2.decrypt(c2, PASSWORD) == MESSAGE
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# Different messages and passwords
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
@pytest.mark.parametrize("msg,pw", [
|
|
209
|
+
("hello world", "secret"),
|
|
210
|
+
("", "anything"), # empty message → encrypt returns None
|
|
211
|
+
("unicode: \u00e9\u00e0\u00fc", "p@ss"),
|
|
212
|
+
("admin", ""), # empty password
|
|
213
|
+
])
|
|
214
|
+
def test_encrypt_decrypt_various(msg: str, pw: str):
|
|
215
|
+
j = Jasypt()
|
|
216
|
+
if msg == "":
|
|
217
|
+
assert j.encrypt(msg, pw) is None
|
|
218
|
+
else:
|
|
219
|
+
encrypted = j.encrypt(msg, pw)
|
|
220
|
+
assert j.decrypt(encrypted, pw) == msg
|