ssl2 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.
ssl2/credentials.py ADDED
@@ -0,0 +1,125 @@
1
+ import base64
2
+ import ctypes
3
+ import os
4
+ import sys
5
+ from collections.abc import Callable, Sequence
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Final, Self
9
+
10
+ from ssl2.errors import ConfigurationError
11
+
12
+ LINUX_CANDIDATES: Final = (
13
+ Path("/etc/ssl/certs/ca-certificates.crt"),
14
+ Path("/etc/pki/tls/certs/ca-bundle.crt"),
15
+ Path("/etc/ssl/ca-bundle.pem"),
16
+ Path("/etc/pki/tls/cacert.pem"),
17
+ Path("/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem"),
18
+ Path("/etc/ssl/cert.pem"),
19
+ Path("/etc/ssl/certs"),
20
+ Path("/etc/pki/tls/certs"),
21
+ Path("/system/etc/security/cacerts"),
22
+ )
23
+ DARWIN_CANDIDATES: Final = (
24
+ Path("/etc/ssl/cert.pem"),
25
+ Path("/private/etc/ssl/cert.pem"),
26
+ )
27
+ X509_ASN_ENCODING: Final = 0x00000001
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class Credential:
32
+ buffer: bytes | None = None
33
+ path: str | os.PathLike[str] | None = None
34
+ id: bytes | None = None
35
+
36
+ @classmethod
37
+ def from_buffer(cls, buffer: bytes) -> Self:
38
+ return cls(buffer=buffer)
39
+
40
+ @classmethod
41
+ def from_file(cls, path: str | os.PathLike[str]) -> Self:
42
+ return cls(path=path)
43
+
44
+ @classmethod
45
+ def from_id(cls, id: bytes) -> Self:
46
+ return cls(id=id)
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class Certificate(Credential):
51
+ pass
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class PrivateKey(Credential):
56
+ pass
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class TrustStore(Credential):
61
+ @classmethod
62
+ def system(cls) -> Self:
63
+ for variable in ("SSL_CERT_FILE", "SSL_CERT_DIR"):
64
+ if variable in os.environ:
65
+ return cls(path=os.environ[variable])
66
+ if sys.platform == "win32":
67
+ return cls(buffer=cls._windows_bundle(ctypes.WinDLL("crypt32.dll")))
68
+ candidates = DARWIN_CANDIDATES if sys.platform == "darwin" else LINUX_CANDIDATES
69
+ for candidate in candidates:
70
+ if candidate.exists():
71
+ return cls(path=candidate)
72
+ raise ConfigurationError(f"no system trust store found on {sys.platform}")
73
+
74
+ @staticmethod
75
+ def _crypt32(
76
+ dll: ctypes.CDLL,
77
+ ) -> tuple[
78
+ Callable[[None, str], int | None],
79
+ Callable[[int, int | None], int | None],
80
+ Callable[[int, int], int],
81
+ ]:
82
+ dll.CertOpenSystemStoreW.restype = ctypes.c_void_p
83
+ dll.CertOpenSystemStoreW.argtypes = (ctypes.c_void_p, ctypes.c_wchar_p)
84
+ dll.CertEnumCertificatesInStore.restype = ctypes.c_void_p
85
+ dll.CertEnumCertificatesInStore.argtypes = (ctypes.c_void_p, ctypes.c_void_p)
86
+ dll.CertCloseStore.argtypes = (ctypes.c_void_p, ctypes.c_uint32)
87
+ return (
88
+ dll.CertOpenSystemStoreW,
89
+ dll.CertEnumCertificatesInStore,
90
+ dll.CertCloseStore,
91
+ )
92
+
93
+ @classmethod
94
+ def _windows_bundle(cls, dll: ctypes.CDLL) -> bytes:
95
+ open_store, enumerate_store, close_store = cls._crypt32(dll)
96
+ # CERT_CONTEXT starts with a u32 encoding type, a pointer to the DER bytes and their u32 length, each at pointer alignment
97
+ pointer = ctypes.sizeof(ctypes.c_void_p)
98
+ chunks: list[bytes] = []
99
+ for name in ("ROOT", "CA"):
100
+ store = open_store(None, name)
101
+ if store is None:
102
+ raise ConfigurationError(
103
+ f"cannot open the Windows {name} certificate store"
104
+ )
105
+ context = enumerate_store(store, None)
106
+ while context is not None:
107
+ encoding_type = ctypes.c_uint32.from_address(context).value
108
+ encoded = ctypes.c_void_p.from_address(context + pointer).value
109
+ length = ctypes.c_uint32.from_address(context + 2 * pointer).value
110
+ if encoding_type & X509_ASN_ENCODING and encoded is not None:
111
+ der = ctypes.string_at(encoded, length)
112
+ chunks.append(
113
+ b"-----BEGIN CERTIFICATE-----\n"
114
+ + base64.encodebytes(der)
115
+ + b"-----END CERTIFICATE-----\n"
116
+ )
117
+ context = enumerate_store(store, context)
118
+ _ = close_store(store, 0)
119
+ return b"".join(chunks)
120
+
121
+
122
+ @dataclass(frozen=True, slots=True)
123
+ class SigningChain:
124
+ leaf: tuple[Certificate, PrivateKey | None]
125
+ chain: Sequence[Certificate] | None = None
ssl2/errors.py ADDED
@@ -0,0 +1,22 @@
1
+ class TLSError(Exception):
2
+ pass
3
+
4
+
5
+ class WantWriteError(TLSError):
6
+ pass
7
+
8
+
9
+ class WantReadError(TLSError):
10
+ pass
11
+
12
+
13
+ class RaggedEOF(TLSError):
14
+ pass
15
+
16
+
17
+ class ConfigurationError(TLSError):
18
+ pass
19
+
20
+
21
+ class EarlyDataRejected(TLSError):
22
+ pass
ssl2/fingerprint.py ADDED
@@ -0,0 +1,326 @@
1
+ import hashlib
2
+ from dataclasses import dataclass
3
+ from enum import StrEnum
4
+ from typing import Final, Self
5
+
6
+ from ssl2.client_hello import ClientHello, ClientHelloBuilder
7
+ from ssl2.parameters import (
8
+ CipherSuite,
9
+ CodePoint,
10
+ ECPointFormat,
11
+ ExtensionType,
12
+ Http2SettingIdentifier,
13
+ NamedGroup,
14
+ ProtocolVersion,
15
+ SignatureScheme,
16
+ )
17
+
18
+ JA4_HASH_LENGTH: Final = 12
19
+ JA4_UNHASHED_EXTENSIONS: Final = frozenset(
20
+ {ExtensionType.server_name, ExtensionType.application_layer_protocol_negotiation}
21
+ )
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class JA3:
26
+ version: ProtocolVersion
27
+ cipher_suites: tuple[CipherSuite, ...]
28
+ extensions: tuple[ExtensionType, ...]
29
+ supported_groups: tuple[NamedGroup, ...]
30
+ ec_point_formats: tuple[ECPointFormat, ...]
31
+
32
+ @classmethod
33
+ def of(cls, client_hello: ClientHello) -> Self:
34
+ return cls(
35
+ client_hello.legacy_version,
36
+ client_hello.cipher_suites,
37
+ client_hello.extensions,
38
+ client_hello.supported_groups,
39
+ client_hello.ec_point_formats,
40
+ )
41
+
42
+ @classmethod
43
+ def parse(cls, text: str) -> Self:
44
+ fields = text.split(",")
45
+ if len(fields) != 5:
46
+ raise ValueError("JA3 text needs five comma separated fields")
47
+ version, cipher_suites, extensions, supported_groups, ec_point_formats = fields
48
+ return cls(
49
+ ProtocolVersion(int(version)),
50
+ cls._values(CipherSuite, cipher_suites),
51
+ cls._values(ExtensionType, extensions),
52
+ cls._values(NamedGroup, supported_groups),
53
+ cls._values(ECPointFormat, ec_point_formats),
54
+ )
55
+
56
+ def builder(self) -> ClientHelloBuilder:
57
+ return (
58
+ ClientHelloBuilder()
59
+ .legacy_version(self.version)
60
+ .cipher_suites(*self.cipher_suites)
61
+ .extensions(*self.extensions)
62
+ .supported_groups(*self.supported_groups)
63
+ .ec_point_formats(*self.ec_point_formats)
64
+ )
65
+
66
+ @property
67
+ def text(self) -> str:
68
+ fields = (
69
+ self.cipher_suites,
70
+ self.extensions,
71
+ self.supported_groups,
72
+ self.ec_point_formats,
73
+ )
74
+ return ",".join(
75
+ (str(int(self.version)), *(self._join(field) for field in fields))
76
+ )
77
+
78
+ @property
79
+ def digest(self) -> str:
80
+ return hashlib.md5(self.text.encode(), usedforsecurity=False).hexdigest()
81
+
82
+ @staticmethod
83
+ def _join(values: tuple[CodePoint, ...]) -> str:
84
+ return "-".join(str(int(value)) for value in values)
85
+
86
+ @staticmethod
87
+ def _values[CodePointT: CodePoint](
88
+ registry: type[CodePointT], text: str
89
+ ) -> tuple[CodePointT, ...]:
90
+ return tuple(registry(int(value)) for value in text.split("-") if value)
91
+
92
+
93
+ class JA4Transport(StrEnum):
94
+ TCP = "t"
95
+ QUIC = "q"
96
+ DTLS = "d"
97
+
98
+
99
+ class JA4Version(StrEnum):
100
+ TLS_1_3 = "13"
101
+ TLS_1_2 = "12"
102
+ TLS_1_1 = "11"
103
+ TLS_1_0 = "10"
104
+ SSL_3_0 = "s3"
105
+ DTLS_1_0 = "d1"
106
+ DTLS_1_2 = "d2"
107
+ DTLS_1_3 = "d3"
108
+ UNKNOWN = "00"
109
+
110
+
111
+ @dataclass(frozen=True, slots=True)
112
+ class JA4:
113
+ transport: JA4Transport
114
+ version: JA4Version
115
+ server_name_indicated: bool
116
+ cipher_count: int
117
+ extension_count: int
118
+ application_protocol_code: str
119
+ cipher_suites: tuple[CipherSuite, ...]
120
+ extensions: tuple[ExtensionType, ...]
121
+ signature_algorithms: tuple[SignatureScheme, ...]
122
+
123
+ @classmethod
124
+ def of(
125
+ cls, client_hello: ClientHello, transport: JA4Transport = JA4Transport.TCP
126
+ ) -> Self:
127
+ return cls(
128
+ transport,
129
+ JA4Version.__members__.get(
130
+ client_hello.highest_version.name, JA4Version.UNKNOWN
131
+ ),
132
+ client_hello.offers(ExtensionType.server_name),
133
+ len(client_hello.cipher_suites),
134
+ len(client_hello.extensions),
135
+ cls._protocol_code(client_hello.application_protocols),
136
+ client_hello.cipher_suites,
137
+ client_hello.extensions,
138
+ client_hello.signature_algorithms,
139
+ )
140
+
141
+ @classmethod
142
+ def parse(cls, text: str) -> Self:
143
+ sections = text.split("_")
144
+ if len(sections) not in (3, 4):
145
+ raise ValueError(
146
+ "JA4 text needs three or four underscore separated sections"
147
+ )
148
+ prefix = sections[0]
149
+ if len(prefix) != 10:
150
+ raise ValueError("the JA4 prefix is ten characters")
151
+ if any(cls._is_hash(section) for section in sections[1:3]):
152
+ raise ValueError(
153
+ "hashed JA4 carries no cipher or extension lists; parse the raw or original form"
154
+ )
155
+ signature_algorithms = (
156
+ cls._values(SignatureScheme, sections[3]) if len(sections) == 4 else ()
157
+ )
158
+ return cls(
159
+ JA4Transport(prefix[0]),
160
+ JA4Version(prefix[1:3]),
161
+ prefix[3] == "d",
162
+ int(prefix[4:6]),
163
+ int(prefix[6:8]),
164
+ prefix[8:10],
165
+ cls._values(CipherSuite, sections[1]),
166
+ cls._values(ExtensionType, sections[2]),
167
+ signature_algorithms,
168
+ )
169
+
170
+ @property
171
+ def prefix(self) -> str:
172
+ server_name = "d" if self.server_name_indicated else "i"
173
+ counts = f"{min(self.cipher_count, 99):02d}{min(self.extension_count, 99):02d}"
174
+ return f"{self.transport}{self.version}{server_name}{counts}{self.application_protocol_code}"
175
+
176
+ @property
177
+ def text(self) -> str:
178
+ return "_".join(
179
+ (
180
+ self.prefix,
181
+ self._hash(self._sorted_ciphers),
182
+ self._hash(self._sorted_extensions),
183
+ )
184
+ )
185
+
186
+ @property
187
+ def raw(self) -> str:
188
+ return f"{self.prefix}_{self._sorted_ciphers}_{self._sorted_extensions}"
189
+
190
+ @property
191
+ def original(self) -> str:
192
+ return "_".join(
193
+ (
194
+ self.prefix,
195
+ self._join(self.cipher_suites),
196
+ self._with_signature_algorithms(self._join(self.extensions)),
197
+ )
198
+ )
199
+
200
+ @property
201
+ def _sorted_ciphers(self) -> str:
202
+ return ",".join(sorted(cipher.hex for cipher in self.cipher_suites))
203
+
204
+ @property
205
+ def _sorted_extensions(self) -> str:
206
+ kept = sorted(
207
+ extension.hex
208
+ for extension in self.extensions
209
+ if extension not in JA4_UNHASHED_EXTENSIONS
210
+ )
211
+ return self._with_signature_algorithms(",".join(kept))
212
+
213
+ def _with_signature_algorithms(self, extensions: str) -> str:
214
+ if not extensions or not self.signature_algorithms:
215
+ return extensions
216
+ return extensions + "_" + self._join(self.signature_algorithms)
217
+
218
+ @staticmethod
219
+ def _join(values: tuple[CodePoint, ...]) -> str:
220
+ return ",".join(value.hex for value in values)
221
+
222
+ @staticmethod
223
+ def _values[CodePointT: CodePoint](
224
+ registry: type[CodePointT], text: str
225
+ ) -> tuple[CodePointT, ...]:
226
+ return tuple(registry(int(value, 16)) for value in text.split(",") if value)
227
+
228
+ @staticmethod
229
+ def _hash(section: str) -> str:
230
+ if not section:
231
+ return "0" * JA4_HASH_LENGTH
232
+ return hashlib.sha256(section.encode()).hexdigest()[:JA4_HASH_LENGTH]
233
+
234
+ @staticmethod
235
+ def _is_hash(section: str) -> bool:
236
+ return len(section) == JA4_HASH_LENGTH and "," not in section
237
+
238
+ @staticmethod
239
+ def _protocol_code(protocols: tuple[bytes, ...]) -> str:
240
+ if not protocols or not protocols[0]:
241
+ return "00"
242
+ first, last = protocols[0][0], protocols[0][-1]
243
+ if bytes((first,)).isalnum() and bytes((last,)).isalnum():
244
+ return chr(first) + chr(last)
245
+ return f"{first:02x}"[0] + f"{last:02x}"[1]
246
+
247
+
248
+ class PseudoHeader(StrEnum):
249
+ METHOD = "m"
250
+ AUTHORITY = "a"
251
+ SCHEME = "s"
252
+ PATH = "p"
253
+
254
+
255
+ @dataclass(frozen=True, slots=True)
256
+ class Http2Setting:
257
+ identifier: Http2SettingIdentifier
258
+ value: int
259
+
260
+ @property
261
+ def text(self) -> str:
262
+ return f"{int(self.identifier)}:{self.value}"
263
+
264
+
265
+ @dataclass(frozen=True, slots=True)
266
+ class Http2Priority:
267
+ stream_id: int
268
+ exclusive: bool
269
+ dependency: int
270
+ weight: int
271
+
272
+ @property
273
+ def text(self) -> str:
274
+ return f"{self.stream_id}:{int(self.exclusive)}:{self.dependency}:{self.weight}"
275
+
276
+
277
+ @dataclass(frozen=True, slots=True)
278
+ class AkamaiFingerprint:
279
+ settings: tuple[Http2Setting, ...]
280
+ window_update: int
281
+ priorities: tuple[Http2Priority, ...]
282
+ pseudo_header_order: tuple[PseudoHeader, ...]
283
+
284
+ @classmethod
285
+ def parse(cls, text: str) -> Self:
286
+ parts = text.split("|")
287
+ if len(parts) != 4:
288
+ raise ValueError("Akamai fingerprint text needs four pipe separated parts")
289
+ settings, window_update, priorities, order = parts
290
+ return cls(
291
+ tuple(cls._setting(item) for item in settings.split(";") if item),
292
+ int(window_update),
293
+ ()
294
+ if priorities == "0"
295
+ else tuple(cls._priority(item) for item in priorities.split(",")),
296
+ tuple(PseudoHeader(letter) for letter in order.split(",") if letter),
297
+ )
298
+
299
+ @property
300
+ def text(self) -> str:
301
+ priorities = ",".join(priority.text for priority in self.priorities) or "0"
302
+ settings = ";".join(setting.text for setting in self.settings)
303
+ return "|".join(
304
+ (
305
+ settings,
306
+ str(self.window_update),
307
+ priorities,
308
+ ",".join(header.value for header in self.pseudo_header_order),
309
+ )
310
+ )
311
+
312
+ @property
313
+ def digest(self) -> str:
314
+ return hashlib.md5(self.text.encode(), usedforsecurity=False).hexdigest()
315
+
316
+ @staticmethod
317
+ def _setting(item: str) -> Http2Setting:
318
+ identifier, value = item.split(":")
319
+ return Http2Setting(Http2SettingIdentifier(int(identifier)), int(value))
320
+
321
+ @staticmethod
322
+ def _priority(item: str) -> Http2Priority:
323
+ stream_id, exclusive, dependency, weight = item.split(":")
324
+ return Http2Priority(
325
+ int(stream_id), bool(int(exclusive)), int(dependency), int(weight)
326
+ )