tigrcorn-security 0.3.16.dev5__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.dev5.dist-info/METADATA +239 -0
- tigrcorn_security-0.3.16.dev5.dist-info/RECORD +20 -0
- tigrcorn_security-0.3.16.dev5.dist-info/WHEEL +5 -0
- tigrcorn_security-0.3.16.dev5.dist-info/licenses/LICENSE +163 -0
- tigrcorn_security-0.3.16.dev5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1284 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import OrderedDict
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from email.utils import parsedate_to_datetime
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from ipaddress import ip_address
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
from threading import RLock
|
|
12
|
+
from typing import Iterable, Sequence
|
|
13
|
+
from urllib.error import HTTPError, URLError
|
|
14
|
+
from urllib.parse import urlparse
|
|
15
|
+
from urllib.request import Request, urlopen
|
|
16
|
+
|
|
17
|
+
class _MissingDependencyProxy:
|
|
18
|
+
def __init__(self, package: str) -> None:
|
|
19
|
+
self._package = package
|
|
20
|
+
|
|
21
|
+
def __getattr__(self, name: str):
|
|
22
|
+
raise ModuleNotFoundError(
|
|
23
|
+
f"{self._package} is required for this X.509 validation operation; install tigrcorn[tls-x509]"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from cryptography import x509
|
|
29
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
30
|
+
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, ed448, padding as asym_padding, rsa
|
|
31
|
+
from cryptography.x509 import ocsp
|
|
32
|
+
except ModuleNotFoundError: # pragma: no cover - exercised in dependency-light environments
|
|
33
|
+
x509 = _MissingDependencyProxy("cryptography") # type: ignore[assignment]
|
|
34
|
+
hashes = _MissingDependencyProxy("cryptography") # type: ignore[assignment]
|
|
35
|
+
serialization = _MissingDependencyProxy("cryptography") # type: ignore[assignment]
|
|
36
|
+
ec = _MissingDependencyProxy("cryptography") # type: ignore[assignment]
|
|
37
|
+
ed25519 = _MissingDependencyProxy("cryptography") # type: ignore[assignment]
|
|
38
|
+
ed448 = _MissingDependencyProxy("cryptography") # type: ignore[assignment]
|
|
39
|
+
asym_padding = _MissingDependencyProxy("cryptography") # type: ignore[assignment]
|
|
40
|
+
rsa = _MissingDependencyProxy("cryptography") # type: ignore[assignment]
|
|
41
|
+
ocsp = _MissingDependencyProxy("cryptography") # type: ignore[assignment]
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
from cryptography.x509 import verification # type: ignore[attr-defined]
|
|
45
|
+
_HAS_X509_VERIFICATION = True
|
|
46
|
+
except ImportError: # pragma: no cover - compatibility path for older cryptography releases
|
|
47
|
+
verification = None # type: ignore[assignment]
|
|
48
|
+
_HAS_X509_VERIFICATION = False
|
|
49
|
+
try:
|
|
50
|
+
from cryptography.x509.oid import AuthorityInformationAccessOID, ExtensionOID, ExtendedKeyUsageOID
|
|
51
|
+
except ModuleNotFoundError: # pragma: no cover - exercised in dependency-light environments
|
|
52
|
+
AuthorityInformationAccessOID = _MissingDependencyProxy("cryptography") # type: ignore[assignment]
|
|
53
|
+
ExtensionOID = _MissingDependencyProxy("cryptography") # type: ignore[assignment]
|
|
54
|
+
ExtendedKeyUsageOID = _MissingDependencyProxy("cryptography") # type: ignore[assignment]
|
|
55
|
+
|
|
56
|
+
from tigrcorn_core.errors import ProtocolError
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class CertificatePurpose(str, Enum):
|
|
60
|
+
SERVER_AUTH = 'server'
|
|
61
|
+
CLIENT_AUTH = 'client'
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RevocationMode(str, Enum):
|
|
65
|
+
OFF = 'off'
|
|
66
|
+
SOFT_FAIL = 'soft-fail'
|
|
67
|
+
REQUIRE = 'require'
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class RevocationMaterial:
|
|
72
|
+
crls: tuple[x509.CertificateRevocationList | bytes, ...] = ()
|
|
73
|
+
ocsp_responses: tuple[ocsp.OCSPResponse | bytes, ...] = ()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class RevocationFreshnessPolicy:
|
|
78
|
+
allowed_clock_skew: timedelta = timedelta(minutes=5)
|
|
79
|
+
ocsp_max_age_without_next_update: timedelta = timedelta(hours=12)
|
|
80
|
+
ocsp_max_validity_window: timedelta | None = timedelta(days=7)
|
|
81
|
+
crl_max_validity_window: timedelta | None = timedelta(days=14)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class RevocationCacheEntry:
|
|
86
|
+
payload: bytes
|
|
87
|
+
fetched_at: datetime
|
|
88
|
+
expires_at: datetime | None
|
|
89
|
+
content_type: str | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class RevocationCache:
|
|
93
|
+
def __init__(self, *, max_entries: int = 256) -> None:
|
|
94
|
+
if max_entries < 1:
|
|
95
|
+
raise ValueError('revocation cache max_entries must be at least 1')
|
|
96
|
+
self._max_entries = max_entries
|
|
97
|
+
self._entries: OrderedDict[tuple[str, str, str], RevocationCacheEntry] = OrderedDict()
|
|
98
|
+
self._lock = RLock()
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def max_entries(self) -> int:
|
|
102
|
+
return self._max_entries
|
|
103
|
+
|
|
104
|
+
def get(self, kind: str, url: str, fingerprint: str, *, moment: datetime) -> RevocationCacheEntry | None:
|
|
105
|
+
key = (kind, url, fingerprint)
|
|
106
|
+
with self._lock:
|
|
107
|
+
entry = self._entries.get(key)
|
|
108
|
+
if entry is None:
|
|
109
|
+
return None
|
|
110
|
+
if entry.expires_at is not None and moment > entry.expires_at:
|
|
111
|
+
del self._entries[key]
|
|
112
|
+
return None
|
|
113
|
+
self._entries.move_to_end(key)
|
|
114
|
+
return entry
|
|
115
|
+
|
|
116
|
+
def put(self, kind: str, url: str, fingerprint: str, entry: RevocationCacheEntry) -> None:
|
|
117
|
+
key = (kind, url, fingerprint)
|
|
118
|
+
with self._lock:
|
|
119
|
+
self._entries[key] = entry
|
|
120
|
+
self._entries.move_to_end(key)
|
|
121
|
+
while len(self._entries) > self._max_entries:
|
|
122
|
+
self._entries.popitem(last=False)
|
|
123
|
+
|
|
124
|
+
def delete(self, kind: str, url: str, fingerprint: str) -> None:
|
|
125
|
+
key = (kind, url, fingerprint)
|
|
126
|
+
with self._lock:
|
|
127
|
+
self._entries.pop(key, None)
|
|
128
|
+
|
|
129
|
+
def purge(self, *, moment: datetime | None = None) -> int:
|
|
130
|
+
now = _as_utc(moment)
|
|
131
|
+
removed = 0
|
|
132
|
+
with self._lock:
|
|
133
|
+
for key, entry in tuple(self._entries.items()):
|
|
134
|
+
if entry.expires_at is not None and now > entry.expires_at:
|
|
135
|
+
del self._entries[key]
|
|
136
|
+
removed += 1
|
|
137
|
+
return removed
|
|
138
|
+
|
|
139
|
+
def __len__(self) -> int:
|
|
140
|
+
with self._lock:
|
|
141
|
+
return len(self._entries)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True, slots=True)
|
|
145
|
+
class RevocationFetchPolicy:
|
|
146
|
+
enable_ocsp_aia: bool = True
|
|
147
|
+
enable_crl_distribution_points: bool = True
|
|
148
|
+
timeout_seconds: float = 5.0
|
|
149
|
+
max_response_bytes: int = 2_000_000
|
|
150
|
+
allowed_schemes: tuple[str, ...] = ('http', 'https')
|
|
151
|
+
freshness: RevocationFreshnessPolicy = field(default_factory=RevocationFreshnessPolicy)
|
|
152
|
+
cache: RevocationCache | None = field(default_factory=RevocationCache)
|
|
153
|
+
user_agent: str = 'tigrcorn/0.3.5'
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True, slots=True)
|
|
157
|
+
class CertificateValidationPolicy:
|
|
158
|
+
purpose: CertificatePurpose = CertificatePurpose.SERVER_AUTH
|
|
159
|
+
max_chain_depth: int | None = None
|
|
160
|
+
revocation_mode: RevocationMode = RevocationMode.OFF
|
|
161
|
+
revocation_material: RevocationMaterial = RevocationMaterial()
|
|
162
|
+
revocation_fetch_policy: RevocationFetchPolicy | None = field(default_factory=RevocationFetchPolicy)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True, slots=True)
|
|
166
|
+
class VerifiedCertificatePath:
|
|
167
|
+
leaf: x509.Certificate
|
|
168
|
+
chain: tuple[x509.Certificate, ...]
|
|
169
|
+
trust_anchor: x509.Certificate
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True, slots=True)
|
|
173
|
+
class _FetchedRevocationPayload:
|
|
174
|
+
payload: bytes
|
|
175
|
+
fetched_at: datetime
|
|
176
|
+
headers: tuple[tuple[str, str], ...]
|
|
177
|
+
content_type: str | None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass(frozen=True, slots=True)
|
|
181
|
+
class _FetchedRevocationMaterial:
|
|
182
|
+
crls: tuple[x509.CertificateRevocationList, ...] = ()
|
|
183
|
+
ocsp_responses: tuple[ocsp.OCSPResponse, ...] = ()
|
|
184
|
+
errors: tuple[str, ...] = ()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class _RevocationFetchError(ProtocolError):
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if _HAS_X509_VERIFICATION:
|
|
192
|
+
_WEBOOKI_CA_POLICY = verification.ExtensionPolicy.webpki_defaults_ca()
|
|
193
|
+
_WEBOOKI_EE_POLICY = verification.ExtensionPolicy.webpki_defaults_ee()
|
|
194
|
+
else: # pragma: no cover - compatibility path for older cryptography releases
|
|
195
|
+
_WEBOOKI_CA_POLICY = None
|
|
196
|
+
_WEBOOKI_EE_POLICY = None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _as_utc(moment: datetime | None) -> datetime:
|
|
200
|
+
now = moment or datetime.now(timezone.utc)
|
|
201
|
+
if now.tzinfo is None:
|
|
202
|
+
return now.replace(tzinfo=timezone.utc)
|
|
203
|
+
return now.astimezone(timezone.utc)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _compat_datetime(value: datetime) -> datetime:
|
|
209
|
+
if value.tzinfo is None:
|
|
210
|
+
return value.replace(tzinfo=timezone.utc)
|
|
211
|
+
return value.astimezone(timezone.utc)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _certificate_not_valid_before(certificate: x509.Certificate) -> datetime:
|
|
215
|
+
value = getattr(certificate, 'not_valid_before_utc', None)
|
|
216
|
+
if value is None:
|
|
217
|
+
value = certificate.not_valid_before
|
|
218
|
+
return _compat_datetime(value)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _certificate_not_valid_after(certificate: x509.Certificate) -> datetime:
|
|
222
|
+
value = getattr(certificate, 'not_valid_after_utc', None)
|
|
223
|
+
if value is None:
|
|
224
|
+
value = certificate.not_valid_after
|
|
225
|
+
return _compat_datetime(value)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _crl_last_update(crl: x509.CertificateRevocationList) -> datetime:
|
|
229
|
+
value = getattr(crl, 'last_update_utc', None)
|
|
230
|
+
if value is None:
|
|
231
|
+
value = crl.last_update
|
|
232
|
+
return _compat_datetime(value)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _crl_next_update(crl: x509.CertificateRevocationList) -> datetime | None:
|
|
236
|
+
value = getattr(crl, 'next_update_utc', None)
|
|
237
|
+
if value is None:
|
|
238
|
+
value = crl.next_update
|
|
239
|
+
return None if value is None else _compat_datetime(value)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _ocsp_response_produced_at(response: ocsp.OCSPResponse) -> datetime:
|
|
243
|
+
value = getattr(response, 'produced_at_utc', None)
|
|
244
|
+
if value is None:
|
|
245
|
+
value = response.produced_at
|
|
246
|
+
return _compat_datetime(value)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _ocsp_single_this_update(single: ocsp.OCSPSingleResponse) -> datetime:
|
|
250
|
+
value = getattr(single, 'this_update_utc', None)
|
|
251
|
+
if value is None:
|
|
252
|
+
value = single.this_update
|
|
253
|
+
return _compat_datetime(value)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _ocsp_single_next_update(single: ocsp.OCSPSingleResponse) -> datetime | None:
|
|
257
|
+
value = getattr(single, 'next_update_utc', None)
|
|
258
|
+
if value is None:
|
|
259
|
+
value = single.next_update
|
|
260
|
+
return None if value is None else _compat_datetime(value)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _basic_constraints(certificate: x509.Certificate) -> x509.BasicConstraints:
|
|
264
|
+
try:
|
|
265
|
+
return certificate.extensions.get_extension_for_oid(ExtensionOID.BASIC_CONSTRAINTS).value
|
|
266
|
+
except x509.ExtensionNotFound as exc:
|
|
267
|
+
raise ProtocolError('peer certificate chain verification failed: missing BasicConstraints extension') from exc
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _key_usage(certificate: x509.Certificate) -> x509.KeyUsage | None:
|
|
271
|
+
try:
|
|
272
|
+
return certificate.extensions.get_extension_for_oid(ExtensionOID.KEY_USAGE).value
|
|
273
|
+
except x509.ExtensionNotFound:
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _extended_key_usage(certificate: x509.Certificate) -> x509.ExtendedKeyUsage | None:
|
|
278
|
+
try:
|
|
279
|
+
return certificate.extensions.get_extension_for_oid(ExtensionOID.EXTENDED_KEY_USAGE).value
|
|
280
|
+
except x509.ExtensionNotFound:
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _subject_alt_name(certificate: x509.Certificate) -> x509.SubjectAlternativeName | None:
|
|
285
|
+
try:
|
|
286
|
+
return certificate.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME).value
|
|
287
|
+
except x509.ExtensionNotFound:
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _name_constraints(certificate: x509.Certificate) -> x509.NameConstraints | None:
|
|
292
|
+
try:
|
|
293
|
+
return certificate.extensions.get_extension_for_oid(ExtensionOID.NAME_CONSTRAINTS).value
|
|
294
|
+
except x509.ExtensionNotFound:
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _verify_certificate_signature(child: x509.Certificate, issuer: x509.Certificate) -> None:
|
|
299
|
+
if child.issuer != issuer.subject:
|
|
300
|
+
raise ProtocolError('peer certificate chain verification failed: issuer name mismatch')
|
|
301
|
+
try:
|
|
302
|
+
_verify_signature(issuer.public_key(), child.signature, child.tbs_certificate_bytes, child.signature_hash_algorithm)
|
|
303
|
+
except Exception as exc:
|
|
304
|
+
raise ProtocolError('peer certificate chain verification failed: signature verification failed') from exc
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _verify_crl_signature(crl: x509.CertificateRevocationList, issuer: x509.Certificate) -> None:
|
|
308
|
+
verifier = getattr(crl, 'is_signature_valid', None)
|
|
309
|
+
if callable(verifier):
|
|
310
|
+
if not verifier(issuer.public_key()):
|
|
311
|
+
raise ProtocolError('CRL signature verification failed')
|
|
312
|
+
return
|
|
313
|
+
try:
|
|
314
|
+
_verify_signature(issuer.public_key(), crl.signature, crl.tbs_certlist_bytes, crl.signature_hash_algorithm)
|
|
315
|
+
except Exception as exc:
|
|
316
|
+
raise ProtocolError('CRL signature verification failed') from exc
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _dns_within_constraint(name: str, constraint: str) -> bool:
|
|
320
|
+
candidate = _normalized_dns_name(name)
|
|
321
|
+
permitted = _normalized_dns_name(constraint)
|
|
322
|
+
return candidate == permitted or candidate.endswith('.' + permitted)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _enforce_name_constraints(chain: Sequence[x509.Certificate]) -> None:
|
|
326
|
+
leaf = chain[0]
|
|
327
|
+
leaf_san = _subject_alt_name(leaf)
|
|
328
|
+
leaf_dns = tuple(_normalized_dns_name(name) for name in leaf_san.get_values_for_type(x509.DNSName)) if leaf_san is not None else ()
|
|
329
|
+
leaf_ips = tuple(leaf_san.get_values_for_type(x509.IPAddress)) if leaf_san is not None else ()
|
|
330
|
+
for issuer in chain[1:]:
|
|
331
|
+
constraints = _name_constraints(issuer)
|
|
332
|
+
if constraints is None:
|
|
333
|
+
continue
|
|
334
|
+
permitted = constraints.permitted_subtrees or ()
|
|
335
|
+
excluded = constraints.excluded_subtrees or ()
|
|
336
|
+
for subtree in excluded:
|
|
337
|
+
if isinstance(subtree, x509.DNSName) and any(_dns_within_constraint(name, subtree.value) for name in leaf_dns):
|
|
338
|
+
raise ProtocolError('peer certificate chain verification failed: name constraints violated')
|
|
339
|
+
if isinstance(subtree, x509.IPAddress) and any(ip == subtree.value for ip in leaf_ips):
|
|
340
|
+
raise ProtocolError('peer certificate chain verification failed: name constraints violated')
|
|
341
|
+
if permitted:
|
|
342
|
+
permitted_dns = [subtree.value for subtree in permitted if isinstance(subtree, x509.DNSName)]
|
|
343
|
+
permitted_ips = [subtree.value for subtree in permitted if isinstance(subtree, x509.IPAddress)]
|
|
344
|
+
if leaf_dns and permitted_dns and not all(any(_dns_within_constraint(name, constraint) for constraint in permitted_dns) for name in leaf_dns):
|
|
345
|
+
raise ProtocolError('peer certificate chain verification failed: name constraints violated')
|
|
346
|
+
if leaf_ips and permitted_ips and not all(any(ip == constraint for constraint in permitted_ips) for ip in leaf_ips):
|
|
347
|
+
raise ProtocolError('peer certificate chain verification failed: name constraints violated')
|
|
348
|
+
if (leaf_dns and not permitted_dns) or (leaf_ips and not permitted_ips):
|
|
349
|
+
raise ProtocolError('peer certificate chain verification failed: name constraints violated')
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _manual_verified_chain(
|
|
353
|
+
chain: Sequence[x509.Certificate],
|
|
354
|
+
trust_roots: Sequence[x509.Certificate],
|
|
355
|
+
*,
|
|
356
|
+
server_name: str,
|
|
357
|
+
moment: datetime,
|
|
358
|
+
policy: CertificateValidationPolicy,
|
|
359
|
+
) -> tuple[x509.Certificate, ...]:
|
|
360
|
+
if not chain:
|
|
361
|
+
raise ProtocolError('peer certificate chain verification failed: empty certificate chain')
|
|
362
|
+
|
|
363
|
+
verified_chain = list(chain)
|
|
364
|
+
for certificate in verified_chain:
|
|
365
|
+
verify_certificate_validity(certificate, moment=moment)
|
|
366
|
+
|
|
367
|
+
trust_anchor: x509.Certificate | None = None
|
|
368
|
+
top = verified_chain[-1]
|
|
369
|
+
for root in trust_roots:
|
|
370
|
+
if root.fingerprint(hashes.SHA256()) == top.fingerprint(hashes.SHA256()):
|
|
371
|
+
trust_anchor = root
|
|
372
|
+
verified_chain[-1] = root
|
|
373
|
+
break
|
|
374
|
+
try:
|
|
375
|
+
_verify_certificate_signature(top, root)
|
|
376
|
+
except ProtocolError:
|
|
377
|
+
continue
|
|
378
|
+
trust_anchor = root
|
|
379
|
+
verified_chain.append(root)
|
|
380
|
+
break
|
|
381
|
+
if trust_anchor is None:
|
|
382
|
+
raise ProtocolError('peer certificate chain verification failed: unable to locate a trusted issuer')
|
|
383
|
+
|
|
384
|
+
if len(verified_chain) > 1:
|
|
385
|
+
for child, issuer in zip(verified_chain, verified_chain[1:]):
|
|
386
|
+
_verify_certificate_signature(child, issuer)
|
|
387
|
+
|
|
388
|
+
if policy.max_chain_depth is not None:
|
|
389
|
+
intermediate_count = max(0, len(verified_chain) - 2)
|
|
390
|
+
if intermediate_count > policy.max_chain_depth:
|
|
391
|
+
raise ProtocolError('peer certificate chain verification failed: maximum chain depth exceeded')
|
|
392
|
+
|
|
393
|
+
for index in range(1, len(verified_chain)):
|
|
394
|
+
issuer = verified_chain[index]
|
|
395
|
+
verify_certificate_validity(issuer, moment=moment)
|
|
396
|
+
constraints = _basic_constraints(issuer)
|
|
397
|
+
if not constraints.ca and index != len(verified_chain) - 1:
|
|
398
|
+
raise ProtocolError('peer certificate chain verification failed: issuer is not a CA certificate')
|
|
399
|
+
usage = _key_usage(issuer)
|
|
400
|
+
if usage is not None and not usage.key_cert_sign and index != 0:
|
|
401
|
+
raise ProtocolError('peer certificate chain verification failed: issuer is not permitted to sign certificates')
|
|
402
|
+
if constraints.path_length is not None:
|
|
403
|
+
subordinate_ca_count = sum(1 for candidate in verified_chain[1:index] if _basic_constraints(candidate).ca)
|
|
404
|
+
if subordinate_ca_count > constraints.path_length:
|
|
405
|
+
raise ProtocolError('peer certificate chain verification failed: path length constraint violated')
|
|
406
|
+
|
|
407
|
+
leaf = verified_chain[0]
|
|
408
|
+
leaf_constraints = _basic_constraints(leaf)
|
|
409
|
+
if leaf_constraints.ca:
|
|
410
|
+
raise ProtocolError('peer certificate chain verification failed: leaf certificate must not be a CA certificate')
|
|
411
|
+
eku = _extended_key_usage(leaf)
|
|
412
|
+
if eku is not None:
|
|
413
|
+
required = ExtendedKeyUsageOID.CLIENT_AUTH if policy.purpose is CertificatePurpose.CLIENT_AUTH else ExtendedKeyUsageOID.SERVER_AUTH
|
|
414
|
+
if required not in eku:
|
|
415
|
+
raise ProtocolError('peer certificate chain verification failed: leaf certificate does not satisfy the required extended key usage')
|
|
416
|
+
usage = _key_usage(leaf)
|
|
417
|
+
if usage is not None and not usage.digital_signature:
|
|
418
|
+
raise ProtocolError('peer certificate chain verification failed: leaf certificate is not permitted for digital signatures')
|
|
419
|
+
|
|
420
|
+
_enforce_name_constraints(verified_chain)
|
|
421
|
+
|
|
422
|
+
if policy.purpose is CertificatePurpose.SERVER_AUTH:
|
|
423
|
+
verify_certificate_hostname(leaf, server_name)
|
|
424
|
+
|
|
425
|
+
return tuple(verified_chain)
|
|
426
|
+
|
|
427
|
+
def _looks_like_pem(data: bytes) -> bool:
|
|
428
|
+
return data.lstrip().startswith(b'-----BEGIN ')
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _split_pem_certificates(data: bytes) -> tuple[bytes, ...]:
|
|
432
|
+
if not _looks_like_pem(data):
|
|
433
|
+
return (data,)
|
|
434
|
+
matches = tuple(
|
|
435
|
+
match.strip() + b'\n'
|
|
436
|
+
for match in re.findall(rb'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----', data, flags=re.DOTALL)
|
|
437
|
+
)
|
|
438
|
+
return matches or (data,)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _load_certificate(data: bytes) -> x509.Certificate:
|
|
442
|
+
if _looks_like_pem(data):
|
|
443
|
+
return x509.load_pem_x509_certificate(data)
|
|
444
|
+
return x509.load_der_x509_certificate(data)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def load_pem_certificates(pems: Iterable[bytes]) -> list[x509.Certificate]:
|
|
448
|
+
certificates: list[x509.Certificate] = []
|
|
449
|
+
for pem in pems:
|
|
450
|
+
for certificate_blob in _split_pem_certificates(pem):
|
|
451
|
+
certificates.append(_load_certificate(certificate_blob))
|
|
452
|
+
return certificates
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _load_crl(data: x509.CertificateRevocationList | bytes) -> x509.CertificateRevocationList:
|
|
456
|
+
if isinstance(data, x509.CertificateRevocationList):
|
|
457
|
+
return data
|
|
458
|
+
if _looks_like_pem(data):
|
|
459
|
+
return x509.load_pem_x509_crl(data)
|
|
460
|
+
return x509.load_der_x509_crl(data)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _split_pem_crls(data: bytes) -> tuple[bytes, ...]:
|
|
464
|
+
matches = tuple(
|
|
465
|
+
match.strip() + b'\n'
|
|
466
|
+
for match in re.findall(
|
|
467
|
+
rb'-----BEGIN (?:X509 )?CRL-----.*?-----END (?:X509 )?CRL-----',
|
|
468
|
+
data,
|
|
469
|
+
flags=re.DOTALL,
|
|
470
|
+
)
|
|
471
|
+
)
|
|
472
|
+
return matches or (data,)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def load_crls_from_file(path: str | Path) -> tuple[x509.CertificateRevocationList, ...]:
|
|
476
|
+
data = Path(path).read_bytes()
|
|
477
|
+
if _looks_like_pem(data):
|
|
478
|
+
return tuple(_load_crl(blob) for blob in _split_pem_crls(data))
|
|
479
|
+
return (_load_crl(data),)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _load_ocsp_response(data: ocsp.OCSPResponse | bytes) -> ocsp.OCSPResponse:
|
|
483
|
+
if isinstance(data, ocsp.OCSPResponse):
|
|
484
|
+
return data
|
|
485
|
+
return ocsp.load_der_ocsp_response(data)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _normalized_dns_name(value: str) -> str:
|
|
489
|
+
return value.rstrip('.').encode('idna').decode('ascii').lower()
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _certificate_time_bounds(certificate: x509.Certificate) -> tuple[datetime, datetime]:
|
|
493
|
+
return _certificate_not_valid_before(certificate), _certificate_not_valid_after(certificate)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def verify_certificate_validity(certificate: x509.Certificate, *, moment: datetime | None = None) -> None:
|
|
497
|
+
now = _as_utc(moment)
|
|
498
|
+
not_before, not_after = _certificate_time_bounds(certificate)
|
|
499
|
+
if now < not_before or now > not_after:
|
|
500
|
+
raise ProtocolError('peer certificate is not currently valid')
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _dnsname_match(pattern: str, hostname: str) -> bool:
|
|
504
|
+
left = _normalized_dns_name(pattern)
|
|
505
|
+
right = _normalized_dns_name(hostname)
|
|
506
|
+
if left == right:
|
|
507
|
+
return True
|
|
508
|
+
if not left.startswith('*.'):
|
|
509
|
+
return False
|
|
510
|
+
suffix = left[1:]
|
|
511
|
+
if not right.endswith(suffix):
|
|
512
|
+
return False
|
|
513
|
+
prefix = right[: -len(suffix)]
|
|
514
|
+
return prefix.count('.') == 0 and bool(prefix)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _server_subject(server_name: str):
|
|
518
|
+
if not _HAS_X509_VERIFICATION:
|
|
519
|
+
return server_name
|
|
520
|
+
try:
|
|
521
|
+
return verification.IPAddress(ip_address(server_name))
|
|
522
|
+
except ValueError:
|
|
523
|
+
return verification.DNSName(_normalized_dns_name(server_name))
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _first_subject_alt_name(certificate: x509.Certificate):
|
|
527
|
+
try:
|
|
528
|
+
san = certificate.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME).value
|
|
529
|
+
except x509.ExtensionNotFound as exc:
|
|
530
|
+
raise ProtocolError('peer certificate does not contain a subjectAltName extension') from exc
|
|
531
|
+
dns_names = san.get_values_for_type(x509.DNSName)
|
|
532
|
+
if dns_names:
|
|
533
|
+
return _normalized_dns_name(dns_names[0]) if not _HAS_X509_VERIFICATION else verification.DNSName(_normalized_dns_name(dns_names[0]))
|
|
534
|
+
ip_names = san.get_values_for_type(x509.IPAddress)
|
|
535
|
+
if ip_names:
|
|
536
|
+
return ip_names[0] if not _HAS_X509_VERIFICATION else verification.IPAddress(ip_names[0])
|
|
537
|
+
raise ProtocolError('peer certificate subjectAltName extension does not contain a DNS or IP subject')
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def verify_certificate_hostname(certificate: x509.Certificate, server_name: str) -> None:
|
|
541
|
+
if not server_name:
|
|
542
|
+
return
|
|
543
|
+
try:
|
|
544
|
+
san = certificate.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME).value
|
|
545
|
+
except x509.ExtensionNotFound as exc:
|
|
546
|
+
raise ProtocolError('peer certificate does not contain a subjectAltName extension') from exc
|
|
547
|
+
try:
|
|
548
|
+
target_ip = ip_address(server_name)
|
|
549
|
+
except ValueError:
|
|
550
|
+
target_ip = None
|
|
551
|
+
if target_ip is not None:
|
|
552
|
+
if any(candidate == target_ip for candidate in san.get_values_for_type(x509.IPAddress)):
|
|
553
|
+
return
|
|
554
|
+
raise ProtocolError('peer certificate does not match requested IP address')
|
|
555
|
+
dns_names = tuple(_normalized_dns_name(name) for name in san.get_values_for_type(x509.DNSName))
|
|
556
|
+
if not dns_names:
|
|
557
|
+
raise ProtocolError('peer certificate does not contain a DNS subjectAltName')
|
|
558
|
+
if any(_dnsname_match(pattern, server_name) for pattern in dns_names):
|
|
559
|
+
return
|
|
560
|
+
raise ProtocolError('peer certificate does not match requested server name')
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _verify_signature(public_key: object, signature: bytes, payload: bytes, algorithm: object | None) -> None:
|
|
564
|
+
if isinstance(public_key, ed25519.Ed25519PublicKey):
|
|
565
|
+
public_key.verify(signature, payload)
|
|
566
|
+
return
|
|
567
|
+
if isinstance(public_key, ed448.Ed448PublicKey):
|
|
568
|
+
public_key.verify(signature, payload)
|
|
569
|
+
return
|
|
570
|
+
if isinstance(public_key, rsa.RSAPublicKey):
|
|
571
|
+
if algorithm is None:
|
|
572
|
+
raise ProtocolError('RSA signature is missing a hash algorithm')
|
|
573
|
+
public_key.verify(signature, payload, asym_padding.PKCS1v15(), algorithm)
|
|
574
|
+
return
|
|
575
|
+
if isinstance(public_key, ec.EllipticCurvePublicKey):
|
|
576
|
+
if algorithm is None:
|
|
577
|
+
raise ProtocolError('EC signature is missing a hash algorithm')
|
|
578
|
+
public_key.verify(signature, payload, ec.ECDSA(algorithm))
|
|
579
|
+
return
|
|
580
|
+
raise ProtocolError('unsupported signature public key type')
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _subject_key_identifier_bytes(certificate: x509.Certificate) -> bytes:
|
|
584
|
+
try:
|
|
585
|
+
return certificate.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_KEY_IDENTIFIER).value.digest
|
|
586
|
+
except x509.ExtensionNotFound:
|
|
587
|
+
return x509.SubjectKeyIdentifier.from_public_key(certificate.public_key()).digest
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _build_policy_builder(
|
|
591
|
+
trust_roots: Sequence[x509.Certificate],
|
|
592
|
+
*,
|
|
593
|
+
moment: datetime,
|
|
594
|
+
max_chain_depth: int | None,
|
|
595
|
+
):
|
|
596
|
+
if not _HAS_X509_VERIFICATION:
|
|
597
|
+
return None
|
|
598
|
+
builder = verification.PolicyBuilder().store(verification.Store(trust_roots)).time(moment)
|
|
599
|
+
builder = builder.extension_policies(ca_policy=_WEBOOKI_CA_POLICY, ee_policy=_WEBOOKI_EE_POLICY)
|
|
600
|
+
if max_chain_depth is not None:
|
|
601
|
+
builder = builder.max_chain_depth(max_chain_depth)
|
|
602
|
+
return builder
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _verify_server_path(
|
|
606
|
+
chain: Sequence[x509.Certificate],
|
|
607
|
+
trust_roots: Sequence[x509.Certificate],
|
|
608
|
+
*,
|
|
609
|
+
server_name: str,
|
|
610
|
+
moment: datetime,
|
|
611
|
+
policy: CertificateValidationPolicy,
|
|
612
|
+
) -> tuple[x509.Certificate, ...]:
|
|
613
|
+
if not _HAS_X509_VERIFICATION:
|
|
614
|
+
return _manual_verified_chain(chain, trust_roots, server_name=server_name, moment=moment, policy=policy)
|
|
615
|
+
subject = _server_subject(server_name) if server_name else _first_subject_alt_name(chain[0])
|
|
616
|
+
builder = _build_policy_builder(trust_roots, moment=moment, max_chain_depth=policy.max_chain_depth)
|
|
617
|
+
verifier = builder.build_server_verifier(subject)
|
|
618
|
+
return tuple(verifier.verify(chain[0], list(chain[1:])))
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _verify_client_path(
|
|
622
|
+
chain: Sequence[x509.Certificate],
|
|
623
|
+
trust_roots: Sequence[x509.Certificate],
|
|
624
|
+
*,
|
|
625
|
+
moment: datetime,
|
|
626
|
+
policy: CertificateValidationPolicy,
|
|
627
|
+
) -> tuple[x509.Certificate, ...]:
|
|
628
|
+
if not _HAS_X509_VERIFICATION:
|
|
629
|
+
return _manual_verified_chain(chain, trust_roots, server_name='', moment=moment, policy=policy)
|
|
630
|
+
builder = _build_policy_builder(trust_roots, moment=moment, max_chain_depth=policy.max_chain_depth)
|
|
631
|
+
verifier = builder.build_client_verifier()
|
|
632
|
+
result = verifier.verify(chain[0], list(chain[1:]))
|
|
633
|
+
return tuple(result.chain)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _load_revocation_material(material: RevocationMaterial) -> tuple[tuple[x509.CertificateRevocationList, ...], tuple[ocsp.OCSPResponse, ...]]:
|
|
637
|
+
crls = tuple(_load_crl(item) for item in material.crls)
|
|
638
|
+
responses = tuple(_load_ocsp_response(item) for item in material.ocsp_responses)
|
|
639
|
+
return crls, responses
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _verify_crl(
|
|
643
|
+
crl: x509.CertificateRevocationList,
|
|
644
|
+
issuer: x509.Certificate,
|
|
645
|
+
*,
|
|
646
|
+
moment: datetime,
|
|
647
|
+
freshness: RevocationFreshnessPolicy,
|
|
648
|
+
) -> None:
|
|
649
|
+
if crl.issuer != issuer.subject:
|
|
650
|
+
raise ProtocolError('CRL issuer does not match certificate issuer')
|
|
651
|
+
last_update = _crl_last_update(crl)
|
|
652
|
+
next_update = _crl_next_update(crl)
|
|
653
|
+
skew = freshness.allowed_clock_skew
|
|
654
|
+
if moment + skew < last_update:
|
|
655
|
+
raise ProtocolError('CRL is not yet valid at the requested validation time')
|
|
656
|
+
if next_update is None:
|
|
657
|
+
raise ProtocolError('CRL does not contain a nextUpdate value')
|
|
658
|
+
expiry = next_update + skew
|
|
659
|
+
if freshness.crl_max_validity_window is not None:
|
|
660
|
+
expiry = min(expiry, last_update + freshness.crl_max_validity_window)
|
|
661
|
+
if moment > expiry:
|
|
662
|
+
raise ProtocolError('CRL is not valid at the requested validation time')
|
|
663
|
+
_verify_crl_signature(crl, issuer)
|
|
664
|
+
try:
|
|
665
|
+
key_usage = issuer.extensions.get_extension_for_oid(ExtensionOID.KEY_USAGE).value
|
|
666
|
+
except x509.ExtensionNotFound:
|
|
667
|
+
key_usage = None
|
|
668
|
+
if key_usage is not None and not key_usage.crl_sign:
|
|
669
|
+
raise ProtocolError('CRL issuer is not permitted to sign CRLs')
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _crl_status(
|
|
673
|
+
certificate: x509.Certificate,
|
|
674
|
+
issuer: x509.Certificate,
|
|
675
|
+
*,
|
|
676
|
+
crls: Sequence[x509.CertificateRevocationList],
|
|
677
|
+
moment: datetime,
|
|
678
|
+
freshness: RevocationFreshnessPolicy,
|
|
679
|
+
) -> str:
|
|
680
|
+
for crl in crls:
|
|
681
|
+
if crl.issuer != issuer.subject:
|
|
682
|
+
continue
|
|
683
|
+
_verify_crl(crl, issuer, moment=moment, freshness=freshness)
|
|
684
|
+
revoked = crl.get_revoked_certificate_by_serial_number(certificate.serial_number)
|
|
685
|
+
if revoked is not None:
|
|
686
|
+
return 'revoked'
|
|
687
|
+
return 'good'
|
|
688
|
+
return 'unknown'
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _ocsp_response_items(response: ocsp.OCSPResponse) -> tuple[object, ...]:
|
|
694
|
+
items = getattr(response, 'responses', None)
|
|
695
|
+
if items is not None:
|
|
696
|
+
return tuple(items)
|
|
697
|
+
if response.response_status is not ocsp.OCSPResponseStatus.SUCCESSFUL:
|
|
698
|
+
return ()
|
|
699
|
+
return (response,)
|
|
700
|
+
|
|
701
|
+
def _matching_ocsp_responses(
|
|
702
|
+
response: ocsp.OCSPResponse,
|
|
703
|
+
certificate: x509.Certificate,
|
|
704
|
+
issuer: x509.Certificate,
|
|
705
|
+
) -> tuple[ocsp.OCSPSingleResponse, ...]:
|
|
706
|
+
if response.response_status is not ocsp.OCSPResponseStatus.SUCCESSFUL:
|
|
707
|
+
return ()
|
|
708
|
+
candidates = _ocsp_response_items(response)
|
|
709
|
+
matches: list[ocsp.OCSPSingleResponse] = []
|
|
710
|
+
for single in candidates:
|
|
711
|
+
request = ocsp.OCSPRequestBuilder().add_certificate(certificate, issuer, single.hash_algorithm).build()
|
|
712
|
+
if single.serial_number != certificate.serial_number:
|
|
713
|
+
continue
|
|
714
|
+
if single.issuer_name_hash != request.issuer_name_hash:
|
|
715
|
+
continue
|
|
716
|
+
if single.issuer_key_hash != request.issuer_key_hash:
|
|
717
|
+
continue
|
|
718
|
+
matches.append(single)
|
|
719
|
+
return tuple(matches)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _is_valid_ocsp_signer(candidate: x509.Certificate, issuer: x509.Certificate, *, moment: datetime) -> bool:
|
|
723
|
+
verify_certificate_validity(candidate, moment=moment)
|
|
724
|
+
if candidate == issuer:
|
|
725
|
+
return True
|
|
726
|
+
try:
|
|
727
|
+
eku = candidate.extensions.get_extension_for_oid(ExtensionOID.EXTENDED_KEY_USAGE).value
|
|
728
|
+
except x509.ExtensionNotFound:
|
|
729
|
+
return False
|
|
730
|
+
if ExtendedKeyUsageOID.OCSP_SIGNING not in eku:
|
|
731
|
+
return False
|
|
732
|
+
if candidate.issuer != issuer.subject:
|
|
733
|
+
return False
|
|
734
|
+
try:
|
|
735
|
+
_verify_signature(
|
|
736
|
+
issuer.public_key(),
|
|
737
|
+
candidate.signature,
|
|
738
|
+
candidate.tbs_certificate_bytes,
|
|
739
|
+
candidate.signature_hash_algorithm,
|
|
740
|
+
)
|
|
741
|
+
except Exception:
|
|
742
|
+
return False
|
|
743
|
+
return True
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _resolve_ocsp_signer(
|
|
747
|
+
response: ocsp.OCSPResponse,
|
|
748
|
+
issuer: x509.Certificate,
|
|
749
|
+
trust_roots: Sequence[x509.Certificate],
|
|
750
|
+
*,
|
|
751
|
+
moment: datetime,
|
|
752
|
+
) -> x509.Certificate | None:
|
|
753
|
+
candidates: list[x509.Certificate] = []
|
|
754
|
+
candidates.extend(response.certificates)
|
|
755
|
+
candidates.append(issuer)
|
|
756
|
+
candidates.extend(trust_roots)
|
|
757
|
+
responder_name = response.responder_name
|
|
758
|
+
responder_key_hash = response.responder_key_hash
|
|
759
|
+
for candidate in candidates:
|
|
760
|
+
if responder_name is not None and candidate.subject == responder_name:
|
|
761
|
+
if _is_valid_ocsp_signer(candidate, issuer, moment=moment):
|
|
762
|
+
return candidate
|
|
763
|
+
if responder_key_hash is not None and _subject_key_identifier_bytes(candidate) == responder_key_hash:
|
|
764
|
+
if _is_valid_ocsp_signer(candidate, issuer, moment=moment):
|
|
765
|
+
return candidate
|
|
766
|
+
return None
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _ocsp_single_expiry(
|
|
770
|
+
single: ocsp.OCSPSingleResponse,
|
|
771
|
+
*,
|
|
772
|
+
response: ocsp.OCSPResponse,
|
|
773
|
+
freshness: RevocationFreshnessPolicy,
|
|
774
|
+
) -> datetime | None:
|
|
775
|
+
base = max(_ocsp_single_this_update(single), _ocsp_response_produced_at(response))
|
|
776
|
+
candidates: list[datetime] = []
|
|
777
|
+
next_update = _ocsp_single_next_update(single)
|
|
778
|
+
if next_update is not None:
|
|
779
|
+
candidates.append(next_update + freshness.allowed_clock_skew)
|
|
780
|
+
elif freshness.ocsp_max_age_without_next_update is not None:
|
|
781
|
+
candidates.append(base + freshness.ocsp_max_age_without_next_update)
|
|
782
|
+
if freshness.ocsp_max_validity_window is not None:
|
|
783
|
+
candidates.append(base + freshness.ocsp_max_validity_window)
|
|
784
|
+
if not candidates:
|
|
785
|
+
return None
|
|
786
|
+
return min(candidates)
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _ocsp_status(
|
|
790
|
+
certificate: x509.Certificate,
|
|
791
|
+
issuer: x509.Certificate,
|
|
792
|
+
*,
|
|
793
|
+
responses: Sequence[ocsp.OCSPResponse],
|
|
794
|
+
trust_roots: Sequence[x509.Certificate],
|
|
795
|
+
moment: datetime,
|
|
796
|
+
freshness: RevocationFreshnessPolicy,
|
|
797
|
+
) -> str:
|
|
798
|
+
skew = freshness.allowed_clock_skew
|
|
799
|
+
for response in responses:
|
|
800
|
+
matches = _matching_ocsp_responses(response, certificate, issuer)
|
|
801
|
+
if not matches:
|
|
802
|
+
continue
|
|
803
|
+
signer = _resolve_ocsp_signer(response, issuer, trust_roots, moment=moment)
|
|
804
|
+
if signer is None:
|
|
805
|
+
continue
|
|
806
|
+
try:
|
|
807
|
+
_verify_signature(
|
|
808
|
+
signer.public_key(),
|
|
809
|
+
response.signature,
|
|
810
|
+
response.tbs_response_bytes,
|
|
811
|
+
response.signature_hash_algorithm,
|
|
812
|
+
)
|
|
813
|
+
except Exception:
|
|
814
|
+
continue
|
|
815
|
+
produced_at = _ocsp_response_produced_at(response)
|
|
816
|
+
if produced_at > moment + skew:
|
|
817
|
+
continue
|
|
818
|
+
for single in matches:
|
|
819
|
+
if _ocsp_single_this_update(single) > moment + skew:
|
|
820
|
+
continue
|
|
821
|
+
expiry = _ocsp_single_expiry(single, response=response, freshness=freshness)
|
|
822
|
+
if expiry is not None and moment > expiry:
|
|
823
|
+
continue
|
|
824
|
+
if single.certificate_status is ocsp.OCSPCertStatus.REVOKED:
|
|
825
|
+
return 'revoked'
|
|
826
|
+
if single.certificate_status is ocsp.OCSPCertStatus.GOOD:
|
|
827
|
+
return 'good'
|
|
828
|
+
return 'unknown'
|
|
829
|
+
return 'unknown'
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _header_map(headers: Sequence[tuple[str, str]]) -> dict[str, str]:
|
|
833
|
+
result: dict[str, str] = {}
|
|
834
|
+
for key, value in headers:
|
|
835
|
+
result[key.lower()] = value
|
|
836
|
+
return result
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _parse_http_cache_headers(
|
|
840
|
+
fetched_at: datetime,
|
|
841
|
+
headers: Sequence[tuple[str, str]],
|
|
842
|
+
) -> tuple[datetime | None, bool]:
|
|
843
|
+
mapping = _header_map(headers)
|
|
844
|
+
directives = mapping.get('cache-control', '')
|
|
845
|
+
cacheable = True
|
|
846
|
+
max_age: int | None = None
|
|
847
|
+
for item in directives.split(','):
|
|
848
|
+
token = item.strip()
|
|
849
|
+
if not token:
|
|
850
|
+
continue
|
|
851
|
+
lower = token.lower()
|
|
852
|
+
if lower in {'no-store', 'no-cache'}:
|
|
853
|
+
cacheable = False
|
|
854
|
+
if '=' not in token:
|
|
855
|
+
continue
|
|
856
|
+
key, value = token.split('=', 1)
|
|
857
|
+
if key.strip().lower() != 'max-age':
|
|
858
|
+
continue
|
|
859
|
+
try:
|
|
860
|
+
max_age = max(0, int(value.strip().strip('"')))
|
|
861
|
+
except ValueError:
|
|
862
|
+
continue
|
|
863
|
+
if not cacheable:
|
|
864
|
+
return None, False
|
|
865
|
+
candidates: list[datetime] = []
|
|
866
|
+
if max_age is not None:
|
|
867
|
+
candidates.append(fetched_at + timedelta(seconds=max_age))
|
|
868
|
+
expires = mapping.get('expires')
|
|
869
|
+
if expires:
|
|
870
|
+
try:
|
|
871
|
+
expiry = parsedate_to_datetime(expires)
|
|
872
|
+
except (TypeError, ValueError, IndexError):
|
|
873
|
+
expiry = None
|
|
874
|
+
if expiry is not None:
|
|
875
|
+
candidates.append(_as_utc(expiry))
|
|
876
|
+
if not candidates:
|
|
877
|
+
return None, True
|
|
878
|
+
return min(candidates), True
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _ensure_revocation_uri_allowed(url: str, fetch_policy: RevocationFetchPolicy) -> None:
|
|
882
|
+
parsed = urlparse(url)
|
|
883
|
+
scheme = parsed.scheme.lower()
|
|
884
|
+
if not scheme:
|
|
885
|
+
raise _RevocationFetchError('revocation endpoint URI is missing a scheme')
|
|
886
|
+
if scheme not in fetch_policy.allowed_schemes:
|
|
887
|
+
raise _RevocationFetchError(f'revocation endpoint URI scheme {scheme!r} is not allowed')
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _fetch_revocation_payload(
|
|
891
|
+
url: str,
|
|
892
|
+
*,
|
|
893
|
+
fetch_policy: RevocationFetchPolicy,
|
|
894
|
+
method: str = 'GET',
|
|
895
|
+
data: bytes | None = None,
|
|
896
|
+
headers: dict[str, str] | None = None,
|
|
897
|
+
) -> _FetchedRevocationPayload:
|
|
898
|
+
_ensure_revocation_uri_allowed(url, fetch_policy)
|
|
899
|
+
request_headers = {'User-Agent': fetch_policy.user_agent}
|
|
900
|
+
if headers:
|
|
901
|
+
request_headers.update(headers)
|
|
902
|
+
request = Request(url=url, data=data, headers=request_headers, method=method)
|
|
903
|
+
try:
|
|
904
|
+
with urlopen(request, timeout=fetch_policy.timeout_seconds) as response:
|
|
905
|
+
status = getattr(response, 'status', None)
|
|
906
|
+
if status is not None and not (200 <= int(status) < 300):
|
|
907
|
+
raise _RevocationFetchError(f'revocation endpoint returned HTTP {status}')
|
|
908
|
+
body = response.read(fetch_policy.max_response_bytes + 1)
|
|
909
|
+
if len(body) > fetch_policy.max_response_bytes:
|
|
910
|
+
raise _RevocationFetchError('revocation endpoint response exceeded configured size limit')
|
|
911
|
+
fetched_at = datetime.now(timezone.utc)
|
|
912
|
+
content_type = None
|
|
913
|
+
if hasattr(response.headers, 'get_content_type'):
|
|
914
|
+
content_type = response.headers.get_content_type()
|
|
915
|
+
if content_type is None:
|
|
916
|
+
content_type = response.headers.get('Content-Type')
|
|
917
|
+
return _FetchedRevocationPayload(
|
|
918
|
+
payload=body,
|
|
919
|
+
fetched_at=fetched_at,
|
|
920
|
+
headers=tuple((key.lower(), value) for key, value in response.headers.items()),
|
|
921
|
+
content_type=content_type,
|
|
922
|
+
)
|
|
923
|
+
except HTTPError as exc:
|
|
924
|
+
raise _RevocationFetchError(f'revocation endpoint returned HTTP {exc.code}') from exc
|
|
925
|
+
except URLError as exc:
|
|
926
|
+
raise _RevocationFetchError(f'revocation endpoint fetch failed: {exc.reason}') from exc
|
|
927
|
+
except OSError as exc:
|
|
928
|
+
raise _RevocationFetchError(f'revocation endpoint fetch failed: {exc}') from exc
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def _deduplicated(values: Iterable[str]) -> tuple[str, ...]:
|
|
932
|
+
ordered: list[str] = []
|
|
933
|
+
seen: set[str] = set()
|
|
934
|
+
for value in values:
|
|
935
|
+
candidate = value.strip()
|
|
936
|
+
if not candidate or candidate in seen:
|
|
937
|
+
continue
|
|
938
|
+
seen.add(candidate)
|
|
939
|
+
ordered.append(candidate)
|
|
940
|
+
return tuple(ordered)
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _ocsp_aia_urls(certificate: x509.Certificate) -> tuple[str, ...]:
|
|
944
|
+
try:
|
|
945
|
+
extension = certificate.extensions.get_extension_for_oid(ExtensionOID.AUTHORITY_INFORMATION_ACCESS).value
|
|
946
|
+
except x509.ExtensionNotFound:
|
|
947
|
+
return ()
|
|
948
|
+
uris: list[str] = []
|
|
949
|
+
for access_description in extension:
|
|
950
|
+
if access_description.access_method != AuthorityInformationAccessOID.OCSP:
|
|
951
|
+
continue
|
|
952
|
+
location = access_description.access_location
|
|
953
|
+
if isinstance(location, x509.UniformResourceIdentifier):
|
|
954
|
+
uris.append(location.value)
|
|
955
|
+
return _deduplicated(uris)
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _crl_distribution_point_urls(certificate: x509.Certificate) -> tuple[str, ...]:
|
|
959
|
+
try:
|
|
960
|
+
extension = certificate.extensions.get_extension_for_oid(ExtensionOID.CRL_DISTRIBUTION_POINTS).value
|
|
961
|
+
except x509.ExtensionNotFound:
|
|
962
|
+
return ()
|
|
963
|
+
uris: list[str] = []
|
|
964
|
+
for point in extension:
|
|
965
|
+
if point.full_name is None:
|
|
966
|
+
continue
|
|
967
|
+
for name in point.full_name:
|
|
968
|
+
if isinstance(name, x509.UniformResourceIdentifier):
|
|
969
|
+
uris.append(name.value)
|
|
970
|
+
return _deduplicated(uris)
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def _ocsp_request_bytes(certificate: x509.Certificate, issuer: x509.Certificate) -> bytes:
|
|
974
|
+
request = ocsp.OCSPRequestBuilder().add_certificate(certificate, issuer, hashes.SHA1()).build()
|
|
975
|
+
return request.public_bytes(serialization.Encoding.DER)
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def _ocsp_cache_key(url: str, request_bytes: bytes) -> tuple[str, str, str]:
|
|
979
|
+
fingerprint = hashes.Hash(hashes.SHA256())
|
|
980
|
+
fingerprint.update(request_bytes)
|
|
981
|
+
return 'ocsp', url, fingerprint.finalize().hex()
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def _crl_cache_key(url: str) -> tuple[str, str, str]:
|
|
985
|
+
return 'crl', url, ''
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _ocsp_cache_expiry(
|
|
989
|
+
response: ocsp.OCSPResponse,
|
|
990
|
+
certificate: x509.Certificate,
|
|
991
|
+
issuer: x509.Certificate,
|
|
992
|
+
*,
|
|
993
|
+
fetched_at: datetime,
|
|
994
|
+
headers: Sequence[tuple[str, str]],
|
|
995
|
+
freshness: RevocationFreshnessPolicy,
|
|
996
|
+
) -> datetime | None:
|
|
997
|
+
if response.response_status is not ocsp.OCSPResponseStatus.SUCCESSFUL:
|
|
998
|
+
return None
|
|
999
|
+
matches = _matching_ocsp_responses(response, certificate, issuer)
|
|
1000
|
+
if not matches:
|
|
1001
|
+
return None
|
|
1002
|
+
header_expiry, cacheable = _parse_http_cache_headers(fetched_at, headers)
|
|
1003
|
+
if not cacheable:
|
|
1004
|
+
return None
|
|
1005
|
+
candidates: list[datetime] = []
|
|
1006
|
+
if header_expiry is not None:
|
|
1007
|
+
candidates.append(header_expiry)
|
|
1008
|
+
for single in matches:
|
|
1009
|
+
expiry = _ocsp_single_expiry(single, response=response, freshness=freshness)
|
|
1010
|
+
if expiry is not None:
|
|
1011
|
+
candidates.append(expiry)
|
|
1012
|
+
if not candidates:
|
|
1013
|
+
return None
|
|
1014
|
+
return min(candidates)
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def _crl_cache_expiry(
|
|
1018
|
+
crl: x509.CertificateRevocationList,
|
|
1019
|
+
*,
|
|
1020
|
+
fetched_at: datetime,
|
|
1021
|
+
headers: Sequence[tuple[str, str]],
|
|
1022
|
+
freshness: RevocationFreshnessPolicy,
|
|
1023
|
+
) -> datetime | None:
|
|
1024
|
+
header_expiry, cacheable = _parse_http_cache_headers(fetched_at, headers)
|
|
1025
|
+
if not cacheable:
|
|
1026
|
+
return None
|
|
1027
|
+
candidates: list[datetime] = []
|
|
1028
|
+
if header_expiry is not None:
|
|
1029
|
+
candidates.append(header_expiry)
|
|
1030
|
+
next_update = _crl_next_update(crl)
|
|
1031
|
+
if next_update is not None:
|
|
1032
|
+
candidates.append(next_update + freshness.allowed_clock_skew)
|
|
1033
|
+
if freshness.crl_max_validity_window is not None:
|
|
1034
|
+
candidates.append(_crl_last_update(crl) + freshness.crl_max_validity_window)
|
|
1035
|
+
if not candidates:
|
|
1036
|
+
return None
|
|
1037
|
+
return min(candidates)
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def _fetch_online_revocation_material(
|
|
1041
|
+
certificate: x509.Certificate,
|
|
1042
|
+
issuer: x509.Certificate,
|
|
1043
|
+
*,
|
|
1044
|
+
fetch_policy: RevocationFetchPolicy,
|
|
1045
|
+
moment: datetime,
|
|
1046
|
+
) -> _FetchedRevocationMaterial:
|
|
1047
|
+
fetched_crls: list[x509.CertificateRevocationList] = []
|
|
1048
|
+
fetched_responses: list[ocsp.OCSPResponse] = []
|
|
1049
|
+
errors: list[str] = []
|
|
1050
|
+
cache = fetch_policy.cache
|
|
1051
|
+
freshness = fetch_policy.freshness
|
|
1052
|
+
|
|
1053
|
+
if fetch_policy.enable_ocsp_aia:
|
|
1054
|
+
request_bytes = _ocsp_request_bytes(certificate, issuer)
|
|
1055
|
+
for url in _ocsp_aia_urls(certificate):
|
|
1056
|
+
kind, endpoint, fingerprint = _ocsp_cache_key(url, request_bytes)
|
|
1057
|
+
cached = cache.get(kind, endpoint, fingerprint, moment=moment) if cache is not None else None
|
|
1058
|
+
if cached is not None:
|
|
1059
|
+
try:
|
|
1060
|
+
fetched_responses.append(_load_ocsp_response(cached.payload))
|
|
1061
|
+
continue
|
|
1062
|
+
except ValueError:
|
|
1063
|
+
if cache is not None:
|
|
1064
|
+
cache.delete(kind, endpoint, fingerprint)
|
|
1065
|
+
try:
|
|
1066
|
+
payload = _fetch_revocation_payload(
|
|
1067
|
+
url,
|
|
1068
|
+
fetch_policy=fetch_policy,
|
|
1069
|
+
method='POST',
|
|
1070
|
+
data=request_bytes,
|
|
1071
|
+
headers={
|
|
1072
|
+
'Accept': 'application/ocsp-response',
|
|
1073
|
+
'Content-Type': 'application/ocsp-request',
|
|
1074
|
+
},
|
|
1075
|
+
)
|
|
1076
|
+
response = _load_ocsp_response(payload.payload)
|
|
1077
|
+
fetched_responses.append(response)
|
|
1078
|
+
expiry = _ocsp_cache_expiry(
|
|
1079
|
+
response,
|
|
1080
|
+
certificate,
|
|
1081
|
+
issuer,
|
|
1082
|
+
fetched_at=payload.fetched_at,
|
|
1083
|
+
headers=payload.headers,
|
|
1084
|
+
freshness=freshness,
|
|
1085
|
+
)
|
|
1086
|
+
if cache is not None and expiry is not None:
|
|
1087
|
+
cache.put(
|
|
1088
|
+
kind,
|
|
1089
|
+
endpoint,
|
|
1090
|
+
fingerprint,
|
|
1091
|
+
RevocationCacheEntry(
|
|
1092
|
+
payload=payload.payload,
|
|
1093
|
+
fetched_at=payload.fetched_at,
|
|
1094
|
+
expires_at=expiry,
|
|
1095
|
+
content_type=payload.content_type,
|
|
1096
|
+
),
|
|
1097
|
+
)
|
|
1098
|
+
except (ValueError, _RevocationFetchError) as exc:
|
|
1099
|
+
errors.append(f'OCSP {url}: {exc}')
|
|
1100
|
+
|
|
1101
|
+
if fetch_policy.enable_crl_distribution_points:
|
|
1102
|
+
for url in _crl_distribution_point_urls(certificate):
|
|
1103
|
+
kind, endpoint, fingerprint = _crl_cache_key(url)
|
|
1104
|
+
cached = cache.get(kind, endpoint, fingerprint, moment=moment) if cache is not None else None
|
|
1105
|
+
if cached is not None:
|
|
1106
|
+
try:
|
|
1107
|
+
fetched_crls.append(_load_crl(cached.payload))
|
|
1108
|
+
continue
|
|
1109
|
+
except ValueError:
|
|
1110
|
+
if cache is not None:
|
|
1111
|
+
cache.delete(kind, endpoint, fingerprint)
|
|
1112
|
+
try:
|
|
1113
|
+
payload = _fetch_revocation_payload(url, fetch_policy=fetch_policy)
|
|
1114
|
+
crl = _load_crl(payload.payload)
|
|
1115
|
+
fetched_crls.append(crl)
|
|
1116
|
+
expiry = _crl_cache_expiry(crl, fetched_at=payload.fetched_at, headers=payload.headers, freshness=freshness)
|
|
1117
|
+
if cache is not None and expiry is not None:
|
|
1118
|
+
cache.put(
|
|
1119
|
+
kind,
|
|
1120
|
+
endpoint,
|
|
1121
|
+
fingerprint,
|
|
1122
|
+
RevocationCacheEntry(
|
|
1123
|
+
payload=payload.payload,
|
|
1124
|
+
fetched_at=payload.fetched_at,
|
|
1125
|
+
expires_at=expiry,
|
|
1126
|
+
content_type=payload.content_type,
|
|
1127
|
+
),
|
|
1128
|
+
)
|
|
1129
|
+
except (ValueError, _RevocationFetchError) as exc:
|
|
1130
|
+
errors.append(f'CRL {url}: {exc}')
|
|
1131
|
+
|
|
1132
|
+
return _FetchedRevocationMaterial(
|
|
1133
|
+
crls=tuple(fetched_crls),
|
|
1134
|
+
ocsp_responses=tuple(fetched_responses),
|
|
1135
|
+
errors=tuple(errors),
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def _enforce_revocation_policy(
|
|
1140
|
+
chain: Sequence[x509.Certificate],
|
|
1141
|
+
trust_roots: Sequence[x509.Certificate],
|
|
1142
|
+
*,
|
|
1143
|
+
policy: CertificateValidationPolicy,
|
|
1144
|
+
moment: datetime,
|
|
1145
|
+
) -> None:
|
|
1146
|
+
if policy.revocation_mode is RevocationMode.OFF:
|
|
1147
|
+
return
|
|
1148
|
+
crls, responses = _load_revocation_material(policy.revocation_material)
|
|
1149
|
+
fetch_policy = policy.revocation_fetch_policy
|
|
1150
|
+
freshness = fetch_policy.freshness if fetch_policy is not None else RevocationFreshnessPolicy()
|
|
1151
|
+
if (
|
|
1152
|
+
not crls
|
|
1153
|
+
and not responses
|
|
1154
|
+
and fetch_policy is None
|
|
1155
|
+
and policy.revocation_mode is RevocationMode.REQUIRE
|
|
1156
|
+
):
|
|
1157
|
+
raise ProtocolError('revocation checking was required but no revocation evidence or fetch policy was provided')
|
|
1158
|
+
|
|
1159
|
+
for index in range(len(chain) - 1):
|
|
1160
|
+
certificate = chain[index]
|
|
1161
|
+
issuer = chain[index + 1]
|
|
1162
|
+
status = _ocsp_status(
|
|
1163
|
+
certificate,
|
|
1164
|
+
issuer,
|
|
1165
|
+
responses=responses,
|
|
1166
|
+
trust_roots=trust_roots,
|
|
1167
|
+
moment=moment,
|
|
1168
|
+
freshness=freshness,
|
|
1169
|
+
)
|
|
1170
|
+
if status == 'good':
|
|
1171
|
+
continue
|
|
1172
|
+
if status == 'revoked':
|
|
1173
|
+
raise ProtocolError('peer certificate has been revoked')
|
|
1174
|
+
status = _crl_status(
|
|
1175
|
+
certificate,
|
|
1176
|
+
issuer,
|
|
1177
|
+
crls=crls,
|
|
1178
|
+
moment=moment,
|
|
1179
|
+
freshness=freshness,
|
|
1180
|
+
)
|
|
1181
|
+
if status == 'good':
|
|
1182
|
+
continue
|
|
1183
|
+
if status == 'revoked':
|
|
1184
|
+
raise ProtocolError('peer certificate has been revoked')
|
|
1185
|
+
|
|
1186
|
+
online_errors: tuple[str, ...] = ()
|
|
1187
|
+
if fetch_policy is not None:
|
|
1188
|
+
fetched = _fetch_online_revocation_material(
|
|
1189
|
+
certificate,
|
|
1190
|
+
issuer,
|
|
1191
|
+
fetch_policy=fetch_policy,
|
|
1192
|
+
moment=moment,
|
|
1193
|
+
)
|
|
1194
|
+
online_errors = fetched.errors
|
|
1195
|
+
if fetched.ocsp_responses:
|
|
1196
|
+
status = _ocsp_status(
|
|
1197
|
+
certificate,
|
|
1198
|
+
issuer,
|
|
1199
|
+
responses=fetched.ocsp_responses,
|
|
1200
|
+
trust_roots=trust_roots,
|
|
1201
|
+
moment=moment,
|
|
1202
|
+
freshness=freshness,
|
|
1203
|
+
)
|
|
1204
|
+
if status == 'good':
|
|
1205
|
+
continue
|
|
1206
|
+
if status == 'revoked':
|
|
1207
|
+
raise ProtocolError('peer certificate has been revoked')
|
|
1208
|
+
if fetched.crls:
|
|
1209
|
+
status = _crl_status(
|
|
1210
|
+
certificate,
|
|
1211
|
+
issuer,
|
|
1212
|
+
crls=fetched.crls,
|
|
1213
|
+
moment=moment,
|
|
1214
|
+
freshness=freshness,
|
|
1215
|
+
)
|
|
1216
|
+
if status == 'good':
|
|
1217
|
+
continue
|
|
1218
|
+
if status == 'revoked':
|
|
1219
|
+
raise ProtocolError('peer certificate has been revoked')
|
|
1220
|
+
|
|
1221
|
+
if policy.revocation_mode is RevocationMode.REQUIRE:
|
|
1222
|
+
detail = ''
|
|
1223
|
+
if online_errors:
|
|
1224
|
+
detail = f': {online_errors[0]}'
|
|
1225
|
+
raise ProtocolError(f'revocation status could not be established for the certificate chain{detail}')
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def verify_certificate_chain(
|
|
1229
|
+
chain_pems: Iterable[bytes],
|
|
1230
|
+
trust_roots_pems: Iterable[bytes],
|
|
1231
|
+
*,
|
|
1232
|
+
server_name: str = '',
|
|
1233
|
+
moment: datetime | None = None,
|
|
1234
|
+
policy: CertificateValidationPolicy | None = None,
|
|
1235
|
+
) -> x509.Certificate:
|
|
1236
|
+
chain = load_pem_certificates(chain_pems)
|
|
1237
|
+
if not chain:
|
|
1238
|
+
raise ProtocolError('peer did not provide a certificate chain')
|
|
1239
|
+
trust_roots = load_pem_certificates(trust_roots_pems)
|
|
1240
|
+
if not trust_roots:
|
|
1241
|
+
raise ProtocolError('certificate verification requires at least one trusted root or pinned certificate')
|
|
1242
|
+
validation_policy = policy or CertificateValidationPolicy()
|
|
1243
|
+
validation_time = _as_utc(moment)
|
|
1244
|
+
|
|
1245
|
+
try:
|
|
1246
|
+
if validation_policy.purpose is CertificatePurpose.CLIENT_AUTH:
|
|
1247
|
+
verified_chain = _verify_client_path(chain, trust_roots, moment=validation_time, policy=validation_policy)
|
|
1248
|
+
else:
|
|
1249
|
+
verified_chain = _verify_server_path(
|
|
1250
|
+
chain,
|
|
1251
|
+
trust_roots,
|
|
1252
|
+
server_name=server_name,
|
|
1253
|
+
moment=validation_time,
|
|
1254
|
+
policy=validation_policy,
|
|
1255
|
+
)
|
|
1256
|
+
except Exception as exc:
|
|
1257
|
+
if _HAS_X509_VERIFICATION and isinstance(exc, verification.VerificationError):
|
|
1258
|
+
raise ProtocolError(f'peer certificate chain verification failed: {exc}') from exc
|
|
1259
|
+
if isinstance(exc, ProtocolError):
|
|
1260
|
+
raise
|
|
1261
|
+
raise ProtocolError(f'peer certificate chain verification failed: {exc}') from exc
|
|
1262
|
+
except ValueError as exc:
|
|
1263
|
+
raise ProtocolError(f'peer certificate chain verification failed: {exc}') from exc
|
|
1264
|
+
|
|
1265
|
+
_enforce_revocation_policy(verified_chain, trust_roots, policy=validation_policy, moment=validation_time)
|
|
1266
|
+
return verified_chain[0]
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
__all__ = [
|
|
1270
|
+
'CertificatePurpose',
|
|
1271
|
+
'CertificateValidationPolicy',
|
|
1272
|
+
'RevocationCache',
|
|
1273
|
+
'RevocationCacheEntry',
|
|
1274
|
+
'RevocationFetchPolicy',
|
|
1275
|
+
'RevocationFreshnessPolicy',
|
|
1276
|
+
'RevocationMaterial',
|
|
1277
|
+
'RevocationMode',
|
|
1278
|
+
'VerifiedCertificatePath',
|
|
1279
|
+
'load_pem_certificates',
|
|
1280
|
+
'load_crls_from_file',
|
|
1281
|
+
'verify_certificate_chain',
|
|
1282
|
+
'verify_certificate_hostname',
|
|
1283
|
+
'verify_certificate_validity',
|
|
1284
|
+
]
|