purecrypt 0.1.1__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.
purecrypt/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ # SPDX-License-Identifier: 0BSD
2
+ """Pure-Python cryptographic primitives. No dependencies, no Rust.
3
+
4
+ .. warning::
5
+ Pure Python cannot provide constant-time execution. Secret-key
6
+ operations (signing, decryption, key exchange) are hardened against
7
+ the obvious leaks - RSA blinding, fixed-iteration scalar
8
+ multiplication, uniform error paths, constant-time-style compares -
9
+ but residual timing variance remains, so they must not run where a
10
+ co-located attacker can measure timing. Public-data operations
11
+ (certificate validation, signature verification) are unaffected.
12
+ For high-assurance deployments prefer audited native
13
+ implementations (OpenSSL via cryptography, libsodium via PyNaCl).
14
+ """
15
+
16
+ __version__ = "0.1.1"
17
+
18
+ from .exceptions import (
19
+ InvalidCertificate,
20
+ InvalidCiphertext,
21
+ InvalidKey,
22
+ InvalidSerialization,
23
+ InvalidSignature,
24
+ InvalidTag,
25
+ PureCryptError,
26
+ UnsupportedAlgorithm,
27
+ )
28
+
29
+ __all__ = [
30
+ "InvalidCertificate",
31
+ "InvalidCiphertext",
32
+ "InvalidKey",
33
+ "InvalidSerialization",
34
+ "InvalidSignature",
35
+ "InvalidTag",
36
+ "PureCryptError",
37
+ "UnsupportedAlgorithm",
38
+ "__version__",
39
+ ]
purecrypt/_pbes2.py ADDED
@@ -0,0 +1,215 @@
1
+ # SPDX-License-Identifier: 0BSD
2
+ """PBES2 password-encrypted PKCS#8 private keys (RFC 8018, RFC 5958).
3
+
4
+ Wraps PrivateKeyInfo DER into EncryptedPrivateKeyInfo using PBES2 with
5
+ PBKDF2 key derivation and AES-CBC with PKCS#7 padding. Encryption
6
+ always emits PBKDF2-HMAC-SHA256 at 600000 iterations (the OWASP
7
+ recommendation for that PRF) and AES-256-CBC, with 16-byte salt and IV
8
+ drawn from os.urandom. Decryption additionally accepts the SHA-1,
9
+ SHA-224, SHA-384 and SHA-512 PRFs and AES-128/192-CBC for interop with
10
+ other encoders.
11
+
12
+ WARNING: pure Python cannot provide constant-time guarantees. PBES2
13
+ over AES-CBC carries no integrity MAC, so a wrong password can yield
14
+ well-formed garbage and the only wrong-password signals are PKCS#7
15
+ padding plus a structural sanity check that the plaintext parses as
16
+ PrivateKeyInfo. Every failure mode collapses into the identical
17
+ InvalidKey("decryption failed") so no stage is distinguishable to the
18
+ caller. Do not use for production key storage.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+
25
+ from . import aes, asn1
26
+ from .exceptions import InvalidKey, PureCryptError, UnsupportedAlgorithm
27
+ from .kdf import pbkdf2
28
+
29
+ PEM_LABEL = "ENCRYPTED PRIVATE KEY"
30
+ DEFAULT_ITERATIONS = 600_000
31
+ _SALT_LEN = 16
32
+ _KEY_LEN = 32 # AES-256
33
+
34
+ # A ceiling on the declared PBKDF2 count bounds the work a hostile
35
+ # EncryptedPrivateKeyInfo can force during decryption.
36
+ _MAX_ITERATIONS = 10_000_000
37
+
38
+ _OID_PBKDF2 = "1.2.840.113549.1.5.12"
39
+ _OID_PBES2 = "1.2.840.113549.1.5.13"
40
+ _OID_AES256_CBC = "2.16.840.1.101.3.4.1.42"
41
+
42
+ _PRF_TO_OID = {
43
+ "sha1": "1.2.840.113549.2.7",
44
+ "sha224": "1.2.840.113549.2.8",
45
+ "sha256": "1.2.840.113549.2.9",
46
+ "sha384": "1.2.840.113549.2.10",
47
+ "sha512": "1.2.840.113549.2.11",
48
+ }
49
+ _OID_TO_PRF = {oid: name for name, oid in _PRF_TO_OID.items()}
50
+
51
+ _ENCRYPT_PRFS = frozenset({"sha256", "sha384", "sha512"})
52
+
53
+ # AES-CBC cipher OIDs mapped to their key length in bytes.
54
+ _OID_TO_KEYLEN = {
55
+ "2.16.840.1.101.3.4.1.2": 16,
56
+ "2.16.840.1.101.3.4.1.22": 24,
57
+ "2.16.840.1.101.3.4.1.42": 32,
58
+ }
59
+
60
+
61
+ def looks_encrypted(kids: tuple[asn1.DerNode, ...]) -> bool:
62
+ """True when SEQUENCE children have EncryptedPrivateKeyInfo shape."""
63
+ return (
64
+ len(kids) == 2
65
+ and kids[0].tag == asn1.TAG_SEQUENCE
66
+ and kids[1].tag == asn1.TAG_OCTET_STRING
67
+ )
68
+
69
+
70
+ def _password_bytes(password: bytes | str) -> bytes:
71
+ if isinstance(password, str):
72
+ return password.encode("utf-8")
73
+ return bytes(password)
74
+
75
+
76
+ def pbes2_encrypt(
77
+ private_key_info_der: bytes,
78
+ password: bytes | str,
79
+ *,
80
+ iterations: int = DEFAULT_ITERATIONS,
81
+ hash_name: str = "sha256",
82
+ ) -> bytes:
83
+ """Wrap PrivateKeyInfo DER in a PBES2 EncryptedPrivateKeyInfo.
84
+
85
+ Emits PBKDF2-HMAC with hash_name (sha256, sha384 or sha512) and
86
+ AES-256-CBC. The default iteration count follows the OWASP
87
+ recommendation for PBKDF2-HMAC-SHA256.
88
+ """
89
+ if hash_name not in _ENCRYPT_PRFS:
90
+ raise UnsupportedAlgorithm(
91
+ f"PBES2 encryption supports {sorted(_ENCRYPT_PRFS)}, not {hash_name!r}"
92
+ )
93
+ if not 1 <= iterations <= _MAX_ITERATIONS:
94
+ raise ValueError("PBES2 iterations out of supported range")
95
+ salt = os.urandom(_SALT_LEN)
96
+ iv = os.urandom(aes.BLOCK_SIZE)
97
+ key = pbkdf2(_password_bytes(password), salt, iterations, _KEY_LEN, hash_name)
98
+ ct = aes.cbc_encrypt(key, iv, aes.pkcs7_pad(private_key_info_der))
99
+ kdf_alg = asn1.encode_sequence(
100
+ asn1.encode_oid(_OID_PBKDF2),
101
+ asn1.encode_sequence(
102
+ asn1.encode_octet_string(salt),
103
+ asn1.encode_integer(iterations),
104
+ asn1.encode_integer(_KEY_LEN),
105
+ asn1.encode_sequence(
106
+ asn1.encode_oid(_PRF_TO_OID[hash_name]), asn1.encode_null()
107
+ ),
108
+ ),
109
+ )
110
+ enc_alg = asn1.encode_sequence(
111
+ asn1.encode_oid(_OID_AES256_CBC), asn1.encode_octet_string(iv)
112
+ )
113
+ return asn1.encode_sequence(
114
+ asn1.encode_sequence(
115
+ asn1.encode_oid(_OID_PBES2), asn1.encode_sequence(kdf_alg, enc_alg)
116
+ ),
117
+ asn1.encode_octet_string(ct),
118
+ )
119
+
120
+
121
+ def _parse_kdf(node: asn1.DerNode) -> tuple[str, bytes, int, int | None]:
122
+ """Parse the PBES2 keyDerivationFunc. Returns (hash, salt, iters, keylen)."""
123
+ kids = asn1.sequence_value(node)
124
+ if len(kids) != 2 or asn1.oid_value(kids[0]) != _OID_PBKDF2:
125
+ raise InvalidKey("decryption failed")
126
+ params = asn1.sequence_value(kids[1])
127
+ if not 2 <= len(params) <= 4:
128
+ raise InvalidKey("decryption failed")
129
+ # salt CHOICE: only the specified OCTET STRING form is supported.
130
+ salt = asn1.octet_string_value(params[0])
131
+ iterations = asn1.int_value(params[1])
132
+ if not 1 <= iterations <= _MAX_ITERATIONS:
133
+ raise InvalidKey("decryption failed")
134
+ keylen: int | None = None
135
+ hash_name = "sha1" # prf is DEFAULT hmacWithSHA1 when absent
136
+ for extra in params[2:]:
137
+ if extra.tag == asn1.TAG_INTEGER:
138
+ keylen = asn1.int_value(extra)
139
+ elif extra.tag == asn1.TAG_SEQUENCE:
140
+ prf = asn1.sequence_value(extra)
141
+ if not 1 <= len(prf) <= 2:
142
+ raise InvalidKey("decryption failed")
143
+ oid = asn1.oid_value(prf[0])
144
+ if oid not in _OID_TO_PRF:
145
+ raise InvalidKey("decryption failed")
146
+ hash_name = _OID_TO_PRF[oid]
147
+ if len(prf) == 2:
148
+ asn1.null_value(prf[1])
149
+ else:
150
+ raise InvalidKey("decryption failed")
151
+ return hash_name, salt, iterations, keylen
152
+
153
+
154
+ def _parse_cipher(node: asn1.DerNode) -> tuple[int, bytes]:
155
+ """Parse the PBES2 encryptionScheme. Returns (key length, IV)."""
156
+ kids = asn1.sequence_value(node)
157
+ if len(kids) != 2:
158
+ raise InvalidKey("decryption failed")
159
+ oid = asn1.oid_value(kids[0])
160
+ if oid not in _OID_TO_KEYLEN:
161
+ raise InvalidKey("decryption failed")
162
+ iv = asn1.octet_string_value(kids[1])
163
+ if len(iv) != aes.BLOCK_SIZE:
164
+ raise InvalidKey("decryption failed")
165
+ return _OID_TO_KEYLEN[oid], iv
166
+
167
+
168
+ def _check_private_key_info(der: bytes) -> None:
169
+ """Require the plaintext to parse as a PrivateKeyInfo skeleton.
170
+
171
+ PBES2 over AES-CBC has no MAC, so this shape check is the second
172
+ wrong-password signal after PKCS#7 padding. It is a sanity gate,
173
+ not authentication. Version v1(0) and v2(1) are both accepted.
174
+ """
175
+ kids = asn1.sequence_value(asn1.decode(der))
176
+ if len(kids) < 3:
177
+ raise InvalidKey("decryption failed")
178
+ if asn1.int_value(kids[0]) not in (0, 1):
179
+ raise InvalidKey("decryption failed")
180
+ asn1.expect(kids[1], asn1.TAG_SEQUENCE)
181
+ asn1.expect(kids[2], asn1.TAG_OCTET_STRING)
182
+
183
+
184
+ def pbes2_decrypt(
185
+ encrypted_private_key_info_der: bytes, password: bytes | str
186
+ ) -> bytes:
187
+ """Unwrap an EncryptedPrivateKeyInfo into PrivateKeyInfo DER.
188
+
189
+ Every failure mode (malformed DER, unsupported parameters, wrong
190
+ password, bad padding, implausible plaintext) raises the identical
191
+ InvalidKey("decryption failed").
192
+ """
193
+ try:
194
+ kids = asn1.sequence_value(asn1.decode(encrypted_private_key_info_der))
195
+ if not looks_encrypted(kids):
196
+ raise InvalidKey("decryption failed")
197
+ alg_kids = asn1.sequence_value(kids[0])
198
+ if len(alg_kids) != 2 or asn1.oid_value(alg_kids[0]) != _OID_PBES2:
199
+ raise InvalidKey("decryption failed")
200
+ params = asn1.sequence_value(alg_kids[1])
201
+ if len(params) != 2:
202
+ raise InvalidKey("decryption failed")
203
+ hash_name, salt, iterations, keylen = _parse_kdf(params[0])
204
+ key_size, iv = _parse_cipher(params[1])
205
+ if keylen is not None and keylen != key_size:
206
+ raise InvalidKey("decryption failed")
207
+ ct = asn1.octet_string_value(kids[1])
208
+ if len(ct) == 0 or len(ct) % aes.BLOCK_SIZE != 0:
209
+ raise InvalidKey("decryption failed")
210
+ key = pbkdf2(_password_bytes(password), salt, iterations, key_size, hash_name)
211
+ plain = aes.pkcs7_unpad(aes.cbc_decrypt(key, iv, ct))
212
+ _check_private_key_info(plain)
213
+ except (PureCryptError, ValueError):
214
+ raise InvalidKey("decryption failed") from None
215
+ return plain
purecrypt/_utils.py ADDED
@@ -0,0 +1,51 @@
1
+ # SPDX-License-Identifier: 0BSD
2
+ """Shared internals: integer/byte conversion, xor, secret hygiene.
3
+
4
+ Nothing here is public API.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hmac
10
+
11
+
12
+ def i2osp(value: int, length: int) -> bytes:
13
+ """Integer-to-octet-string primitive (RFC 8017)."""
14
+ if value < 0 or value >= 1 << (8 * length):
15
+ raise ValueError("integer too large for requested length")
16
+ return value.to_bytes(length, "big")
17
+
18
+
19
+ def os2ip(data: bytes) -> int:
20
+ """Octet-string-to-integer primitive (RFC 8017)."""
21
+ return int.from_bytes(data, "big")
22
+
23
+
24
+ def xor_bytes(a: bytes, b: bytes) -> bytes:
25
+ """XOR two equal-length byte strings."""
26
+ if len(a) != len(b):
27
+ raise ValueError("xor operands differ in length")
28
+ return bytes(x ^ y for x, y in zip(a, b, strict=True))
29
+
30
+
31
+ def ct_equal(a: bytes, b: bytes) -> bool:
32
+ """Constant-time-ish byte comparison via hmac.compare_digest.
33
+
34
+ Pure Python cannot guarantee constant time. This is the best the
35
+ language offers and avoids the obvious early-exit pitfalls.
36
+ """
37
+ return hmac.compare_digest(a, b)
38
+
39
+
40
+ def wipe(buf: bytearray) -> None:
41
+ """Best-effort overwrite of mutable secret material.
42
+
43
+ CPython may keep other copies of the data alive elsewhere. This is
44
+ hygiene, not a guarantee.
45
+ """
46
+ for i in range(len(buf)):
47
+ buf[i] = 0
48
+
49
+
50
+ def ceil_div(a: int, b: int) -> int:
51
+ return -(-a // b)