ssl2 1.1.0__tar.gz → 2.0.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: ssl2
3
- Version: 1.1.0
3
+ Version: 2.0.0
4
4
  Summary: PEP 748 TLS interface: typed ClientHello model and parser, JA3, JA4 and Akamai fingerprints, proxies, sockets and asyncio streams
5
5
  Requires-Python: >=3.14
6
6
  Project-URL: Repository, https://github.com/baobien-dev/ssl2
@@ -126,7 +126,7 @@ print(JA3.parse(JA3.of(chrome).text) == JA3.of(chrome)) # True
126
126
 
127
127
  `JA3.builder()` starts a `ClientHelloBuilder` from the JA3 fields; the payloads JA3 does not carry, such as signature schemes, key shares and ALPN, go in before `build()`.
128
128
 
129
- `.trust_anchor_ids("44947.2.1", ...)` lists the trust anchor identifiers of draft-ietf-tls-trust-anchor-ids in dotted form, which the parser reads back from the wire. `CHROME_TRUST_ANCHOR_IDS` is the 28 Chrome 155 requests, and `TrustAnchorIdentifier.encode` and `.decode` move between the dotted form and the wire bytes.
129
+ `.trust_anchor_ids("44947.2.1", ...)` lists the trust anchor identifiers of draft-ietf-tls-trust-anchor-ids in dotted form, which the parser reads back from the wire. `TrustAnchorIdentifier.encode` and `.decode` move between the dotted form and the wire bytes.
130
130
 
131
131
  ## Configuration, credentials, trust
132
132
 
@@ -175,8 +175,8 @@ async def main() -> None:
175
175
  asyncio.run(main())
176
176
  ```
177
177
 
178
- `context` is an engine's `ClientContext`. A peer that drops TCP without close_notify reads as `b""`, the same default the `ssl` module has.
178
+ `context` is an engine's `ClientContext`. A peer that drops TCP without close_notify reads as `b""`, the same default the `ssl` module has. On a direct connection, and behind an HTTP or SOCKS proxy, both hand the TCP socket to the engine through `TLSBuffer.attach`, so records move between the kernel and the TLS state without a stop in Python; behind an HTTPS proxy, or over any other `AsyncTransport`, the records go through the buffer's memory BIOs instead. A socket timeout applies to every wait.
179
179
 
180
180
  ## Writing an engine
181
181
 
182
- An engine implements `ClientContext`, `ServerContext` and `TLSBuffer` from `ssl2.interface` and exposes `TLSImplementation(ClientContext, ServerContext, validate_config)`. `TLSSocket` and `TLSStream` drive any such `TLSBuffer`; `SessionCache` stores what the engine records.
182
+ An engine implements `ClientContext`, `ServerContext` and `TLSBuffer` from `ssl2.interface` and exposes `TLSImplementation(ClientContext, ServerContext, validate_config)`. `TLSSocket` and `TLSStream` drive any such `TLSBuffer`, through its memory BIOs or, when the buffer implements `attach(fileno)`, by handing it the socket; `getpeerchain()` returns the certificates the peer sent, leaf first. `SessionCache` stores what the engine records.
@@ -118,7 +118,7 @@ print(JA3.parse(JA3.of(chrome).text) == JA3.of(chrome)) # True
118
118
 
119
119
  `JA3.builder()` starts a `ClientHelloBuilder` from the JA3 fields; the payloads JA3 does not carry, such as signature schemes, key shares and ALPN, go in before `build()`.
120
120
 
121
- `.trust_anchor_ids("44947.2.1", ...)` lists the trust anchor identifiers of draft-ietf-tls-trust-anchor-ids in dotted form, which the parser reads back from the wire. `CHROME_TRUST_ANCHOR_IDS` is the 28 Chrome 155 requests, and `TrustAnchorIdentifier.encode` and `.decode` move between the dotted form and the wire bytes.
121
+ `.trust_anchor_ids("44947.2.1", ...)` lists the trust anchor identifiers of draft-ietf-tls-trust-anchor-ids in dotted form, which the parser reads back from the wire. `TrustAnchorIdentifier.encode` and `.decode` move between the dotted form and the wire bytes.
122
122
 
123
123
  ## Configuration, credentials, trust
124
124
 
@@ -167,8 +167,8 @@ async def main() -> None:
167
167
  asyncio.run(main())
168
168
  ```
169
169
 
