tigrcorn-security 0.3.16__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.
- tigrcorn_security/__init__.py +1 -0
- tigrcorn_security/alpn.py +29 -0
- tigrcorn_security/certs.py +10 -0
- tigrcorn_security/policies.py +68 -0
- tigrcorn_security/py.typed +1 -0
- tigrcorn_security/tls.py +583 -0
- tigrcorn_security/tls13/__init__.py +95 -0
- tigrcorn_security/tls13/extensions.py +759 -0
- tigrcorn_security/tls13/handshake.py +1411 -0
- tigrcorn_security/tls13/key_schedule.py +108 -0
- tigrcorn_security/tls13/messages.py +428 -0
- tigrcorn_security/tls13/transcript.py +51 -0
- tigrcorn_security/tls_cipher_policy.py +43 -0
- tigrcorn_security/x509/__init__.py +31 -0
- tigrcorn_security/x509/path.py +1284 -0
- tigrcorn_security-0.3.16.dist-info/METADATA +293 -0
- tigrcorn_security-0.3.16.dist-info/RECORD +20 -0
- tigrcorn_security-0.3.16.dist-info/WHEEL +5 -0
- tigrcorn_security-0.3.16.dist-info/licenses/LICENSE +163 -0
- tigrcorn_security-0.3.16.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Security helpers."""
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
_ALLOWED_ALPN = {'http/1.1', 'h2', 'h3'}
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def normalize_alpn(selected: str | None) -> str | None:
|
|
7
|
+
if selected is None:
|
|
8
|
+
return None
|
|
9
|
+
candidate = selected.strip().lower()
|
|
10
|
+
if not candidate:
|
|
11
|
+
return None
|
|
12
|
+
if candidate in _ALLOWED_ALPN:
|
|
13
|
+
return candidate
|
|
14
|
+
return candidate
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def normalize_alpn_list(values: list[str] | tuple[str, ...] | None, *, for_udp: bool = False) -> list[str]:
|
|
18
|
+
items = [normalize_alpn(v) for v in (values or []) if v]
|
|
19
|
+
normalized = [v for v in items if v]
|
|
20
|
+
if not normalized:
|
|
21
|
+
normalized = ['h3'] if for_udp else ['h2', 'http/1.1']
|
|
22
|
+
seen: list[str] = []
|
|
23
|
+
for item in normalized:
|
|
24
|
+
if item not in seen:
|
|
25
|
+
seen.append(item)
|
|
26
|
+
return seen
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = ['normalize_alpn', 'normalize_alpn_list']
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
|
|
6
|
+
from tigrcorn_config.model import ListenerConfig
|
|
7
|
+
from tigrcorn_security.x509.path import (
|
|
8
|
+
CertificatePurpose,
|
|
9
|
+
CertificateValidationPolicy,
|
|
10
|
+
RevocationCache,
|
|
11
|
+
RevocationFetchPolicy,
|
|
12
|
+
RevocationFreshnessPolicy,
|
|
13
|
+
RevocationMaterial,
|
|
14
|
+
RevocationMode,
|
|
15
|
+
load_crls_from_file,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True)
|
|
20
|
+
class TLSPolicy:
|
|
21
|
+
require_client_cert: bool = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def revocation_mode_from_listener(listener: ListenerConfig) -> RevocationMode:
|
|
25
|
+
ocsp_mode = getattr(listener, 'ocsp_mode', 'off') or 'off'
|
|
26
|
+
crl_mode = getattr(listener, 'crl_mode', 'off') or 'off'
|
|
27
|
+
if 'require' in {ocsp_mode, crl_mode}:
|
|
28
|
+
return RevocationMode.REQUIRE
|
|
29
|
+
if (
|
|
30
|
+
'soft-fail' in {ocsp_mode, crl_mode}
|
|
31
|
+
or (getattr(listener, 'ocsp_soft_fail', False) and ocsp_mode != 'off')
|
|
32
|
+
):
|
|
33
|
+
return RevocationMode.SOFT_FAIL
|
|
34
|
+
return RevocationMode.OFF
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_validation_policy_for_listener(listener: ListenerConfig) -> CertificateValidationPolicy:
|
|
38
|
+
ocsp_max_age = getattr(listener, 'ocsp_max_age', None)
|
|
39
|
+
freshness = RevocationFreshnessPolicy(
|
|
40
|
+
ocsp_max_age_without_next_update=timedelta(seconds=ocsp_max_age) if ocsp_max_age is not None else RevocationFreshnessPolicy().ocsp_max_age_without_next_update,
|
|
41
|
+
)
|
|
42
|
+
revocation_fetch_enabled = getattr(listener, 'revocation_fetch', True)
|
|
43
|
+
ocsp_enabled = getattr(listener, 'ocsp_mode', 'off') != 'off'
|
|
44
|
+
crl_enabled = getattr(listener, 'crl_mode', 'off') != 'off'
|
|
45
|
+
fetch_policy = RevocationFetchPolicy(
|
|
46
|
+
enable_ocsp_aia=revocation_fetch_enabled and ocsp_enabled,
|
|
47
|
+
enable_crl_distribution_points=revocation_fetch_enabled and crl_enabled,
|
|
48
|
+
freshness=freshness,
|
|
49
|
+
cache=RevocationCache(max_entries=max(1, int(getattr(listener, 'ocsp_cache_size', 128) or 128))),
|
|
50
|
+
)
|
|
51
|
+
if not revocation_fetch_enabled or (not ocsp_enabled and not crl_enabled):
|
|
52
|
+
fetch_policy = None
|
|
53
|
+
local_crls = ()
|
|
54
|
+
if getattr(listener, 'ssl_crl', None):
|
|
55
|
+
local_crls = load_crls_from_file(str(listener.ssl_crl))
|
|
56
|
+
return CertificateValidationPolicy(
|
|
57
|
+
purpose=CertificatePurpose.CLIENT_AUTH,
|
|
58
|
+
revocation_mode=revocation_mode_from_listener(listener),
|
|
59
|
+
revocation_material=RevocationMaterial(crls=local_crls),
|
|
60
|
+
revocation_fetch_policy=fetch_policy,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
'TLSPolicy',
|
|
66
|
+
'build_validation_policy_for_listener',
|
|
67
|
+
'revocation_mode_from_listener',
|
|
68
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
tigrcorn_security/tls.py
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import threading
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Iterable
|
|
10
|
+
|
|
11
|
+
class _MissingDependencyProxy:
|
|
12
|
+
def __init__(self, package: str) -> None:
|
|
13
|
+
self._package = package
|
|
14
|
+
|
|
15
|
+
def __getattr__(self, name: str):
|
|
16
|
+
raise ModuleNotFoundError(
|
|
17
|
+
f"{self._package} is required for this TLS/X.509 operation; install tigrcorn[tls-x509]"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from cryptography import x509
|
|
23
|
+
from cryptography.hazmat.primitives import serialization
|
|
24
|
+
except ModuleNotFoundError: # pragma: no cover - exercised in dependency-light environments
|
|
25
|
+
x509 = _MissingDependencyProxy("cryptography") # type: ignore[assignment]
|
|
26
|
+
serialization = _MissingDependencyProxy("cryptography") # type: ignore[assignment]
|
|
27
|
+
|
|
28
|
+
from tigrcorn_config.model import ListenerConfig
|
|
29
|
+
from tigrcorn_core.errors import ProtocolError
|
|
30
|
+
from tigrcorn_security.tls13.handshake import QuicTlsHandshakeDriver, TlsAlertError
|
|
31
|
+
from tigrcorn_security.tls13.key_schedule import Tls13KeySchedule
|
|
32
|
+
from tigrcorn_security.tls13.messages import decode_handshake_message
|
|
33
|
+
from tigrcorn_security.policies import build_validation_policy_for_listener
|
|
34
|
+
from tigrcorn_security.x509.path import (
|
|
35
|
+
CertificatePurpose,
|
|
36
|
+
CertificateValidationPolicy,
|
|
37
|
+
RevocationCache,
|
|
38
|
+
RevocationCacheEntry,
|
|
39
|
+
RevocationFetchPolicy,
|
|
40
|
+
RevocationFreshnessPolicy,
|
|
41
|
+
RevocationMaterial,
|
|
42
|
+
RevocationMode,
|
|
43
|
+
load_pem_certificates,
|
|
44
|
+
verify_certificate_chain as _verify_certificate_chain,
|
|
45
|
+
verify_certificate_hostname,
|
|
46
|
+
verify_certificate_validity,
|
|
47
|
+
)
|
|
48
|
+
from tigrcorn_transports.quic.crypto import aes_gcm_decrypt, aes_gcm_encrypt
|
|
49
|
+
|
|
50
|
+
_TLS_CONTENT_CHANGE_CIPHER_SPEC = 20
|
|
51
|
+
_TLS_CONTENT_ALERT = 21
|
|
52
|
+
_TLS_CONTENT_HANDSHAKE = 22
|
|
53
|
+
_TLS_CONTENT_APPLICATION_DATA = 23
|
|
54
|
+
_TLS_LEGACY_RECORD_VERSION = 0x0303
|
|
55
|
+
_TLS_MAX_PLAINTEXT = 16384
|
|
56
|
+
_TLS_ALERT_LEVEL_FATAL = 2
|
|
57
|
+
_TLS_ALERT_CLOSE_NOTIFY = 0
|
|
58
|
+
|
|
59
|
+
_CIPHER_NAMES = {
|
|
60
|
+
0x1301: ('TLS_AES_128_GCM_SHA256', 128),
|
|
61
|
+
0x1302: ('TLS_AES_256_GCM_SHA384', 256),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class ServerTLSContext:
|
|
67
|
+
certificate_pem: bytes
|
|
68
|
+
private_key_pem: bytes
|
|
69
|
+
private_key_password: bytes | None
|
|
70
|
+
trusted_certificates: tuple[bytes, ...]
|
|
71
|
+
alpn_protocols: tuple[str, ...]
|
|
72
|
+
require_client_certificate: bool
|
|
73
|
+
validation_policy: CertificateValidationPolicy
|
|
74
|
+
cipher_suites: tuple[int, ...] = (0x1302, 0x1301)
|
|
75
|
+
server_name: str = 'localhost'
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(slots=True)
|
|
79
|
+
class _RecordProtectionState:
|
|
80
|
+
key: bytes
|
|
81
|
+
iv: bytes
|
|
82
|
+
sequence_number: int = 0
|
|
83
|
+
|
|
84
|
+
def next_nonce(self) -> bytes:
|
|
85
|
+
sequence = self.sequence_number.to_bytes(8, 'big')
|
|
86
|
+
padded = b'\x00' * (len(self.iv) - len(sequence)) + sequence
|
|
87
|
+
nonce = bytes(left ^ right for left, right in zip(self.iv, padded))
|
|
88
|
+
self.sequence_number += 1
|
|
89
|
+
return nonce
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class PackageOwnedSSLObject:
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
*,
|
|
96
|
+
selected_alpn_protocol: str | None,
|
|
97
|
+
cipher_suite: int,
|
|
98
|
+
peer_certificate: x509.Certificate | None,
|
|
99
|
+
) -> None:
|
|
100
|
+
self._selected_alpn_protocol = selected_alpn_protocol
|
|
101
|
+
self._cipher_suite = cipher_suite
|
|
102
|
+
self._peer_certificate = peer_certificate
|
|
103
|
+
self._peer_certificate_der = (
|
|
104
|
+
peer_certificate.public_bytes(serialization.Encoding.DER)
|
|
105
|
+
if peer_certificate is not None
|
|
106
|
+
else None
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def selected_alpn_protocol(self) -> str | None:
|
|
110
|
+
return self._selected_alpn_protocol
|
|
111
|
+
|
|
112
|
+
def version(self) -> str:
|
|
113
|
+
return 'TLSv1.3'
|
|
114
|
+
|
|
115
|
+
def cipher(self) -> tuple[str, str, int]:
|
|
116
|
+
name, bits = _CIPHER_NAMES.get(self._cipher_suite, ('TLS_UNKNOWN', 0))
|
|
117
|
+
return name, 'TLSv1.3', bits
|
|
118
|
+
|
|
119
|
+
def getpeercert(self, binary_form: bool = False) -> dict[str, Any] | bytes | None:
|
|
120
|
+
if self._peer_certificate is None:
|
|
121
|
+
return None
|
|
122
|
+
if binary_form:
|
|
123
|
+
return self._peer_certificate_der
|
|
124
|
+
return describe_peer_certificate(self._peer_certificate)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class PackageOwnedTLSConnection:
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
raw_reader: asyncio.StreamReader,
|
|
131
|
+
raw_writer: asyncio.StreamWriter,
|
|
132
|
+
context: ServerTLSContext,
|
|
133
|
+
) -> None:
|
|
134
|
+
self._raw_reader = raw_reader
|
|
135
|
+
self._raw_writer = raw_writer
|
|
136
|
+
self._context = context
|
|
137
|
+
self._driver = QuicTlsHandshakeDriver(
|
|
138
|
+
is_client=False,
|
|
139
|
+
alpn=context.alpn_protocols,
|
|
140
|
+
server_name=context.server_name,
|
|
141
|
+
certificate_pem=context.certificate_pem,
|
|
142
|
+
private_key_pem=context.private_key_pem,
|
|
143
|
+
private_key_password=context.private_key_password,
|
|
144
|
+
trusted_certificates=context.trusted_certificates,
|
|
145
|
+
require_client_certificate=context.require_client_certificate,
|
|
146
|
+
transport_mode='stream',
|
|
147
|
+
validation_policy=context.validation_policy,
|
|
148
|
+
cipher_suites=context.cipher_suites,
|
|
149
|
+
)
|
|
150
|
+
self._read_lock = asyncio.Lock()
|
|
151
|
+
self._write_lock = threading.Lock()
|
|
152
|
+
self._closed = False
|
|
153
|
+
self._eof = False
|
|
154
|
+
self._plaintext_buffer = bytearray()
|
|
155
|
+
self._handshake_inbound: _RecordProtectionState | None = None
|
|
156
|
+
self._handshake_outbound: _RecordProtectionState | None = None
|
|
157
|
+
self._application_inbound: _RecordProtectionState | None = None
|
|
158
|
+
self._application_outbound: _RecordProtectionState | None = None
|
|
159
|
+
self._ssl_object: PackageOwnedSSLObject | None = None
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def ssl_object(self) -> PackageOwnedSSLObject | None:
|
|
163
|
+
return self._ssl_object
|
|
164
|
+
|
|
165
|
+
async def handshake(self) -> None:
|
|
166
|
+
try:
|
|
167
|
+
server_flight = b''
|
|
168
|
+
while not server_flight:
|
|
169
|
+
content_type, payload = await self._read_raw_record()
|
|
170
|
+
if content_type == _TLS_CONTENT_CHANGE_CIPHER_SPEC:
|
|
171
|
+
continue
|
|
172
|
+
if content_type == _TLS_CONTENT_HANDSHAKE:
|
|
173
|
+
server_flight = self._driver.receive(payload)
|
|
174
|
+
continue
|
|
175
|
+
if content_type == _TLS_CONTENT_ALERT:
|
|
176
|
+
self._eof = True
|
|
177
|
+
raise ProtocolError('peer closed the TLS handshake before completion')
|
|
178
|
+
raise ProtocolError('unexpected TLS record before ServerHello')
|
|
179
|
+
|
|
180
|
+
await self._send_server_flight(server_flight)
|
|
181
|
+
|
|
182
|
+
while not self._driver.complete:
|
|
183
|
+
content_type, payload = await self._read_raw_record()
|
|
184
|
+
if content_type == _TLS_CONTENT_CHANGE_CIPHER_SPEC:
|
|
185
|
+
continue
|
|
186
|
+
if content_type == _TLS_CONTENT_HANDSHAKE:
|
|
187
|
+
self._driver.receive(payload)
|
|
188
|
+
continue
|
|
189
|
+
if content_type == _TLS_CONTENT_ALERT:
|
|
190
|
+
self._eof = True
|
|
191
|
+
raise ProtocolError('peer closed the TLS handshake before completion')
|
|
192
|
+
if content_type != _TLS_CONTENT_APPLICATION_DATA:
|
|
193
|
+
raise ProtocolError('unexpected TLS record during encrypted handshake')
|
|
194
|
+
if self._handshake_inbound is None:
|
|
195
|
+
raise ProtocolError('TLS handshake keys are unavailable')
|
|
196
|
+
plaintext, inner_type = _decrypt_record(payload, self._handshake_inbound)
|
|
197
|
+
if inner_type == _TLS_CONTENT_CHANGE_CIPHER_SPEC:
|
|
198
|
+
continue
|
|
199
|
+
if inner_type == _TLS_CONTENT_HANDSHAKE:
|
|
200
|
+
self._driver.receive(plaintext)
|
|
201
|
+
continue
|
|
202
|
+
if inner_type == _TLS_CONTENT_ALERT:
|
|
203
|
+
self._eof = True
|
|
204
|
+
raise ProtocolError('peer sent a fatal TLS alert during the handshake')
|
|
205
|
+
raise ProtocolError('unexpected TLS inner content type during handshake')
|
|
206
|
+
|
|
207
|
+
traffic = self._driver.traffic_secrets
|
|
208
|
+
if traffic is None:
|
|
209
|
+
raise ProtocolError('TLS handshake completed without negotiated traffic secrets')
|
|
210
|
+
parameters = self._driver.cipher_parameters
|
|
211
|
+
self._application_inbound = _build_record_state(
|
|
212
|
+
traffic.client_application_secret,
|
|
213
|
+
key_length=parameters.key_length,
|
|
214
|
+
iv_length=parameters.iv_length,
|
|
215
|
+
hash_name=parameters.hash_name,
|
|
216
|
+
)
|
|
217
|
+
self._application_outbound = _build_record_state(
|
|
218
|
+
traffic.server_application_secret,
|
|
219
|
+
key_length=parameters.key_length,
|
|
220
|
+
iv_length=parameters.iv_length,
|
|
221
|
+
hash_name=parameters.hash_name,
|
|
222
|
+
)
|
|
223
|
+
peer_certificate = None
|
|
224
|
+
if self._driver.peer_certificate_pem is not None:
|
|
225
|
+
peer_certificate = load_pem_certificates((self._driver.peer_certificate_pem,))[0]
|
|
226
|
+
self._ssl_object = PackageOwnedSSLObject(
|
|
227
|
+
selected_alpn_protocol=self._driver.selected_alpn,
|
|
228
|
+
cipher_suite=getattr(self._driver, '_selected_cipher_suite'),
|
|
229
|
+
peer_certificate=peer_certificate,
|
|
230
|
+
)
|
|
231
|
+
except TlsAlertError as exc:
|
|
232
|
+
with contextlib.suppress(Exception):
|
|
233
|
+
await self._send_plain_alert(int(exc.description))
|
|
234
|
+
raise ProtocolError(str(exc)) from exc
|
|
235
|
+
|
|
236
|
+
async def read(self, n: int = -1) -> bytes:
|
|
237
|
+
if n == 0:
|
|
238
|
+
return b''
|
|
239
|
+
async with self._read_lock:
|
|
240
|
+
if n < 0:
|
|
241
|
+
while not self._eof:
|
|
242
|
+
await self._fill_plaintext_buffer()
|
|
243
|
+
data = bytes(self._plaintext_buffer)
|
|
244
|
+
self._plaintext_buffer.clear()
|
|
245
|
+
return data
|
|
246
|
+
while not self._plaintext_buffer and not self._eof:
|
|
247
|
+
await self._fill_plaintext_buffer()
|
|
248
|
+
if not self._plaintext_buffer and self._eof:
|
|
249
|
+
return b''
|
|
250
|
+
take = min(n, len(self._plaintext_buffer))
|
|
251
|
+
data = bytes(self._plaintext_buffer[:take])
|
|
252
|
+
del self._plaintext_buffer[:take]
|
|
253
|
+
return data
|
|
254
|
+
|
|
255
|
+
async def readexactly(self, n: int) -> bytes:
|
|
256
|
+
if n < 0:
|
|
257
|
+
raise ValueError('readexactly size must be non-negative')
|
|
258
|
+
async with self._read_lock:
|
|
259
|
+
while len(self._plaintext_buffer) < n and not self._eof:
|
|
260
|
+
await self._fill_plaintext_buffer()
|
|
261
|
+
if len(self._plaintext_buffer) < n:
|
|
262
|
+
partial = bytes(self._plaintext_buffer)
|
|
263
|
+
self._plaintext_buffer.clear()
|
|
264
|
+
raise asyncio.IncompleteReadError(partial=partial, expected=n)
|
|
265
|
+
data = bytes(self._plaintext_buffer[:n])
|
|
266
|
+
del self._plaintext_buffer[:n]
|
|
267
|
+
return data
|
|
268
|
+
|
|
269
|
+
async def readuntil(self, separator: bytes = b'\n') -> bytes:
|
|
270
|
+
return await self.readuntil_limited(separator, limit=None)
|
|
271
|
+
|
|
272
|
+
async def readuntil_limited(self, separator: bytes = b'\n', *, limit: int | None) -> bytes:
|
|
273
|
+
if not separator:
|
|
274
|
+
raise ValueError('separator must not be empty')
|
|
275
|
+
async with self._read_lock:
|
|
276
|
+
while True:
|
|
277
|
+
index = self._plaintext_buffer.find(separator)
|
|
278
|
+
if index >= 0:
|
|
279
|
+
end = index + len(separator)
|
|
280
|
+
data = bytes(self._plaintext_buffer[:end])
|
|
281
|
+
del self._plaintext_buffer[:end]
|
|
282
|
+
return data
|
|
283
|
+
if limit is not None and len(self._plaintext_buffer) > limit:
|
|
284
|
+
raise asyncio.LimitOverrunError('separator is not found, and chunk exceed the limit', consumed=len(self._plaintext_buffer))
|
|
285
|
+
if self._eof:
|
|
286
|
+
partial = bytes(self._plaintext_buffer)
|
|
287
|
+
self._plaintext_buffer.clear()
|
|
288
|
+
raise asyncio.IncompleteReadError(partial=partial, expected=len(partial) + len(separator))
|
|
289
|
+
await self._fill_plaintext_buffer()
|
|
290
|
+
if limit is not None and len(self._plaintext_buffer) > limit:
|
|
291
|
+
raise asyncio.LimitOverrunError('separator is not found, and chunk exceed the limit', consumed=len(self._plaintext_buffer))
|
|
292
|
+
|
|
293
|
+
def write(self, data: bytes) -> None:
|
|
294
|
+
if self._closed or not data:
|
|
295
|
+
return
|
|
296
|
+
if self._application_outbound is None:
|
|
297
|
+
raise RuntimeError('TLS application keys are not available')
|
|
298
|
+
with self._write_lock:
|
|
299
|
+
offset = 0
|
|
300
|
+
while offset < len(data):
|
|
301
|
+
chunk = data[offset:offset + _TLS_MAX_PLAINTEXT]
|
|
302
|
+
offset += len(chunk)
|
|
303
|
+
record = _encrypt_record(chunk, _TLS_CONTENT_APPLICATION_DATA, self._application_outbound)
|
|
304
|
+
self._raw_writer.write(record)
|
|
305
|
+
|
|
306
|
+
async def drain(self) -> None:
|
|
307
|
+
await self._raw_writer.drain()
|
|
308
|
+
|
|
309
|
+
def close(self) -> None:
|
|
310
|
+
if self._closed:
|
|
311
|
+
return
|
|
312
|
+
self._closed = True
|
|
313
|
+
if self._application_outbound is not None and not self._raw_writer.is_closing():
|
|
314
|
+
with contextlib.suppress(Exception):
|
|
315
|
+
self._raw_writer.write(
|
|
316
|
+
_encrypt_record(
|
|
317
|
+
bytes([1, _TLS_ALERT_CLOSE_NOTIFY]),
|
|
318
|
+
_TLS_CONTENT_ALERT,
|
|
319
|
+
self._application_outbound,
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
self._raw_writer.close()
|
|
323
|
+
|
|
324
|
+
async def wait_closed(self) -> None:
|
|
325
|
+
await self._raw_writer.wait_closed()
|
|
326
|
+
|
|
327
|
+
def is_closing(self) -> bool:
|
|
328
|
+
return self._closed or self._raw_writer.is_closing()
|
|
329
|
+
|
|
330
|
+
def can_write_eof(self) -> bool:
|
|
331
|
+
return False
|
|
332
|
+
|
|
333
|
+
def write_eof(self) -> None:
|
|
334
|
+
self.close()
|
|
335
|
+
|
|
336
|
+
def get_extra_info(self, name: str, default: Any = None) -> Any:
|
|
337
|
+
if name == 'ssl_object':
|
|
338
|
+
return self._ssl_object
|
|
339
|
+
if name == 'sslcontext':
|
|
340
|
+
return self._context
|
|
341
|
+
if name == 'peercert' and self._ssl_object is not None:
|
|
342
|
+
return self._ssl_object.getpeercert(binary_form=False)
|
|
343
|
+
if name == 'cipher' and self._ssl_object is not None:
|
|
344
|
+
return self._ssl_object.cipher()
|
|
345
|
+
if name == 'tls.negotiated_alpn':
|
|
346
|
+
return None if self._ssl_object is None else self._ssl_object.selected_alpn_protocol()
|
|
347
|
+
return self._raw_writer.get_extra_info(name, default)
|
|
348
|
+
|
|
349
|
+
async def _fill_plaintext_buffer(self) -> None:
|
|
350
|
+
content_type, payload = await self._read_raw_record()
|
|
351
|
+
if content_type == _TLS_CONTENT_CHANGE_CIPHER_SPEC:
|
|
352
|
+
return
|
|
353
|
+
if content_type == _TLS_CONTENT_ALERT:
|
|
354
|
+
self._eof = True
|
|
355
|
+
return
|
|
356
|
+
if content_type != _TLS_CONTENT_APPLICATION_DATA:
|
|
357
|
+
raise ProtocolError('unexpected TLS record after the handshake completed')
|
|
358
|
+
if self._application_inbound is None:
|
|
359
|
+
raise ProtocolError('TLS application keys are not available')
|
|
360
|
+
plaintext, inner_type = _decrypt_record(payload, self._application_inbound)
|
|
361
|
+
if inner_type == _TLS_CONTENT_APPLICATION_DATA:
|
|
362
|
+
if plaintext:
|
|
363
|
+
self._plaintext_buffer.extend(plaintext)
|
|
364
|
+
return
|
|
365
|
+
if inner_type == _TLS_CONTENT_ALERT:
|
|
366
|
+
self._eof = True
|
|
367
|
+
return
|
|
368
|
+
if inner_type == _TLS_CONTENT_CHANGE_CIPHER_SPEC:
|
|
369
|
+
return
|
|
370
|
+
raise ProtocolError('unexpected TLS inner content type after the handshake completed')
|
|
371
|
+
|
|
372
|
+
async def _send_server_flight(self, flight: bytes) -> None:
|
|
373
|
+
_message, offset = decode_handshake_message(flight, 0)
|
|
374
|
+
server_hello = flight[:offset]
|
|
375
|
+
encrypted_handshake = flight[offset:]
|
|
376
|
+
self._raw_writer.write(_encode_plain_record(_TLS_CONTENT_HANDSHAKE, server_hello))
|
|
377
|
+
self._raw_writer.write(_encode_plain_record(_TLS_CONTENT_CHANGE_CIPHER_SPEC, b'\x01'))
|
|
378
|
+
traffic = self._driver.traffic_secrets
|
|
379
|
+
if traffic is None:
|
|
380
|
+
raise ProtocolError('TLS handshake traffic secrets were not negotiated')
|
|
381
|
+
parameters = self._driver.cipher_parameters
|
|
382
|
+
self._handshake_inbound = _build_record_state(
|
|
383
|
+
traffic.client_handshake_secret,
|
|
384
|
+
key_length=parameters.key_length,
|
|
385
|
+
iv_length=parameters.iv_length,
|
|
386
|
+
hash_name=parameters.hash_name,
|
|
387
|
+
)
|
|
388
|
+
self._handshake_outbound = _build_record_state(
|
|
389
|
+
traffic.server_handshake_secret,
|
|
390
|
+
key_length=parameters.key_length,
|
|
391
|
+
iv_length=parameters.iv_length,
|
|
392
|
+
hash_name=parameters.hash_name,
|
|
393
|
+
)
|
|
394
|
+
if encrypted_handshake:
|
|
395
|
+
self._raw_writer.write(_encrypt_record(encrypted_handshake, _TLS_CONTENT_HANDSHAKE, self._handshake_outbound))
|
|
396
|
+
await self._raw_writer.drain()
|
|
397
|
+
|
|
398
|
+
async def _read_raw_record(self) -> tuple[int, bytes]:
|
|
399
|
+
try:
|
|
400
|
+
header = await self._raw_reader.readexactly(5)
|
|
401
|
+
except asyncio.IncompleteReadError:
|
|
402
|
+
self._eof = True
|
|
403
|
+
return _TLS_CONTENT_ALERT, b''
|
|
404
|
+
content_type = header[0]
|
|
405
|
+
length = int.from_bytes(header[3:5], 'big')
|
|
406
|
+
try:
|
|
407
|
+
payload = await self._raw_reader.readexactly(length)
|
|
408
|
+
except asyncio.IncompleteReadError as exc:
|
|
409
|
+
raise ProtocolError('truncated TLS record') from exc
|
|
410
|
+
return content_type, payload
|
|
411
|
+
|
|
412
|
+
async def _send_plain_alert(self, description: int) -> None:
|
|
413
|
+
self._raw_writer.write(
|
|
414
|
+
_encode_plain_record(_TLS_CONTENT_ALERT, bytes([_TLS_ALERT_LEVEL_FATAL, description]))
|
|
415
|
+
)
|
|
416
|
+
await self._raw_writer.drain()
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _build_record_state(secret: bytes, *, key_length: int, iv_length: int, hash_name: str) -> _RecordProtectionState:
|
|
420
|
+
schedule = Tls13KeySchedule(hash_name=hash_name)
|
|
421
|
+
return _RecordProtectionState(
|
|
422
|
+
key=schedule.expand_label(secret, 'key', b'', key_length),
|
|
423
|
+
iv=schedule.expand_label(secret, 'iv', b'', iv_length),
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _encode_plain_record(content_type: int, payload: bytes) -> bytes:
|
|
428
|
+
return bytes([content_type]) + _TLS_LEGACY_RECORD_VERSION.to_bytes(2, 'big') + len(payload).to_bytes(2, 'big') + payload
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _encrypt_record(payload: bytes, inner_content_type: int, state: _RecordProtectionState) -> bytes:
|
|
432
|
+
inner = payload + bytes([inner_content_type])
|
|
433
|
+
nonce = state.next_nonce()
|
|
434
|
+
body_length = len(inner) + 16
|
|
435
|
+
header = (
|
|
436
|
+
bytes([_TLS_CONTENT_APPLICATION_DATA])
|
|
437
|
+
+ _TLS_LEGACY_RECORD_VERSION.to_bytes(2, 'big')
|
|
438
|
+
+ body_length.to_bytes(2, 'big')
|
|
439
|
+
)
|
|
440
|
+
ciphertext, tag = aes_gcm_encrypt(state.key, nonce, inner, aad=header)
|
|
441
|
+
return header + ciphertext + tag
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _decrypt_record(payload: bytes, state: _RecordProtectionState) -> tuple[bytes, int]:
|
|
445
|
+
if len(payload) < 16:
|
|
446
|
+
raise ProtocolError('truncated TLS application-data record')
|
|
447
|
+
header = (
|
|
448
|
+
bytes([_TLS_CONTENT_APPLICATION_DATA])
|
|
449
|
+
+ _TLS_LEGACY_RECORD_VERSION.to_bytes(2, 'big')
|
|
450
|
+
+ len(payload).to_bytes(2, 'big')
|
|
451
|
+
)
|
|
452
|
+
ciphertext = payload[:-16]
|
|
453
|
+
tag = payload[-16:]
|
|
454
|
+
nonce = state.next_nonce()
|
|
455
|
+
plaintext = aes_gcm_decrypt(state.key, nonce, ciphertext, tag, aad=header)
|
|
456
|
+
index = len(plaintext) - 1
|
|
457
|
+
while index >= 0 and plaintext[index] == 0:
|
|
458
|
+
index -= 1
|
|
459
|
+
if index < 0:
|
|
460
|
+
raise ProtocolError('TLS inner plaintext is missing a content type')
|
|
461
|
+
return plaintext[:index], plaintext[index]
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def describe_peer_certificate(certificate: x509.Certificate) -> dict[str, Any]:
|
|
465
|
+
return {
|
|
466
|
+
'subject': certificate.subject.rfc4514_string(),
|
|
467
|
+
'issuer': certificate.issuer.rfc4514_string(),
|
|
468
|
+
'serial_number': hex(certificate.serial_number),
|
|
469
|
+
'not_valid_before': _iso_utc(
|
|
470
|
+
certificate.not_valid_before_utc if hasattr(certificate, 'not_valid_before_utc') else certificate.not_valid_before
|
|
471
|
+
),
|
|
472
|
+
'not_valid_after': _iso_utc(
|
|
473
|
+
certificate.not_valid_after_utc if hasattr(certificate, 'not_valid_after_utc') else certificate.not_valid_after
|
|
474
|
+
),
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def tls_extension_payload(writer: Any) -> dict[str, Any] | None:
|
|
479
|
+
ssl_object = getattr(writer, 'get_extra_info', lambda *args, **kwargs: None)('ssl_object')
|
|
480
|
+
if ssl_object is None:
|
|
481
|
+
return None
|
|
482
|
+
payload: dict[str, Any] = {}
|
|
483
|
+
selected_alpn = getattr(ssl_object, 'selected_alpn_protocol', lambda: None)()
|
|
484
|
+
if selected_alpn is not None:
|
|
485
|
+
payload['selected_alpn_protocol'] = selected_alpn
|
|
486
|
+
getpeercert = getattr(ssl_object, 'getpeercert', None)
|
|
487
|
+
if callable(getpeercert):
|
|
488
|
+
peer_cert = getpeercert(binary_form=False)
|
|
489
|
+
if peer_cert is not None:
|
|
490
|
+
payload['peer_cert'] = peer_cert
|
|
491
|
+
return payload or None
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def build_server_ssl_context(listener: ListenerConfig) -> ServerTLSContext | None:
|
|
495
|
+
if not listener.ssl_enabled:
|
|
496
|
+
return None
|
|
497
|
+
assert listener.ssl_certfile is not None
|
|
498
|
+
assert listener.ssl_keyfile is not None
|
|
499
|
+
certificate_pem = Path(listener.ssl_certfile).read_bytes()
|
|
500
|
+
private_key_pem = Path(listener.ssl_keyfile).read_bytes()
|
|
501
|
+
private_key_password = getattr(listener, 'ssl_keyfile_password', None)
|
|
502
|
+
if private_key_password is not None and not isinstance(private_key_password, bytes):
|
|
503
|
+
private_key_password = str(private_key_password).encode('utf-8')
|
|
504
|
+
trusted = (Path(listener.ssl_ca_certs).read_bytes(),) if listener.ssl_ca_certs else ()
|
|
505
|
+
validation_policy = build_validation_policy_for_listener(listener)
|
|
506
|
+
server_name = _listener_server_name(listener)
|
|
507
|
+
return ServerTLSContext(
|
|
508
|
+
certificate_pem=certificate_pem,
|
|
509
|
+
private_key_pem=private_key_pem,
|
|
510
|
+
private_key_password=private_key_password,
|
|
511
|
+
trusted_certificates=trusted,
|
|
512
|
+
alpn_protocols=tuple(listener.alpn_protocols),
|
|
513
|
+
require_client_certificate=listener.ssl_require_client_cert,
|
|
514
|
+
validation_policy=validation_policy,
|
|
515
|
+
cipher_suites=tuple(int(item) for item in (getattr(listener, 'resolved_cipher_suites', ()) or (0x1302, 0x1301))),
|
|
516
|
+
server_name=server_name,
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
async def wrap_server_tls_connection(
|
|
521
|
+
raw_reader: asyncio.StreamReader,
|
|
522
|
+
raw_writer: asyncio.StreamWriter,
|
|
523
|
+
context: ServerTLSContext,
|
|
524
|
+
) -> PackageOwnedTLSConnection:
|
|
525
|
+
connection = PackageOwnedTLSConnection(raw_reader, raw_writer, context)
|
|
526
|
+
await connection.handshake()
|
|
527
|
+
return connection
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
build_server_tls_context = build_server_ssl_context
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def verify_certificate_chain(
|
|
534
|
+
chain_pems: Iterable[bytes],
|
|
535
|
+
trust_roots_pems: Iterable[bytes],
|
|
536
|
+
*,
|
|
537
|
+
server_name: str = '',
|
|
538
|
+
moment: datetime | None = None,
|
|
539
|
+
policy: CertificateValidationPolicy | None = None,
|
|
540
|
+
) -> x509.Certificate:
|
|
541
|
+
return _verify_certificate_chain(
|
|
542
|
+
chain_pems,
|
|
543
|
+
trust_roots_pems,
|
|
544
|
+
server_name=server_name,
|
|
545
|
+
moment=moment,
|
|
546
|
+
policy=policy,
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _listener_server_name(listener: ListenerConfig) -> str:
|
|
551
|
+
host = listener.host or 'localhost'
|
|
552
|
+
if host in {'0.0.0.0', '::', ''}:
|
|
553
|
+
return 'localhost'
|
|
554
|
+
return host
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _iso_utc(moment: datetime) -> str:
|
|
558
|
+
if moment.tzinfo is None:
|
|
559
|
+
moment = moment.replace(tzinfo=timezone.utc)
|
|
560
|
+
return moment.astimezone(timezone.utc).isoformat().replace('+00:00', 'Z')
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
__all__ = [
|
|
564
|
+
'PackageOwnedSSLObject',
|
|
565
|
+
'PackageOwnedTLSConnection',
|
|
566
|
+
'ServerTLSContext',
|
|
567
|
+
'build_server_ssl_context',
|
|
568
|
+
'build_server_tls_context',
|
|
569
|
+
'wrap_server_tls_connection',
|
|
570
|
+
'tls_extension_payload',
|
|
571
|
+
'CertificatePurpose',
|
|
572
|
+
'CertificateValidationPolicy',
|
|
573
|
+
'RevocationCache',
|
|
574
|
+
'RevocationCacheEntry',
|
|
575
|
+
'RevocationFetchPolicy',
|
|
576
|
+
'RevocationFreshnessPolicy',
|
|
577
|
+
'RevocationMaterial',
|
|
578
|
+
'RevocationMode',
|
|
579
|
+
'load_pem_certificates',
|
|
580
|
+
'verify_certificate_validity',
|
|
581
|
+
'verify_certificate_hostname',
|
|
582
|
+
'verify_certificate_chain',
|
|
583
|
+
]
|