alt-python-pysypt 1.0.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,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,7 @@
1
+ pysypt/__init__.py,sha256=fhi4y7ZAL_U5_E85uMpr7IGrkYzz12TuhtM72iGLjZk,801
2
+ pysypt/digester.py,sha256=PZ-52U8SraTR1JEs_cMrzvpN5VluJyy33l1biti7aqg,5544
3
+ pysypt/encryptor.py,sha256=f-5IBZ0kn2wYMdxjz16yFLst07O4Zcr-wITMSK6x1Qs,12110
4
+ pysypt/jasypt.py,sha256=Sa1ZEUiRVJNLAxkagXFe2vXWyf2ZbOyUwCrZ4BR-MBI,2498
5
+ alt_python_pysypt-1.0.0.dist-info/METADATA,sha256=7L_Mq6IAJ7V-hB79BVw7dtKZ0AQO2SfMdO51HymslWw,216
6
+ alt_python_pysypt-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
7
+ alt_python_pysypt-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
pysypt/__init__.py ADDED
@@ -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
+ ]
pysypt/digester.py ADDED
@@ -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)
pysypt/encryptor.py ADDED
@@ -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}")
pysypt/jasypt.py ADDED
@@ -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
+ )