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.
@@ -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,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: alt-python-pysypt
3
+ Version: 1.0.0
4
+ Summary: Jasypt-compatible PBE encryption and digest for Python
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: alt-python-common
7
+ Requires-Dist: cryptography>=42.0
@@ -0,0 +1,261 @@
1
+ # pysypt
2
+
3
+ [![Language](https://img.shields.io/badge/language-Python-3776ab.svg)](https://www.python.org/)
4
+ [![Python](https://img.shields.io/badge/python-3.12%2B-blue.svg)](https://www.python.org/downloads/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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