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 +39 -0
- purecrypt/_pbes2.py +215 -0
- purecrypt/_utils.py +51 -0
- purecrypt/aes.py +449 -0
- purecrypt/asn1.py +404 -0
- purecrypt/chacha.py +216 -0
- purecrypt/ec.py +726 -0
- purecrypt/eddsa.py +267 -0
- purecrypt/exceptions.py +38 -0
- purecrypt/hashes.py +152 -0
- purecrypt/kdf.py +447 -0
- purecrypt/pem.py +107 -0
- purecrypt/py.typed +0 -0
- purecrypt/rsa.py +794 -0
- purecrypt/x25519.py +184 -0
- purecrypt/x509.py +1203 -0
- purecrypt-0.1.1.dist-info/METADATA +103 -0
- purecrypt-0.1.1.dist-info/RECORD +20 -0
- purecrypt-0.1.1.dist-info/WHEEL +4 -0
- purecrypt-0.1.1.dist-info/licenses/LICENSE +12 -0
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)
|