from __future__ import annotations
import hashlib
import ssl
import sys
import threading
import typing
import warnings
import weakref
if typing.TYPE_CHECKING:
from typing import Any
_MAX_CA_CERTS = 256
_MAX_CA_TOTAL_BYTES = 2 * 1024 * 1024
class _SSLContextSnapshot:
__slots__ = (
"verify_mode",
"check_hostname",
"ca_certs_der",
"min_version",
"max_version",
"class_name",
"cipher_fingerprint",
"options_fingerprint",
"has_client_cert",
)
def __init__(
self,
*,
verify_mode: int,
check_hostname: bool,
ca_certs_der: list[bytes],
min_version: int | None,
max_version: int | None,
class_name: str,
cipher_fingerprint: str | None = None,
options_fingerprint: str | None = None,
has_client_cert: bool = False,
) -> None:
self.verify_mode = verify_mode
self.check_hostname = check_hostname
self.ca_certs_der = ca_certs_der
self.min_version = min_version
self.max_version = max_version
self.class_name = class_name
self.cipher_fingerprint = cipher_fingerprint
self.options_fingerprint = options_fingerprint
self.has_client_cert = has_client_cert
def fingerprint(self) -> str:
h = hashlib.sha256()
h.update(f"verify_mode={self.verify_mode};".encode())
h.update(f"check_hostname={int(self.check_hostname)};".encode())
h.update(f"min_version={self.min_version};".encode())
h.update(f"max_version={self.max_version};".encode())
h.update(f"class={self.class_name};".encode())
h.update(f"has_client_cert={int(self.has_client_cert)};".encode())
for cert in self.ca_certs_der:
h.update(b"\x00")
h.update(hashlib.sha256(cert).digest())
h.update(b"|ciphers:")
h.update((self.cipher_fingerprint or "").encode())
h.update(b"|options:")
h.update((self.options_fingerprint or "").encode())
return h.hexdigest()
def __repr__(self) -> str:
return (
f"_SSLContextSnapshot("
f"verify_mode={self.verify_mode}, "
f"check_hostname={self.check_hostname}, "
f"ca_count={len(self.ca_certs_der)}, "
f"min_version={self.min_version}, "
f"max_version={self.max_version}, "
f"class_name={self.class_name!r}, "
f"has_client_cert={self.has_client_cert})"
)
def _cipher_fingerprint(ctx: ssl.SSLContext) -> str | None:
try:
ciphers = ctx.get_ciphers()
except (ssl.SSLError, NotImplementedError):
return None
if not ciphers:
return ""
names = sorted(c["name"] for c in ciphers)
return hashlib.sha256("|".join(names).encode()).hexdigest()
_DEFAULT_CIPHER_FINGERPRINT = _cipher_fingerprint(ssl.create_default_context())
def _options_fingerprint(ctx: ssl.SSLContext) -> str | None:
options = getattr(ctx, "options", None)
if options is None:
return None
return hashlib.sha256(repr(int(options)).encode()).hexdigest()
def _detect_client_cert(ctx: ssl.SSLContext) -> bool:
return False
def snapshot_context(ctx: ssl.SSLContext) -> _SSLContextSnapshot:
verify_mode = ctx.verify_mode
check_hostname = ctx.check_hostname
ca_der: list[bytes] = []
if hasattr(ctx, "get_ca_certs") and callable(ctx.get_ca_certs):
try:
raw_certs = ctx.get_ca_certs(binary_form=True)
except NotImplementedError:
raw_certs = []
for cert in raw_certs:
if len(ca_der) >= _MAX_CA_CERTS:
raise ValueError(
f"CA certificate count exceeds {_MAX_CA_CERTS}"
)
ca_der.append(bytes(cert))
total = sum(len(c) for c in ca_der)
if total > _MAX_CA_TOTAL_BYTES:
raise ValueError(
f"CA certificate total size ({total} bytes) exceeds "
f"{_MAX_CA_TOTAL_BYTES} limit"
)
min_ver = _extract_version(ctx, "minimum_version")
max_ver = _extract_version(ctx, "maximum_version")
class_name = type(ctx).__name__
return _SSLContextSnapshot(
verify_mode=verify_mode,
check_hostname=check_hostname,
ca_certs_der=ca_der,
min_version=min_ver,
max_version=max_ver,
class_name=class_name,
cipher_fingerprint=_cipher_fingerprint(ctx),
options_fingerprint=_options_fingerprint(ctx),
has_client_cert=_detect_client_cert(ctx),
)
def _extract_version(ctx: ssl.SSLContext, attr: str) -> int | None:
val = getattr(ctx, attr, None)
if val is None:
return None
if isinstance(val, int):
return val
return None
class Classification:
EXACTLY_REPRESENTABLE = "exactly_representable"
REPRESENTABLE_WITH_DEFAULTS = "representable_with_known_defaults"
UNREPRESENTABLE = "unrepresentable"
def _classify_context(
ctx: ssl.SSLContext,
snapshot: _SSLContextSnapshot | None = None,
) -> str:
if snapshot is None:
snapshot = snapshot_context(ctx)
_TLS_1_2_WIRE = 771
_TLS_1_3_WIRE = 772
if snapshot.min_version is not None:
if snapshot.min_version > 0 and snapshot.min_version < _TLS_1_2_WIRE:
return Classification.UNREPRESENTABLE
if snapshot.max_version is not None:
if snapshot.max_version > _TLS_1_3_WIRE:
return Classification.UNREPRESENTABLE
if snapshot.verify_mode == ssl.CERT_NONE:
if snapshot.check_hostname:
return Classification.UNREPRESENTABLE
return Classification.REPRESENTABLE_WITH_DEFAULTS
if snapshot.verify_mode != ssl.CERT_REQUIRED:
return Classification.UNREPRESENTABLE
if snapshot.cipher_fingerprint != _DEFAULT_CIPHER_FINGERPRINT:
return Classification.UNREPRESENTABLE
if hasattr(ctx, "options"):
options = ctx.options
_BLOCKED_OPTIONS = 0
for name in (
"OP_PRIORITIZE_CHACHA",
):
val = getattr(ssl, name, 0)
if val:
_BLOCKED_OPTIONS |= val
if _BLOCKED_OPTIONS and (options & _BLOCKED_OPTIONS):
return Classification.UNREPRESENTABLE
if not _eggfetch_ssl_registry.is_eggfetch_context(ctx):
if snapshot.class_name != "SSLContext":
return Classification.UNREPRESENTABLE
if snapshot.ca_certs_der:
return Classification.EXACTLY_REPRESENTABLE
if snapshot.class_name == "SSLContext":
return Classification.EXACTLY_REPRESENTABLE
return Classification.UNREPRESENTABLE
class _EggfetchSSLRegistry:
def __init__(self) -> None:
self._lock = threading.Lock()
self._entries: weakref.WeakKeyDictionary[
ssl.SSLContext, dict[str, Any]
] = weakref.WeakKeyDictionary()
def register(
self,
ctx: ssl.SSLContext,
*,
cert_path: str | None = None,
key_path: str | None = None,
verify: bool | str = True,
trust_env: bool = True,
passthrough: bool = False,
) -> None:
if passthrough:
stored_verify: bool | str = True
stored_cert_path: str | None = None
stored_key_path: str | None = None
else:
stored_verify = verify
stored_cert_path = cert_path
stored_key_path = key_path
meta = {
"cert_path": stored_cert_path,
"key_path": stored_key_path,
"verify": stored_verify,
"trust_env": trust_env,
"passthrough": passthrough,
"fingerprint": snapshot_context(ctx).fingerprint(),
}
with self._lock:
self._entries[ctx] = meta
def get(self, ctx: ssl.SSLContext) -> dict[str, Any] | None:
with self._lock:
meta = self._entries.get(ctx)
if meta is None:
return None
if meta.get("passthrough"):
return {"passthrough": True}
current_fp = snapshot_context(ctx).fingerprint()
if current_fp != meta["fingerprint"]:
del self._entries[ctx]
return None
return dict(meta)
def is_eggfetch_context(self, ctx: ssl.SSLContext) -> bool:
return self.get(ctx) is not None
def is_passthrough(self, ctx: ssl.SSLContext) -> bool:
with self._lock:
meta = self._entries.get(ctx)
if meta is None:
return False
return bool(meta.get("passthrough"))
_eggfetch_ssl_registry = _EggfetchSSLRegistry()
def context_to_eggfetch_kwargs(
ctx: ssl.SSLContext,
) -> dict[str, Any]:
meta = _eggfetch_ssl_registry.get(ctx)
if meta is not None:
if meta.get("passthrough"):
pass
else:
kwargs: dict[str, Any] = {}
if meta["verify"] is not True:
kwargs["verify"] = meta["verify"]
if meta["cert_path"] is not None:
kwargs["cert"] = meta["cert_path"]
if meta["trust_env"] is not True:
kwargs["trust_env"] = meta["trust_env"]
return kwargs
snapshot = snapshot_context(ctx)
classification = _classify_context(ctx, snapshot)
if classification == Classification.UNREPRESENTABLE:
raise TypeError(
"eggfetch cannot safely translate this ssl.SSLContext. "
"Use eggfetch.compat.httpx.create_ssl_context() to create "
"a context that eggfetch can faithfully represent, or pass "
"verify/cert kwargs directly."
)
kwargs = {}
if snapshot.verify_mode == ssl.CERT_NONE:
kwargs["verify"] = False
elif snapshot.ca_certs_der:
kwargs["verify"] = snapshot.ca_certs_der
else:
kwargs["verify"] = True
return kwargs