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/__init__.py +106 -0
- ssl2/asynchronous.py +217 -0
- ssl2/client_hello.py +353 -0
- ssl2/credentials.py +125 -0
- ssl2/errors.py +22 -0
- ssl2/fingerprint.py +326 -0
- ssl2/interface.py +398 -0
- ssl2/parameters.py +731 -0
- ssl2/proxy.py +308 -0
- ssl2/py.typed +0 -0
- ssl2-1.0.0.dist-info/METADATA +180 -0
- ssl2-1.0.0.dist-info/RECORD +13 -0
- ssl2-1.0.0.dist-info/WHEEL +4 -0
ssl2/__init__.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from ssl2.asynchronous import AsyncTransport, StreamTransport, TLSStream
|
|
2
|
+
from ssl2.client_hello import ClientHello, ClientHelloBuilder, ClientHelloParser
|
|
3
|
+
from ssl2.credentials import (
|
|
4
|
+
Certificate,
|
|
5
|
+
Credential,
|
|
6
|
+
PrivateKey,
|
|
7
|
+
SigningChain,
|
|
8
|
+
TrustStore,
|
|
9
|
+
)
|
|
10
|
+
from ssl2.errors import (
|
|
11
|
+
ConfigurationError,
|
|
12
|
+
EarlyDataRejected,
|
|
13
|
+
RaggedEOF,
|
|
14
|
+
TLSError,
|
|
15
|
+
WantReadError,
|
|
16
|
+
WantWriteError,
|
|
17
|
+
)
|
|
18
|
+
from ssl2.fingerprint import (
|
|
19
|
+
JA3,
|
|
20
|
+
JA4,
|
|
21
|
+
AkamaiFingerprint,
|
|
22
|
+
Http2Priority,
|
|
23
|
+
Http2Setting,
|
|
24
|
+
JA4Transport,
|
|
25
|
+
JA4Version,
|
|
26
|
+
PseudoHeader,
|
|
27
|
+
)
|
|
28
|
+
from ssl2.interface import (
|
|
29
|
+
ClientContext,
|
|
30
|
+
ServerContext,
|
|
31
|
+
SessionCache,
|
|
32
|
+
TLSBuffer,
|
|
33
|
+
TLSClientConfiguration,
|
|
34
|
+
TLSImplementation,
|
|
35
|
+
TLSServerConfiguration,
|
|
36
|
+
TLSSocket,
|
|
37
|
+
Transport,
|
|
38
|
+
)
|
|
39
|
+
from ssl2.parameters import (
|
|
40
|
+
CertificateCompressionAlgorithm,
|
|
41
|
+
CipherSuite,
|
|
42
|
+
CodePoint,
|
|
43
|
+
ECPointFormat,
|
|
44
|
+
ExtensionType,
|
|
45
|
+
Http2SettingIdentifier,
|
|
46
|
+
NamedGroup,
|
|
47
|
+
NextProtocol,
|
|
48
|
+
ProtocolVersion,
|
|
49
|
+
PskKeyExchangeMode,
|
|
50
|
+
SignatureScheme,
|
|
51
|
+
TLSVersion,
|
|
52
|
+
)
|
|
53
|
+
from ssl2.proxy import Proxy, ProxyCredentials, ProxyError, ProxyScheme
|
|
54
|
+
|
|
55
|
+
__all__ = (
|
|
56
|
+
"JA3",
|
|
57
|
+
"JA4",
|
|
58
|
+
"AkamaiFingerprint",
|
|
59
|
+
"AsyncTransport",
|
|
60
|
+
"Certificate",
|
|
61
|
+
"CertificateCompressionAlgorithm",
|
|
62
|
+
"CipherSuite",
|
|
63
|
+
"ClientContext",
|
|
64
|
+
"ClientHello",
|
|
65
|
+
"ClientHelloBuilder",
|
|
66
|
+
"ClientHelloParser",
|
|
67
|
+
"CodePoint",
|
|
68
|
+
"ConfigurationError",
|
|
69
|
+
"Credential",
|
|
70
|
+
"ECPointFormat",
|
|
71
|
+
"EarlyDataRejected",
|
|
72
|
+
"ExtensionType",
|
|
73
|
+
"Http2Priority",
|
|
74
|
+
"Http2Setting",
|
|
75
|
+
"Http2SettingIdentifier",
|
|
76
|
+
"JA4Transport",
|
|
77
|
+
"JA4Version",
|
|
78
|
+
"NamedGroup",
|
|
79
|
+
"NextProtocol",
|
|
80
|
+
"PrivateKey",
|
|
81
|
+
"ProtocolVersion",
|
|
82
|
+
"Proxy",
|
|
83
|
+
"ProxyCredentials",
|
|
84
|
+
"ProxyError",
|
|
85
|
+
"ProxyScheme",
|
|
86
|
+
"PseudoHeader",
|
|
87
|
+
"PskKeyExchangeMode",
|
|
88
|
+
"RaggedEOF",
|
|
89
|
+
"ServerContext",
|
|
90
|
+
"SessionCache",
|
|
91
|
+
"SignatureScheme",
|
|
92
|
+
"SigningChain",
|
|
93
|
+
"StreamTransport",
|
|
94
|
+
"TLSBuffer",
|
|
95
|
+
"TLSClientConfiguration",
|
|
96
|
+
"TLSError",
|
|
97
|
+
"TLSImplementation",
|
|
98
|
+
"TLSServerConfiguration",
|
|
99
|
+
"TLSSocket",
|
|
100
|
+
"TLSStream",
|
|
101
|
+
"TLSVersion",
|
|
102
|
+
"Transport",
|
|
103
|
+
"TrustStore",
|
|
104
|
+
"WantReadError",
|
|
105
|
+
"WantWriteError",
|
|
106
|
+
)
|
ssl2/asynchronous.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import socket
|
|
3
|
+
from collections.abc import Callable, Generator
|
|
4
|
+
from functools import partial
|
|
5
|
+
from typing import Final, Protocol, Self, cast, final
|
|
6
|
+
|
|
7
|
+
from ssl2.errors import RaggedEOF, WantReadError, WantWriteError
|
|
8
|
+
from ssl2.interface import RECEIVE_SIZE, ClientContext, ServerContext, TLSBuffer
|
|
9
|
+
from ssl2.parameters import CipherSuite, NextProtocol, TLSVersion
|
|
10
|
+
from ssl2.proxy import Proxy, ProxyError, ProxyScheme
|
|
11
|
+
|
|
12
|
+
SHUTDOWN_TIMEOUT: Final = 5.0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AsyncTransport(Protocol):
|
|
16
|
+
async def read(self, limit: int, /) -> bytes: ...
|
|
17
|
+
|
|
18
|
+
async def write(self, data: bytes, /) -> None: ...
|
|
19
|
+
|
|
20
|
+
async def close(self) -> None: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@final
|
|
24
|
+
class StreamTransport:
|
|
25
|
+
def __init__(
|
|
26
|
+
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
27
|
+
) -> None:
|
|
28
|
+
self._reader = reader
|
|
29
|
+
self._writer = writer
|
|
30
|
+
|
|
31
|
+
async def read(self, limit: int) -> bytes:
|
|
32
|
+
return await self._reader.read(limit)
|
|
33
|
+
|
|
34
|
+
async def write(self, data: bytes) -> None:
|
|
35
|
+
self._writer.write(data)
|
|
36
|
+
await self._writer.drain()
|
|
37
|
+
|
|
38
|
+
async def close(self) -> None:
|
|
39
|
+
self._writer.close()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@final
|
|
43
|
+
class TLSStream:
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
context: ClientContext | ServerContext,
|
|
47
|
+
buffer: TLSBuffer,
|
|
48
|
+
transport: AsyncTransport,
|
|
49
|
+
) -> None:
|
|
50
|
+
self._context = context
|
|
51
|
+
self._buffer = buffer
|
|
52
|
+
self._transport = transport
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
async def connect(
|
|
56
|
+
cls,
|
|
57
|
+
context: ClientContext,
|
|
58
|
+
address: tuple[str, int],
|
|
59
|
+
*,
|
|
60
|
+
proxy: Proxy | None = None,
|
|
61
|
+
timeout: float | None = None,
|
|
62
|
+
) -> Self:
|
|
63
|
+
async with asyncio.timeout(timeout):
|
|
64
|
+
transport, leftover = await cls.tunnel(context, address, proxy)
|
|
65
|
+
try:
|
|
66
|
+
return await cls.open(
|
|
67
|
+
context, context.create_buffer(address[0]), transport, leftover
|
|
68
|
+
)
|
|
69
|
+
except BaseException:
|
|
70
|
+
await transport.close()
|
|
71
|
+
raise
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
async def tunnel(
|
|
75
|
+
cls, context: ClientContext, address: tuple[str, int], proxy: Proxy | None
|
|
76
|
+
) -> tuple[AsyncTransport, bytes]:
|
|
77
|
+
if proxy is None:
|
|
78
|
+
return StreamTransport(*await asyncio.open_connection(*address)), b""
|
|
79
|
+
transport: AsyncTransport
|
|
80
|
+
if proxy.scheme is ProxyScheme.HTTPS:
|
|
81
|
+
transport = await cls.connect(context, (proxy.host, proxy.port))
|
|
82
|
+
else:
|
|
83
|
+
transport = StreamTransport(
|
|
84
|
+
*await asyncio.open_connection(proxy.host, proxy.port)
|
|
85
|
+
)
|
|
86
|
+
try:
|
|
87
|
+
return transport, await cls._tunnel(proxy, address, transport)
|
|
88
|
+
except BaseException:
|
|
89
|
+
await transport.close()
|
|
90
|
+
raise
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
async def open(
|
|
94
|
+
cls,
|
|
95
|
+
context: ClientContext | ServerContext,
|
|
96
|
+
buffer: TLSBuffer,
|
|
97
|
+
transport: AsyncTransport,
|
|
98
|
+
leftover: bytes = b"",
|
|
99
|
+
) -> Self:
|
|
100
|
+
self = cls(context, buffer, transport)
|
|
101
|
+
if leftover:
|
|
102
|
+
buffer.process_incoming(leftover)
|
|
103
|
+
await self._run(buffer.do_handshake)
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def context(self) -> ClientContext | ServerContext:
|
|
108
|
+
return self._context
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def buffer(self) -> TLSBuffer:
|
|
112
|
+
return self._buffer
|
|
113
|
+
|
|
114
|
+
async def read(self, limit: int) -> bytes:
|
|
115
|
+
try:
|
|
116
|
+
return await self._run(lambda: self._buffer.read(limit))
|
|
117
|
+
except RaggedEOF:
|
|
118
|
+
return b""
|
|
119
|
+
|
|
120
|
+
async def write(self, data: bytes) -> None:
|
|
121
|
+
view = memoryview(data)
|
|
122
|
+
while view:
|
|
123
|
+
written = await self._run(partial(self._buffer.write, view))
|
|
124
|
+
view = view[written:]
|
|
125
|
+
|
|
126
|
+
async def close(self) -> None:
|
|
127
|
+
try:
|
|
128
|
+
async with asyncio.timeout(SHUTDOWN_TIMEOUT):
|
|
129
|
+
await self._run(self._buffer.shutdown)
|
|
130
|
+
except TimeoutError, RaggedEOF, OSError:
|
|
131
|
+
pass
|
|
132
|
+
await self._transport.close()
|
|
133
|
+
|
|
134
|
+
def cipher(self) -> CipherSuite | int | None:
|
|
135
|
+
return self._buffer.cipher()
|
|
136
|
+
|
|
137
|
+
def negotiated_protocol(self) -> NextProtocol | bytes | None:
|
|
138
|
+
return self._buffer.negotiated_protocol()
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def negotiated_tls_version(self) -> TLSVersion | None:
|
|
142
|
+
return self._buffer.negotiated_tls_version
|
|
143
|
+
|
|
144
|
+
def getpeercert(self) -> bytes | None:
|
|
145
|
+
return self._buffer.getpeercert()
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
async def associate(
|
|
149
|
+
cls, proxy: Proxy, transport: AsyncTransport
|
|
150
|
+
) -> tuple[str, int]:
|
|
151
|
+
_, (host, port) = await cls._converse(
|
|
152
|
+
proxy.associate(("0.0.0.0", 0)), transport
|
|
153
|
+
)
|
|
154
|
+
return (proxy.host if host in ("0.0.0.0", "::") else host, port)
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
async def _tunnel(
|
|
158
|
+
cls, proxy: Proxy, destination: tuple[str, int], transport: AsyncTransport
|
|
159
|
+
) -> bytes:
|
|
160
|
+
host, port = destination
|
|
161
|
+
if (family := proxy.scheme.local_address_family) is not None:
|
|
162
|
+
candidates = await asyncio.get_running_loop().getaddrinfo(
|
|
163
|
+
host, port, family=family, type=socket.SOCK_STREAM
|
|
164
|
+
)
|
|
165
|
+
host = str(candidates[0][4][0])
|
|
166
|
+
leftover, _ = await cls._converse(proxy.converse((host, port)), transport)
|
|
167
|
+
return leftover
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
async def _converse[ResultT](
|
|
171
|
+
conversation: Generator[tuple[bytes, int | bytes], bytes, ResultT],
|
|
172
|
+
transport: AsyncTransport,
|
|
173
|
+
) -> tuple[bytes, ResultT]:
|
|
174
|
+
pending = bytearray()
|
|
175
|
+
step = next(conversation)
|
|
176
|
+
while True:
|
|
177
|
+
request, expected = step
|
|
178
|
+
if request:
|
|
179
|
+
await transport.write(request)
|
|
180
|
+
while not (end := Proxy.reply_length(pending, expected)):
|
|
181
|
+
piece = await transport.read(RECEIVE_SIZE)
|
|
182
|
+
if not piece:
|
|
183
|
+
raise ProxyError(
|
|
184
|
+
"the proxy closed the connection during the tunnel handshake"
|
|
185
|
+
)
|
|
186
|
+
pending += piece
|
|
187
|
+
reply = bytes(pending[:end])
|
|
188
|
+
del pending[:end]
|
|
189
|
+
try:
|
|
190
|
+
step = conversation.send(reply)
|
|
191
|
+
except StopIteration as stop:
|
|
192
|
+
return bytes(pending), cast(ResultT, stop.value)
|
|
193
|
+
|
|
194
|
+
async def _run[ResultT](self, operation: Callable[[], ResultT]) -> ResultT:
|
|
195
|
+
while True:
|
|
196
|
+
try:
|
|
197
|
+
result = operation()
|
|
198
|
+
except WantReadError:
|
|
199
|
+
await self._flush()
|
|
200
|
+
await self._fill()
|
|
201
|
+
continue
|
|
202
|
+
except WantWriteError:
|
|
203
|
+
await self._flush()
|
|
204
|
+
continue
|
|
205
|
+
await self._flush()
|
|
206
|
+
return result
|
|
207
|
+
|
|
208
|
+
async def _flush(self) -> None:
|
|
209
|
+
# one take of everything buffered: draining a memory BIO in small pieces moves the remainder each time
|
|
210
|
+
while pending := self._buffer.outgoing_bytes_buffered():
|
|
211
|
+
await self._transport.write(self._buffer.process_outgoing(pending))
|
|
212
|
+
|
|
213
|
+
async def _fill(self) -> None:
|
|
214
|
+
data = await self._transport.read(RECEIVE_SIZE)
|
|
215
|
+
if not data:
|
|
216
|
+
raise RaggedEOF("the transport closed before close_notify")
|
|
217
|
+
self._buffer.process_incoming(data)
|
ssl2/client_hello.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
from collections.abc import Iterable
|
|
2
|
+
from dataclasses import dataclass, replace
|
|
3
|
+
from typing import Final, Self, final
|
|
4
|
+
|
|
5
|
+
from ssl2.parameters import (
|
|
6
|
+
CertificateCompressionAlgorithm,
|
|
7
|
+
CipherSuite,
|
|
8
|
+
CodePoint,
|
|
9
|
+
ContentType,
|
|
10
|
+
ECPointFormat,
|
|
11
|
+
ExtensionType,
|
|
12
|
+
HandshakeType,
|
|
13
|
+
NamedGroup,
|
|
14
|
+
NextProtocol,
|
|
15
|
+
ProtocolVersion,
|
|
16
|
+
PskKeyExchangeMode,
|
|
17
|
+
SignatureScheme,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
22
|
+
class ClientHello:
|
|
23
|
+
legacy_version: ProtocolVersion = ProtocolVersion.TLS_1_2
|
|
24
|
+
supported_versions: tuple[ProtocolVersion, ...] = (ProtocolVersion.TLS_1_2,)
|
|
25
|
+
cipher_suites: tuple[CipherSuite, ...]
|
|
26
|
+
extensions: tuple[ExtensionType, ...]
|
|
27
|
+
supported_groups: tuple[NamedGroup, ...] = ()
|
|
28
|
+
key_share_groups: tuple[NamedGroup, ...] = ()
|
|
29
|
+
ec_point_formats: tuple[ECPointFormat, ...] = ()
|
|
30
|
+
signature_algorithms: tuple[SignatureScheme, ...] = ()
|
|
31
|
+
application_protocols: tuple[bytes, ...] = ()
|
|
32
|
+
application_settings: tuple[bytes, ...] = ()
|
|
33
|
+
certificate_compression_algorithms: tuple[CertificateCompressionAlgorithm, ...] = ()
|
|
34
|
+
psk_key_exchange_modes: tuple[PskKeyExchangeMode, ...] = ()
|
|
35
|
+
delegated_credential_schemes: tuple[SignatureScheme, ...] = ()
|
|
36
|
+
record_size_limit: int | None = None
|
|
37
|
+
grease: bool = False
|
|
38
|
+
permute_extensions: bool = False
|
|
39
|
+
|
|
40
|
+
def __post_init__(self) -> None:
|
|
41
|
+
if len(set(self.extensions)) != len(self.extensions):
|
|
42
|
+
raise ValueError("extensions must not repeat")
|
|
43
|
+
if len(set(self.supported_versions)) != len(self.supported_versions):
|
|
44
|
+
raise ValueError("supported_versions must not repeat")
|
|
45
|
+
payloads: tuple[tuple[object, ExtensionType], ...] = (
|
|
46
|
+
(self.supported_groups, ExtensionType.supported_groups),
|
|
47
|
+
(self.key_share_groups, ExtensionType.key_share),
|
|
48
|
+
(self.ec_point_formats, ExtensionType.ec_point_formats),
|
|
49
|
+
(self.signature_algorithms, ExtensionType.signature_algorithms),
|
|
50
|
+
(
|
|
51
|
+
self.application_protocols,
|
|
52
|
+
ExtensionType.application_layer_protocol_negotiation,
|
|
53
|
+
),
|
|
54
|
+
(
|
|
55
|
+
self.certificate_compression_algorithms,
|
|
56
|
+
ExtensionType.compress_certificate,
|
|
57
|
+
),
|
|
58
|
+
(self.psk_key_exchange_modes, ExtensionType.psk_key_exchange_modes),
|
|
59
|
+
(self.delegated_credential_schemes, ExtensionType.delegated_credential),
|
|
60
|
+
(self.record_size_limit, ExtensionType.record_size_limit),
|
|
61
|
+
)
|
|
62
|
+
for value, extension in payloads:
|
|
63
|
+
if bool(value) != self.offers(extension):
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"{extension.name} needs its payload and its extension together"
|
|
66
|
+
)
|
|
67
|
+
settings = {
|
|
68
|
+
ExtensionType.application_settings,
|
|
69
|
+
ExtensionType.application_settings_old,
|
|
70
|
+
}
|
|
71
|
+
if len(settings & set(self.extensions)) != bool(self.application_settings):
|
|
72
|
+
raise ValueError(
|
|
73
|
+
"application_settings needs exactly one application_settings extension codepoint"
|
|
74
|
+
)
|
|
75
|
+
if not self._is_subsequence(self.key_share_groups, self.supported_groups):
|
|
76
|
+
raise ValueError(
|
|
77
|
+
"key_share_groups must follow the order of supported_groups"
|
|
78
|
+
)
|
|
79
|
+
if not self.offers(
|
|
80
|
+
ExtensionType.supported_versions
|
|
81
|
+
) and self.supported_versions != (self.legacy_version,):
|
|
82
|
+
raise ValueError(
|
|
83
|
+
"without the supported_versions extension only the legacy version is offered"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def offers(self, extension: ExtensionType) -> bool:
|
|
87
|
+
return extension in self.extensions
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def highest_version(self) -> ProtocolVersion:
|
|
91
|
+
return max(self.supported_versions)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def lowest_version(self) -> ProtocolVersion:
|
|
95
|
+
return min(self.supported_versions)
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _is_subsequence(
|
|
99
|
+
part: tuple[NamedGroup, ...], whole: tuple[NamedGroup, ...]
|
|
100
|
+
) -> bool:
|
|
101
|
+
position = 0
|
|
102
|
+
for group in whole:
|
|
103
|
+
if position < len(part) and part[position] == group:
|
|
104
|
+
position += 1
|
|
105
|
+
return position == len(part)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
EMPTY_CLIENT_HELLO: Final = ClientHello(cipher_suites=(), extensions=())
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ClientHelloBuilder:
|
|
112
|
+
def __init__(self) -> None:
|
|
113
|
+
self._fields: dict[str, object] = {}
|
|
114
|
+
|
|
115
|
+
def legacy_version(self, version: ProtocolVersion | int) -> Self:
|
|
116
|
+
return self._set("legacy_version", ProtocolVersion(version))
|
|
117
|
+
|
|
118
|
+
def supported_versions(self, *versions: ProtocolVersion | int) -> Self:
|
|
119
|
+
return self._set(
|
|
120
|
+
"supported_versions",
|
|
121
|
+
tuple(ProtocolVersion(version) for version in versions),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def cipher_suites(self, *suites: CipherSuite | int) -> Self:
|
|
125
|
+
return self._set("cipher_suites", tuple(CipherSuite(suite) for suite in suites))
|
|
126
|
+
|
|
127
|
+
def extensions(self, *types: ExtensionType | int) -> Self:
|
|
128
|
+
return self._set("extensions", tuple(ExtensionType(type) for type in types))
|
|
129
|
+
|
|
130
|
+
def supported_groups(self, *groups: NamedGroup | int) -> Self:
|
|
131
|
+
return self._set(
|
|
132
|
+
"supported_groups", tuple(NamedGroup(group) for group in groups)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def key_shares(self, *groups: NamedGroup | int) -> Self:
|
|
136
|
+
return self._set(
|
|
137
|
+
"key_share_groups", tuple(NamedGroup(group) for group in groups)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def ec_point_formats(self, *formats: ECPointFormat | int) -> Self:
|
|
141
|
+
return self._set(
|
|
142
|
+
"ec_point_formats", tuple(ECPointFormat(format) for format in formats)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def signature_algorithms(self, *schemes: SignatureScheme | int) -> Self:
|
|
146
|
+
return self._set(
|
|
147
|
+
"signature_algorithms", tuple(SignatureScheme(scheme) for scheme in schemes)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def application_protocols(self, *protocols: NextProtocol | bytes) -> Self:
|
|
151
|
+
return self._set("application_protocols", self._protocol_bytes(protocols))
|
|
152
|
+
|
|
153
|
+
def application_settings(self, *protocols: NextProtocol | bytes) -> Self:
|
|
154
|
+
return self._set("application_settings", self._protocol_bytes(protocols))
|
|
155
|
+
|
|
156
|
+
def certificate_compression(
|
|
157
|
+
self, *algorithms: CertificateCompressionAlgorithm | int
|
|
158
|
+
) -> Self:
|
|
159
|
+
algorithms_typed = tuple(
|
|
160
|
+
CertificateCompressionAlgorithm(algorithm) for algorithm in algorithms
|
|
161
|
+
)
|
|
162
|
+
return self._set("certificate_compression_algorithms", algorithms_typed)
|
|
163
|
+
|
|
164
|
+
def psk_key_exchange_modes(self, *modes: PskKeyExchangeMode | int) -> Self:
|
|
165
|
+
return self._set(
|
|
166
|
+
"psk_key_exchange_modes", tuple(PskKeyExchangeMode(mode) for mode in modes)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def delegated_credentials(self, *schemes: SignatureScheme | int) -> Self:
|
|
170
|
+
return self._set(
|
|
171
|
+
"delegated_credential_schemes",
|
|
172
|
+
tuple(SignatureScheme(scheme) for scheme in schemes),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def record_size_limit(self, limit: int | None) -> Self:
|
|
176
|
+
return self._set("record_size_limit", limit)
|
|
177
|
+
|
|
178
|
+
def grease(self, enabled: bool = True) -> Self:
|
|
179
|
+
return self._set("grease", enabled)
|
|
180
|
+
|
|
181
|
+
def permute_extensions(self, enabled: bool = True) -> Self:
|
|
182
|
+
return self._set("permute_extensions", enabled)
|
|
183
|
+
|
|
184
|
+
def build(self) -> ClientHello:
|
|
185
|
+
return replace(EMPTY_CLIENT_HELLO, **self._fields)
|
|
186
|
+
|
|
187
|
+
def _set(self, name: str, value: object) -> Self:
|
|
188
|
+
self._fields[name] = value
|
|
189
|
+
return self
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _protocol_bytes(protocols: Iterable[NextProtocol | bytes]) -> tuple[bytes, ...]:
|
|
193
|
+
return tuple(
|
|
194
|
+
protocol.value if isinstance(protocol, NextProtocol) else protocol
|
|
195
|
+
for protocol in protocols
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@final
|
|
200
|
+
class ByteReader:
|
|
201
|
+
def __init__(self, data: bytes) -> None:
|
|
202
|
+
self._data = data
|
|
203
|
+
self._position = 0
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def remaining(self) -> int:
|
|
207
|
+
return len(self._data) - self._position
|
|
208
|
+
|
|
209
|
+
def take(self, length: int) -> bytes:
|
|
210
|
+
if length > self.remaining:
|
|
211
|
+
raise ValueError("truncated ClientHello")
|
|
212
|
+
chunk = self._data[self._position : self._position + length]
|
|
213
|
+
self._position += length
|
|
214
|
+
return chunk
|
|
215
|
+
|
|
216
|
+
def integer(self, width: int) -> int:
|
|
217
|
+
return int.from_bytes(self.take(width), "big")
|
|
218
|
+
|
|
219
|
+
def vector(self, length_width: int) -> bytes:
|
|
220
|
+
return self.take(self.integer(length_width))
|
|
221
|
+
|
|
222
|
+
def integers(self, length_width: int, item_width: int) -> tuple[int, ...]:
|
|
223
|
+
body = self.vector(length_width)
|
|
224
|
+
if len(body) % item_width:
|
|
225
|
+
raise ValueError("malformed vector length")
|
|
226
|
+
return tuple(
|
|
227
|
+
int.from_bytes(body[offset : offset + item_width], "big")
|
|
228
|
+
for offset in range(0, len(body), item_width)
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def strings(self, length_width: int, item_length_width: int) -> tuple[bytes, ...]:
|
|
232
|
+
inner = ByteReader(self.vector(length_width))
|
|
233
|
+
items: list[bytes] = []
|
|
234
|
+
while inner.remaining:
|
|
235
|
+
items.append(inner.vector(item_length_width))
|
|
236
|
+
return tuple(items)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@final
|
|
240
|
+
class ClientHelloParser:
|
|
241
|
+
def __init__(self) -> None:
|
|
242
|
+
self._fields: dict[str, object] = {}
|
|
243
|
+
self._grease = False
|
|
244
|
+
|
|
245
|
+
@classmethod
|
|
246
|
+
def parse(cls, data: bytes) -> ClientHello:
|
|
247
|
+
if not data:
|
|
248
|
+
raise ValueError("empty ClientHello")
|
|
249
|
+
if data[0] == ContentType.handshake:
|
|
250
|
+
data = cls._unwrap_records(data)
|
|
251
|
+
reader = ByteReader(data)
|
|
252
|
+
if reader.integer(1) != HandshakeType.client_hello:
|
|
253
|
+
raise ValueError("not a ClientHello")
|
|
254
|
+
parser = cls()
|
|
255
|
+
parser._read_body(ByteReader(reader.vector(3)))
|
|
256
|
+
return replace(EMPTY_CLIENT_HELLO, **parser._fields, grease=parser._grease)
|
|
257
|
+
|
|
258
|
+
@staticmethod
|
|
259
|
+
def _unwrap_records(data: bytes) -> bytes:
|
|
260
|
+
reader = ByteReader(data)
|
|
261
|
+
message = bytearray()
|
|
262
|
+
while reader.remaining:
|
|
263
|
+
content_type = reader.integer(1)
|
|
264
|
+
_ = reader.take(2)
|
|
265
|
+
fragment = reader.vector(2)
|
|
266
|
+
if content_type != ContentType.handshake:
|
|
267
|
+
raise ValueError("record is not a handshake record")
|
|
268
|
+
message += fragment
|
|
269
|
+
if len(message) >= 4 and len(message) >= 4 + int.from_bytes(
|
|
270
|
+
message[1:4], "big"
|
|
271
|
+
):
|
|
272
|
+
break
|
|
273
|
+
return bytes(message)
|
|
274
|
+
|
|
275
|
+
def _read_body(self, body: ByteReader) -> None:
|
|
276
|
+
legacy_version = ProtocolVersion(body.integer(2))
|
|
277
|
+
_ = body.take(32)
|
|
278
|
+
_ = body.vector(1)
|
|
279
|
+
self._fields["legacy_version"] = legacy_version
|
|
280
|
+
self._fields["supported_versions"] = (legacy_version,)
|
|
281
|
+
self._fields["cipher_suites"] = self._typed(CipherSuite, body.integers(2, 2))
|
|
282
|
+
_ = body.vector(1)
|
|
283
|
+
extensions: list[ExtensionType] = []
|
|
284
|
+
if body.remaining:
|
|
285
|
+
block = ByteReader(body.vector(2))
|
|
286
|
+
while block.remaining:
|
|
287
|
+
extension_type = ExtensionType(block.integer(2))
|
|
288
|
+
payload = ByteReader(block.vector(2))
|
|
289
|
+
if extension_type.is_grease:
|
|
290
|
+
self._grease = True
|
|
291
|
+
continue
|
|
292
|
+
extensions.append(extension_type)
|
|
293
|
+
self._read_payload(extension_type, payload)
|
|
294
|
+
self._fields["extensions"] = tuple(extensions)
|
|
295
|
+
|
|
296
|
+
def _read_payload(self, extension_type: ExtensionType, payload: ByteReader) -> None:
|
|
297
|
+
match extension_type:
|
|
298
|
+
case ExtensionType.supported_versions:
|
|
299
|
+
self._fields["supported_versions"] = self._typed(
|
|
300
|
+
ProtocolVersion, payload.integers(1, 2)
|
|
301
|
+
)
|
|
302
|
+
case ExtensionType.supported_groups:
|
|
303
|
+
self._fields["supported_groups"] = self._typed(
|
|
304
|
+
NamedGroup, payload.integers(2, 2)
|
|
305
|
+
)
|
|
306
|
+
case ExtensionType.ec_point_formats:
|
|
307
|
+
self._fields["ec_point_formats"] = self._typed(
|
|
308
|
+
ECPointFormat, payload.integers(1, 1)
|
|
309
|
+
)
|
|
310
|
+
case ExtensionType.signature_algorithms:
|
|
311
|
+
self._fields["signature_algorithms"] = self._typed(
|
|
312
|
+
SignatureScheme, payload.integers(2, 2)
|
|
313
|
+
)
|
|
314
|
+
case ExtensionType.application_layer_protocol_negotiation:
|
|
315
|
+
self._fields["application_protocols"] = payload.strings(2, 1)
|
|
316
|
+
case (
|
|
317
|
+
ExtensionType.application_settings
|
|
318
|
+
| ExtensionType.application_settings_old
|
|
319
|
+
):
|
|
320
|
+
self._fields["application_settings"] = payload.strings(2, 1)
|
|
321
|
+
case ExtensionType.key_share:
|
|
322
|
+
shares = ByteReader(payload.vector(2))
|
|
323
|
+
groups: list[int] = []
|
|
324
|
+
while shares.remaining:
|
|
325
|
+
groups.append(shares.integer(2))
|
|
326
|
+
_ = shares.vector(2)
|
|
327
|
+
self._fields["key_share_groups"] = self._typed(
|
|
328
|
+
NamedGroup, tuple(groups)
|
|
329
|
+
)
|
|
330
|
+
case ExtensionType.psk_key_exchange_modes:
|
|
331
|
+
self._fields["psk_key_exchange_modes"] = self._typed(
|
|
332
|
+
PskKeyExchangeMode, payload.integers(1, 1)
|
|
333
|
+
)
|
|
334
|
+
case ExtensionType.compress_certificate:
|
|
335
|
+
self._fields["certificate_compression_algorithms"] = self._typed(
|
|
336
|
+
CertificateCompressionAlgorithm, payload.integers(1, 2)
|
|
337
|
+
)
|
|
338
|
+
case ExtensionType.delegated_credential:
|
|
339
|
+
self._fields["delegated_credential_schemes"] = self._typed(
|
|
340
|
+
SignatureScheme, payload.integers(2, 2)
|
|
341
|
+
)
|
|
342
|
+
case ExtensionType.record_size_limit:
|
|
343
|
+
self._fields["record_size_limit"] = payload.integer(2)
|
|
344
|
+
case _:
|
|
345
|
+
pass
|
|
346
|
+
|
|
347
|
+
def _typed[CodePointT: CodePoint](
|
|
348
|
+
self, registry: type[CodePointT], values: tuple[int, ...]
|
|
349
|
+
) -> tuple[CodePointT, ...]:
|
|
350
|
+
members = tuple(registry(value) for value in values)
|
|
351
|
+
kept = tuple(member for member in members if not member.is_grease)
|
|
352
|
+
self._grease |= len(kept) != len(members)
|
|
353
|
+
return kept
|