170
- `context` is an engine's `ClientContext`. A peer that drops TCP without close_notify reads as `b""`, the same default the `ssl` module has.
170
+ `context` is an engine's `ClientContext`. A peer that drops TCP without close_notify reads as `b""`, the same default the `ssl` module has. On a direct connection, and behind an HTTP or SOCKS proxy, both hand the TCP socket to the engine through `TLSBuffer.attach`, so records move between the kernel and the TLS state without a stop in Python; behind an HTTPS proxy, or over any other `AsyncTransport`, the records go through the buffer's memory BIOs instead. A socket timeout applies to every wait.
171
171
 
172
172
  ## Writing an engine
173
173
 
174
- An engine implements `ClientContext`, `ServerContext` and `TLSBuffer` from `ssl2.interface` and exposes `TLSImplementation(ClientContext, ServerContext, validate_config)`. `TLSSocket` and `TLSStream` drive any such `TLSBuffer`; `SessionCache` stores what the engine records.
174
+ An engine implements `ClientContext`, `ServerContext` and `TLSBuffer` from `ssl2.interface` and exposes `TLSImplementation(ClientContext, ServerContext, validate_config)`. `TLSSocket` and `TLSStream` drive any such `TLSBuffer`, through its memory BIOs or, when the buffer implements `attach(fileno)`, by handing it the socket; `getpeerchain()` returns the certificates the peer sent, leaf first. `SessionCache` stores what the engine records.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ssl2"
3
- version = "1.1.0"
3
+ version = "2.0.0"
4
4
  description = "PEP 748 TLS interface: typed ClientHello model and parser, JA3, JA4 and Akamai fingerprints, proxies, sockets and asyncio streams"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -9,6 +9,9 @@ dependencies = []
9
9
  [project.urls]
10
10
  Repository = "https://github.com/baobien-dev/ssl2"
11
11
 
12
+ [tool.basedpyright]
13
+ typeCheckingMode = "strict"
14
+
12
15
  [build-system]
13
16
  requires = ["uv_build>=0.13.0,<0.14.0"]
14
17
  build-backend = "uv_build"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ssl2"
3
- version = "1.1.0"
3
+ version = "2.0.0"
4
4
  description = "PEP 748 TLS interface: typed ClientHello model and parser, JA3, JA4 and Akamai fingerprints, proxies, sockets and asyncio streams"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -9,6 +9,9 @@ dependencies = []
9
9
  [project.urls]
10
10
  Repository = "https://github.com/baobien-dev/ssl2"
11
11
 
12
+ [tool.basedpyright]
13
+ typeCheckingMode = "strict"
14
+
12
15
  [build-system]
13
16
  requires = ["uv_build>=0.13.0,<0.14.0"]
14
17
  build-backend = "uv_build"
@@ -1,6 +1,11 @@
1
- from ssl2.asynchronous import AsyncTransport, StreamTransport, TLSStream
1
+ from ssl2.asynchronous import (
2
+ AsyncTransport,
3
+ SocketTransport,
4
+ StreamTransport,
5
+ TLSStream,
6
+ Tunnel,
7
+ )
2
8
  from ssl2.client_hello import (
3
- CHROME_TRUST_ANCHOR_IDS,
4
9
  ClientHello,
5
10
  ClientHelloBuilder,
6
11
  ClientHelloParser,
@@ -59,7 +64,6 @@ from ssl2.parameters import (
59
64
  from ssl2.proxy import Proxy, ProxyCredentials, ProxyError, ProxyScheme
60
65
 
61
66
  __all__ = (
62
- "CHROME_TRUST_ANCHOR_IDS",
63
67
  "JA3",
64
68
  "JA4",
65
69
  "AkamaiFingerprint",
@@ -97,6 +101,7 @@ __all__ = (
97
101
  "SessionCache",
98
102
  "SignatureScheme",
99
103
  "SigningChain",
104
+ "SocketTransport",
100
105
  "StreamTransport",
101
106
  "TLSBuffer",
102
107
  "TLSClientConfiguration",
@@ -109,6 +114,7 @@ __all__ = (
109
114
  "Transport",
110
115
  "TrustAnchorIdentifier",
111
116
  "TrustStore",
117
+ "Tunnel",
112
118
  "WantReadError",
113
119
  "WantWriteError",
114
120
  )
@@ -1,15 +1,16 @@
1
1
  import asyncio
2
2
  import socket
3
3
  from collections.abc import Callable, Generator
4
+ from dataclasses import dataclass
4
5
  from functools import partial
5
- from typing import Final, Protocol, Self, cast, final
6
+ from typing import Protocol, Self, cast
6
7
 
7
8
  from ssl2.errors import RaggedEOF, WantReadError, WantWriteError
8
9
  from ssl2.interface import RECEIVE_SIZE, ClientContext, ServerContext, TLSBuffer
9
10
  from ssl2.parameters import CipherSuite, NextProtocol, TLSVersion
10
11
  from ssl2.proxy import Proxy, ProxyError, ProxyScheme
11
12
 
12
- SHUTDOWN_TIMEOUT: Final = 5.0
13
+ SHUTDOWN_TIMEOUT = 5.0
13
14
 
14
15
 
15
16
  class AsyncTransport(Protocol):
@@ -20,7 +21,6 @@ class AsyncTransport(Protocol):
20
21
  async def close(self) -> None: ...
21
22
 
22
23
 
23
- @final
24
24
  class StreamTransport:
25
25
  def __init__(
26
26
  self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
@@ -39,17 +39,67 @@ class StreamTransport:
39
39
  self._writer.close()
40
40
 
41
41
 
42
- @final
42
+ class SocketTransport:
43
+ def __init__(self, sock: socket.socket) -> None:
44
+ self._socket = sock
45
+ self._loop = asyncio.get_running_loop()
46
+
47
+ @classmethod
48
+ async def connect(cls, address: tuple[str, int]) -> Self:
49
+ loop = asyncio.get_running_loop()
50
+ host, port = address
51
+ failure = OSError(f"{host}:{port} resolves to no address")
52
+ for family, kind, protocol, _, resolved in await loop.getaddrinfo(
53
+ host, port, type=socket.SOCK_STREAM
54
+ ):
55
+ sock = socket.socket(family, kind, protocol)
56
+ sock.setblocking(False)
57
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
58
+ try:
59
+ await loop.sock_connect(sock, resolved)
60
+ except OSError as error:
61
+ sock.close()
62
+ failure = error
63
+ continue
64
+ return cls(sock)
65
+ raise failure
66
+
67
+ @property
68
+ def socket(self) -> socket.socket:
69
+ return self._socket
70
+
71
+ async def read(self, limit: int) -> bytes:
72
+ return await self._loop.sock_recv(self._socket, limit)
73
+
74
+ async def write(self, data: bytes) -> None:
75
+ await self._loop.sock_sendall(self._socket, data)
76
+
77
+ async def close(self) -> None:
78
+ self._socket.close()
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class Tunnel:
83
+ transport: AsyncTransport
84
+ leftover: bytes
85
+ # the TCP socket under the transport, when TLS can take it over; None behind a TLS hop
86
+ socket: socket.socket | None
87
+
88
+
43
89
  class TLSStream:
44
90
  def __init__(
45
91
  self,
46
92
  context: ClientContext | ServerContext,
47
93
  buffer: TLSBuffer,
48
94
  transport: AsyncTransport,
95
+ sock: socket.socket | None = None,
49
96
  ) -> None:
50
97
  self._context = context
51
98
  self._buffer = buffer
52
99
  self._transport = transport
100
+ # with a socket, BoringSSL reads and writes the kernel itself; without one the records go through the transport
101
+ self._socket = sock
102
+ self._loop = asyncio.get_running_loop()
53
103
 
54
104
  @classmethod
55
105
  async def connect(
@@ -61,33 +111,39 @@ class TLSStream:
61
111
  timeout: float | None = None,
62
112
  ) -> Self:
63
113
  async with asyncio.timeout(timeout):
64
- transport, leftover = await cls.tunnel(context, address, proxy)
114
+ tunnel = await cls.tunnel(context, address, proxy)
65
115
  try:
116
+ buffer = context.create_buffer(address[0])
117
+ if tunnel.socket is not None and not tunnel.leftover:
118
+ return await cls.open_socket(context, buffer, tunnel.socket)
66
119
  return await cls.open(
67
- context, context.create_buffer(address[0]), transport, leftover
120
+ context, buffer, tunnel.transport, tunnel.leftover
68
121
  )
69
122
  except BaseException:
70
- await transport.close()
123
+ await tunnel.transport.close()
71
124
  raise
72
125
 
73
126
  @classmethod
74
127
  async def tunnel(
75
128
  cls, context: ClientContext, address: tuple[str, int], proxy: Proxy | None
76
- ) -> tuple[AsyncTransport, bytes]:
129
+ ) -> Tunnel:
77
130
  if proxy is None:
78
- return StreamTransport(*await asyncio.open_connection(*address)), b""
79
- transport: AsyncTransport
131
+ direct = await SocketTransport.connect(address)
132
+ return Tunnel(direct, b"", direct.socket)
133
+ hop: AsyncTransport
134
+ sock: socket.socket | None
80
135
  if proxy.scheme is ProxyScheme.HTTPS:
81
- transport = await cls.connect(context, (proxy.host, proxy.port))
136
+ hop = await cls.connect(context, (proxy.host, proxy.port))
137
+ sock = None
82
138
  else:
83
- transport = StreamTransport(
84
- *await asyncio.open_connection(proxy.host, proxy.port)
85
- )
139
+ plain = await SocketTransport.connect((proxy.host, proxy.port))
140
+ hop, sock = plain, plain.socket
86
141
  try:
87
- return transport, await cls._tunnel(proxy, address, transport)
142
+ leftover = await cls._tunnel(proxy, address, hop)
88
143
  except BaseException:
89
- await transport.close()
144
+ await hop.close()
90
145
  raise
146
+ return Tunnel(hop, leftover, sock)
91
147
 
92
148
  @classmethod
93
149
  async def open(
@@ -103,6 +159,18 @@ class TLSStream:
103
159
  await self._run(buffer.do_handshake)
104
160
  return self
105
161
 
162
+ @classmethod
163
+ async def open_socket(
164
+ cls,
165
+ context: ClientContext | ServerContext,
166
+ buffer: TLSBuffer,
167
+ sock: socket.socket,
168
+ ) -> Self:
169
+ buffer.attach(sock.fileno())
170
+ self = cls(context, buffer, SocketTransport(sock), sock)
171
+ await self._run(buffer.do_handshake)
172
+ return self
173
+
106
174
  @property
107
175
  def context(self) -> ClientContext | ServerContext:
108
176
  return self._context
@@ -118,10 +186,9 @@ class TLSStream:
118
186
  return b""
119
187
 
120
188
  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:]
189
+ while data:
190
+ written = await self._run(partial(self._buffer.write, data))
191
+ data = data[written:]
125
192
 
126
193
  async def close(self) -> None:
127
194
  try:
@@ -196,15 +263,37 @@ class TLSStream:
196
263
  try:
197
264
  result = operation()
198
265
  except WantReadError:
199
- await self._flush()
200
- await self._fill()
266
+ if self._socket is None:
267
+ await self._flush()
268
+ await self._fill()
269
+ else:
270
+ await self._ready(self._loop.add_reader, self._loop.remove_reader)
201
271
  continue
202
272
  except WantWriteError:
203
- await self._flush()
273
+ if self._socket is None:
274
+ await self._flush()
275
+ else:
276
+ await self._ready(self._loop.add_writer, self._loop.remove_writer)
204
277
  continue
205
- await self._flush()
278
+ if self._socket is None:
279
+ await self._flush()
206
280
  return result
207
281
 
282
+ async def _ready(
283
+ self,
284
+ watch: Callable[[int, Callable[[], None]], object],
285
+ unwatch: Callable[[int], object],
286
+ ) -> None:
287
+ if self._socket is None:
288
+ return
289
+ fileno = self._socket.fileno()
290
+ waiter: asyncio.Future[None] = self._loop.create_future()
291
+ watch(fileno, lambda: None if waiter.done() else waiter.set_result(None))
292
+ try:
293
+ await waiter
294
+ finally:
295
+ unwatch(fileno)
296
+
208
297
  async def _flush(self) -> None:
209
298
  # one take of everything buffered: draining a memory BIO in small pieces moves the remainder each time
210
299
  while pending := self._buffer.outgoing_bytes_buffered():
@@ -1,6 +1,6 @@
1
1
  from collections.abc import Iterable
2
2
  from dataclasses import dataclass, replace
3
- from typing import Final, Self, final
3
+ from typing import Self
4
4
 
5
5
  from ssl2.parameters import (
6
6
  CertificateCompressionAlgorithm,
@@ -107,41 +107,9 @@ class ClientHello:
107
107
  return position == len(part)
108
108
 
109
109
 
110
- EMPTY_CLIENT_HELLO: Final = ClientHello(cipher_suites=(), extensions=())
111
- # the trust anchor identifiers Chrome 155 requests: its root store, in the dotted form of draft-ietf-tls-trust-anchor-ids
112
- CHROME_TRUST_ANCHOR_IDS: Final = (
113
- "44947.2.1",
114
- "44947.2.6",
115
- "44947.2.13",
116
- "44947.2.14",
117
- "44947.2.15",
118
- "44947.2.18",
119
- "44947.2.19",
120
- "44947.2.20",
121
- "52580.200109.1.7",
122
- "52580.200109.1.8",
123
- "52580.200109.1.9",
124
- "52580.200109.1.10",
125
- "52580.200109.1.11",
126
- "52580.200109.1.12",
127
- "52580.200109.1.13",
128
- "52580.200109.1.18",
129
- "52580.200109.1.19",
130
- "11129.9.1",
131
- "11129.9.4",
132
- "11129.9.5",
133
- "11129.9.6",
134
- "11129.9.7",
135
- "11129.9.8",
136
- "11129.9.10",
137
- "11129.9.11",
138
- "11129.9.12",
139
- "11129.9.13",
140
- "11129.9.15",
141
- )
110
+ EMPTY_CLIENT_HELLO = ClientHello(cipher_suites=(), extensions=())
142
111
 
143
112
 
144
- @final
145
113
  class TrustAnchorIdentifier:
146
114
  # draft-ietf-tls-trust-anchor-ids: a relative object identifier, dotted in the model and base-128 arcs on the wire
147
115
  @staticmethod
@@ -264,7 +232,6 @@ class ClientHelloBuilder:
264
232
  )
265
233
 
266
234
 
267
- @final
268
235
  class ByteReader:
269
236
  def __init__(self, data: bytes) -> None:
270
237
  self._data = data
@@ -304,7 +271,6 @@ class ByteReader:
304
271
  return tuple(items)
305
272
 
306
273
 
307
- @final
308
274
  class ClientHelloParser:
309
275
  def __init__(self) -> None:
310
276
  self._fields: dict[str, object] = {}
@@ -329,7 +295,7 @@ class ClientHelloParser:
329
295
  message = bytearray()
330
296
  while reader.remaining:
331
297
  content_type = reader.integer(1)
332
- _ = reader.take(2)
298
+ reader.take(2)
333
299
  fragment = reader.vector(2)
334
300
  if content_type != ContentType.handshake:
335
301
  raise ValueError("record is not a handshake record")
@@ -342,12 +308,12 @@ class ClientHelloParser:
342
308
 
343
309
  def _read_body(self, body: ByteReader) -> None:
344
310
  legacy_version = ProtocolVersion(body.integer(2))
345
- _ = body.take(32)
346
- _ = body.vector(1)
311
+ body.take(32)
312
+ body.vector(1)
347
313
  self._fields["legacy_version"] = legacy_version
348
314
  self._fields["supported_versions"] = (legacy_version,)
349
315
  self._fields["cipher_suites"] = self._typed(CipherSuite, body.integers(2, 2))
350
- _ = body.vector(1)
316
+ body.vector(1)
351
317
  extensions: list[ExtensionType] = []
352
318
  if body.remaining:
353
319
  block = ByteReader(body.vector(2))
@@ -391,7 +357,7 @@ class ClientHelloParser:
391
357
  groups: list[int] = []
392
358
  while shares.remaining:
393
359
  groups.append(shares.integer(2))
394
- _ = shares.vector(2)
360
+ shares.vector(2)
395
361
  self._fields["key_share_groups"] = self._typed(
396
362
  NamedGroup, tuple(groups)
397
363
  )
@@ -5,11 +5,11 @@ import sys
5
5
  from collections.abc import Callable, Sequence
6
6
  from dataclasses import dataclass
7
7
  from pathlib import Path
8
- from typing import Final, Self
8
+ from typing import Self
9
9
 
10
10
  from ssl2.errors import ConfigurationError
11
11
 
12
- LINUX_CANDIDATES: Final = (
12
+ LINUX_CANDIDATES = (
13
13
  Path("/etc/ssl/certs/ca-certificates.crt"),
14
14
  Path("/etc/pki/tls/certs/ca-bundle.crt"),
15
15
  Path("/etc/ssl/ca-bundle.pem"),
@@ -20,11 +20,11 @@ LINUX_CANDIDATES: Final = (
20
20
  Path("/etc/pki/tls/certs"),
21
21
  Path("/system/etc/security/cacerts"),
22
22
  )
23
- DARWIN_CANDIDATES: Final = (
23
+ DARWIN_CANDIDATES = (
24
24
  Path("/etc/ssl/cert.pem"),
25
25
  Path("/private/etc/ssl/cert.pem"),
26
26
  )
27
- X509_ASN_ENCODING: Final = 0x00000001
27
+ X509_ASN_ENCODING = 0x00000001
28
28
 
29
29
 
30
30
  @dataclass(frozen=True, slots=True)
@@ -115,7 +115,7 @@ class TrustStore(Credential):
115
115
  + b"-----END CERTIFICATE-----\n"
116
116
  )
117
117
  context = enumerate_store(store, context)
118
- _ = close_store(store, 0)
118
+ close_store(store, 0)
119
119
  return b"".join(chunks)
120
120
 
121
121
 
@@ -1,7 +1,7 @@
1
1
  import hashlib
2
2
  from dataclasses import dataclass
3
3
  from enum import StrEnum
4
- from typing import Final, Self
4
+ from typing import Self
5
5
 
6
6
  from ssl2.client_hello import ClientHello, ClientHelloBuilder
7
7
  from ssl2.parameters import (
@@ -15,8 +15,8 @@ from ssl2.parameters import (
15
15
  SignatureScheme,
16
16
  )
17
17
 
18
- JA4_HASH_LENGTH: Final = 12
19
- JA4_UNHASHED_EXTENSIONS: Final = frozenset(
18
+ JA4_HASH_LENGTH = 12
19
+ JA4_UNHASHED_EXTENSIONS = frozenset(
20
20
  {ExtensionType.server_name, ExtensionType.application_layer_protocol_negotiation}
21
21
  )
22
22
 
@@ -1,10 +1,11 @@
1
1
  import errno
2
+ import select
2
3
  import socket
3
4
  from collections import OrderedDict
4
5
  from collections.abc import Buffer, Callable, Sequence
5
6
  from dataclasses import dataclass
6
7
  from threading import Lock
7
- from typing import Final, Protocol, Self, final, overload
8
+ from typing import Protocol, Self, overload
8
9
 
9
10
  from ssl2.client_hello import ClientHello
10
11
  from ssl2.credentials import SigningChain, TrustStore
@@ -12,7 +13,7 @@ from ssl2.errors import ConfigurationError, RaggedEOF, WantReadError, WantWriteE
12
13
  from ssl2.parameters import CipherSuite, NextProtocol, TLSVersion
13
14
  from ssl2.proxy import Proxy, ProxyError, ProxyScheme
14
15
 
15
- RECEIVE_SIZE: Final = 65536
16
+ RECEIVE_SIZE = 65536
16
17
 
17
18
 
18
19
  @dataclass(frozen=True, slots=True)
@@ -84,6 +85,10 @@ class TLSBuffer(Protocol):
84
85
 
85
86
  def getpeercert(self) -> bytes | None: ...
86
87
 
88
+ def getpeerchain(self) -> tuple[bytes, ...]: ...
89
+
90
+ def attach(self, fileno: int, /) -> None: ...
91
+
87
92
 
88
93
  class ClientContext(Protocol):
89
94
  def __init__(self, configuration: TLSClientConfiguration) -> None: ...
@@ -114,7 +119,6 @@ class TLSImplementation[ClientContextT: ClientContext, ServerContextT: ServerCon
114
119
  validate_config: Callable[[TLSClientConfiguration | TLSServerConfiguration], None]
115
120
 
116
121
 
117
- @final
118
122
  class SessionCache:
119
123
  def __init__(self, limit: int = 1024) -> None:
120
124
  self._entries: OrderedDict[str, tuple[bytes, bool]] = OrderedDict()
@@ -126,7 +130,7 @@ class SessionCache:
126
130
  self._entries[key] = (session, single_use)
127
131
  self._entries.move_to_end(key)
128
132
  while len(self._entries) > self._limit:
129
- _ = self._entries.popitem(last=False)
133
+ self._entries.popitem(last=False)
130
134
 
131
135
  def take(self, key: str) -> bytes | None:
132
136
  with self._lock:
@@ -170,9 +174,10 @@ class Listening:
170
174
  class Connected:
171
175
  buffer: TLSBuffer
172
176
  transport: Transport
177
+ # the socket BoringSSL reads and writes itself; None when the records go through the transport in Python
178
+ socket: socket.socket | None = None
173
179
 
174
180
 
175
- @final
176
181
  class TLSSocket:
177
182
  def __init__(
178
183
  self, context: ClientContext | ServerContext, state: Listening | Connected
@@ -180,6 +185,8 @@ class TLSSocket:
180
185
  self._context = context
181
186
  self._state = state
182
187
  self._unsent = b""
188
+ # the timeout of an attached socket lives here: the socket itself stays non-blocking for BoringSSL
189
+ self._timeout: float | None = None
183
190
 
184
191
  @classmethod
185
192
  def connect(
@@ -190,22 +197,26 @@ class TLSSocket:
190
197
  proxy: Proxy | None = None,
191
198
  timeout: float | None = None,
192
199
  ) -> Self:
193
- transport: Transport
200
+ buffer = context.create_buffer(address[0])
194
201
  if proxy is None:
195
- transport = socket.create_connection(address, timeout=timeout)
196
- elif proxy.scheme is ProxyScheme.HTTPS:
197
- transport = cls.connect(context, (proxy.host, proxy.port), timeout=timeout)
198
- else:
199
- transport = socket.create_connection(
200
- (proxy.host, proxy.port), timeout=timeout
202
+ return cls.open_socket(
203
+ context, buffer, socket.create_connection(address, timeout=timeout)
201
204
  )
205
+ if proxy.scheme is ProxyScheme.HTTPS:
206
+ hop = cls.connect(context, (proxy.host, proxy.port), timeout=timeout)
207
+ try:
208
+ return cls.open(context, buffer, hop, cls._tunnel(proxy, address, hop))
209
+ except BaseException:
210
+ hop.close()
211
+ raise
212
+ plain = socket.create_connection((proxy.host, proxy.port), timeout=timeout)
202
213
  try:
203
- leftover = b"" if proxy is None else cls._tunnel(proxy, address, transport)
204
- return cls.open(
205
- context, context.create_buffer(address[0]), transport, leftover
206
- )
214
+ leftover = cls._tunnel(proxy, address, plain)
215
+ if leftover:
216
+ return cls.open(context, buffer, plain, leftover)
217
+ return cls.open_socket(context, buffer, plain)
207
218
  except BaseException:
208
- transport.close()
219
+ plain.close()
209
220
  raise
210
221
 
211
222
  @classmethod
@@ -222,6 +233,24 @@ class TLSSocket:
222
233
  self._run(buffer.do_handshake)
223
234
  return self
224
235
 
236
+ @classmethod
237
+ def open_socket(
238
+ cls,
239
+ context: ClientContext | ServerContext,
240
+ buffer: TLSBuffer,
241
+ sock: socket.socket,
242
+ ) -> Self:
243
+ buffer.attach(sock.fileno())
244
+ self = cls(context, Connected(buffer, sock, sock))
245
+ self._timeout = sock.gettimeout()
246
+ sock.setblocking(False)
247
+ try:
248
+ self._run(buffer.do_handshake)
249
+ except BaseException:
250
+ sock.close()
251
+ raise
252
+ return self
253
+
225
254
  @classmethod
226
255
  def bind(cls, context: ServerContext, address: tuple[str | None, int]) -> Self:
227
256
  host, port = address
@@ -276,7 +305,7 @@ class TLSSocket:
276
305
  accepted: tuple[socket.socket, tuple[str | None, int]] = (
277
306
  listening.listener.accept()
278
307
  )
279
- return type(self).open(
308
+ return type(self).open_socket(
280
309
  self._context, listening.accept(), accepted[0]
281
310
  ), accepted[1]
282
311
 
@@ -289,6 +318,9 @@ class TLSSocket:
289
318
  def getpeercert(self) -> bytes | None:
290
319
  return self._connected().buffer.getpeercert()
291
320
 
321
+ def getpeerchain(self) -> tuple[bytes, ...]:
322
+ return self._connected().buffer.getpeerchain()
323
+
292
324
  def cipher(self) -> CipherSuite | int | None:
293
325
  return self._connected().buffer.cipher()
294
326
 
@@ -300,9 +332,14 @@ class TLSSocket:
300
332
  return self._connected().buffer.negotiated_tls_version
301
333
 
302
334
  def settimeout(self, value: float | None) -> None:
303
- self._endpoint().settimeout(value)
335
+ if isinstance(self._state, Connected) and self._state.socket is not None:
336
+ self._timeout = value
337
+ else:
338
+ self._endpoint().settimeout(value)
304
339
 
305
340
  def gettimeout(self) -> float | None:
341
+ if isinstance(self._state, Connected) and self._state.socket is not None:
342
+ return self._timeout
306
343
  return self._endpoint().gettimeout()
307
344
 
308
345
  def fileno(self) -> int:
@@ -358,19 +395,38 @@ class TLSSocket:
358
395
 
359
396
  def _run[ResultT](self, operation: Callable[[], ResultT]) -> ResultT:
360
397
  connection = self._connected()
398
+ sock = connection.socket
361
399
  while True:
362
400
  try:
363
401
  result = operation()
364
402
  except WantReadError:
365
- self._flush(connection)
366
- self._fill(connection)
403
+ if sock is None:
404
+ self._flush(connection)
405
+ self._fill(connection)
406
+ elif self._timeout == 0:
407
+ raise
408
+ else:
409
+ self._wait(sock, reading=True)
367
410
  continue
368
411
  except WantWriteError:
369
- self._flush(connection)
412
+ if sock is None:
413
+ self._flush(connection)
414
+ elif self._timeout == 0:
415
+ raise
416
+ else:
417
+ self._wait(sock, reading=False)
370
418
  continue
371
- self._flush(connection)
419
+ if sock is None:
420
+ self._flush(connection)
372
421
  return result
373
422
 
423
+ def _wait(self, sock: socket.socket, *, reading: bool) -> None:
424
+ readable, writable, _ = select.select(
425
+ [sock] if reading else [], [] if reading else [sock], [], self._timeout
426
+ )
427
+ if not readable and not writable:
428
+ raise TimeoutError("timed out")
429
+
374
430
  def _flush(self, connection: Connected) -> None:
375
431
  while self._unsent or connection.buffer.outgoing_bytes_buffered():
376
432
  if not self._unsent:
@@ -393,6 +449,7 @@ class TLSSocket:
393
449
  def _shutdown_once(self, connection: Connected) -> None:
394
450
  try:
395
451
  connection.buffer.shutdown()
396
- except WantReadError:
452
+ except WantReadError, WantWriteError:
397
453
  pass
398
- self._flush(connection)
454
+ if connection.socket is None:
455
+ self._flush(connection)
@@ -1,6 +1,6 @@
1
1
  # generated from the IANA registry CSV files and BoringSSL tls1.h at 8f77af2e, so regenerate rather than edit
2
2
  from enum import Enum, IntEnum
3
- from typing import Final, Self, override
3
+ from typing import Self, override
4
4
 
5
5
 
6
6
  class CodePoint(IntEnum):
@@ -55,13 +55,13 @@ class ProtocolVersion(CodePoint):
55
55
  return TLS_VERSIONS.get(self)
56
56
 
57
57
 
58
- PROTOCOL_VERSIONS: Final = {
58
+ PROTOCOL_VERSIONS = {
59
59
  TLSVersion.TLSv1_0: ProtocolVersion.TLS_1_0,
60
60
  TLSVersion.TLSv1_1: ProtocolVersion.TLS_1_1,
61
61
  TLSVersion.TLSv1_2: ProtocolVersion.TLS_1_2,
62
62
  TLSVersion.TLSv1_3: ProtocolVersion.TLS_1_3,
63
63
  }
64
- TLS_VERSIONS: Final = {protocol: named for named, protocol in PROTOCOL_VERSIONS.items()}
64
+ TLS_VERSIONS = {protocol: named for named, protocol in PROTOCOL_VERSIONS.items()}
65
65
 
66
66
 
67
67
  class ServerNameType(CodePoint):
@@ -5,15 +5,15 @@ import struct
5
5
  from collections.abc import Generator
6
6
  from dataclasses import dataclass
7
7
  from enum import IntEnum, StrEnum
8
- from typing import Final, Self
8
+ from typing import Self
9
9
  from urllib.parse import unquote, urlsplit
10
10
 
11
11
  type Conversation = Generator[tuple[bytes, int | bytes], bytes, object]
12
12
  type Association = Generator[tuple[bytes, int | bytes], bytes, tuple[str, int]]
13
13
 
14
- REPLY_LIMIT: Final = 65536
15
- USERNAME_PASSWORD_VERSION: Final = 0x01
16
- SOCKS4A_NAME_FOLLOWS: Final = bytes((0, 0, 0, 1))
14
+ REPLY_LIMIT = 65536
15
+ USERNAME_PASSWORD_VERSION = 0x01
16
+ SOCKS4A_NAME_FOLLOWS = bytes((0, 0, 0, 1))
17
17
 
18
18
 
19
19
  class ProxyError(ConnectionError):
File without changes
File without changes