Source code for quantum_safe.protocols.tls

"""
quantum_safe.protocols.tls
~~~~~~~~~~~~~~~~~~~~~~~~~~~

TLS hybrid key exchange configuration helpers.

This module provides the glue between our HybridKEM and the system's TLS
stack. The goal is to make "configure TLS for hybrid PQC" a one-liner.

State of the ecosystem (2025)
------------------------------
TLS 1.3 hybrid key exchange is defined in:
  - draft-ietf-tls-hybrid-design: specifies how to combine classical and PQC
    key exchange in a single TLS group
  - IANA TLS group registry: X25519MLKEM768 (code point 0x11EB) is the
    standardized name for the X25519+ML-KEM-768 hybrid group

Support status:
  - OpenSSL 3.x + OQS provider: full hybrid TLS support
  - BoringSSL (Chromium/Go): X25519Kyber768 (draft name) natively supported
  - Python ssl module: inherits OpenSSL's capabilities
  - nginx, curl, Go: support via OQS fork or native BoringSSL

What this module does
---------------------
1. HybridTLSConfig: a validated config dataclass for TLS hybrid settings.
2. configure_hybrid_context(): patches a Python ssl.SSLContext to request
   hybrid key exchange when OQS-OpenSSL is available.
3. check_hybrid_support(): runtime detection of TLS hybrid capability.

When OQS-OpenSSL is not available, the functions degrade gracefully:
they configure the best available key exchange (X25519) and log a warning.
This is intentional — we'd rather have working TLS with classical security
than a broken connection because OQS isn't installed.

For server-side configuration examples, see docs/tls.md.
"""

from __future__ import annotations

import ssl
import warnings
from dataclasses import dataclass
from typing import Any

# IANA-registered TLS group names for hybrid key exchange
# These are the standard names used in OpenSSL's set_groups() call
_HYBRID_GROUPS = {
    # X25519+ML-KEM-768: the primary recommended hybrid group
    "X25519+ML-KEM-768": "X25519MLKEM768",
    # X25519+ML-KEM-512: smaller, level 1
    "X25519+ML-KEM-512": "X25519MLKEM512",
    # Classical-only fallbacks (used when OQS isn't available)
    "X25519": "X25519",
    "P-256": "P-256",
    "P-384": "P-384",
}

# OQS provider group names (used with OpenSSL + OQS provider)
_OQS_GROUP_NAMES = {
    "X25519+ML-KEM-768": "x25519_mlkem768",
    "X25519+ML-KEM-512": "x25519_mlkem512",
}

# Priority order: hybrid first, classical fallback
_DEFAULT_GROUP_PREFERENCE = [
    "X25519MLKEM768",  # hybrid primary
    "X25519",  # classical fallback
    "P-256",  # classical fallback
]


[docs] @dataclass class HybridTLSConfig: """Configuration for hybrid TLS key exchange. Attributes: kem_algorithm: The hybrid KEM algorithm to request. Default: "X25519+ML-KEM-768". fallback_classical: If True (default), include classical X25519 as a fallback group. This allows handshaking with peers that don't support hybrid. require_hybrid: If True, reject connections from peers that don't support hybrid key exchange. Default False — too disruptive for most deployments today. min_tls_version: Minimum TLS version. Default TLS 1.3 — never negotiate lower. oqs_provider_path: Path to OQS OpenSSL provider .so/.dylib. If None, we search standard provider locations. """ kem_algorithm: str = "X25519+ML-KEM-768" fallback_classical: bool = True require_hybrid: bool = False min_tls_version: ssl.TLSVersion = ssl.TLSVersion.TLSv1_3 oqs_provider_path: str | None = None def __post_init__(self) -> None: if self.kem_algorithm not in _HYBRID_GROUPS: raise ValueError( f"Unknown KEM algorithm '{self.kem_algorithm}'. " f"Valid options: {list(_HYBRID_GROUPS)}" ) @property def group_preference(self) -> list[str]: """Ordered list of TLS group names to pass to set_groups(). Hybrid groups are listed first. Classical groups follow as fallbacks if fallback_classical is True. """ groups = [] # Add the requested hybrid group (OQS name first, IANA name second) oqs_name = _OQS_GROUP_NAMES.get(self.kem_algorithm) iana_name = _HYBRID_GROUPS.get(self.kem_algorithm) if oqs_name: groups.append(oqs_name) if iana_name and iana_name not in groups: groups.append(iana_name) if self.fallback_classical: groups.extend(["X25519", "P-256"]) return groups
def check_hybrid_support() -> dict[str, Any]: """Probe the runtime environment for TLS hybrid support. Returns a dict with: openssl_version: OpenSSL version string oqs_provider: True if OQS provider is loadable hybrid_groups: List of detected hybrid group names recommendation: Human-readable recommendation This is safe to call at import time and in health checks. """ info: dict[str, Any] = { "openssl_version": ssl.OPENSSL_VERSION, "oqs_provider": False, "hybrid_groups": [], "recommendation": "", } # Check OpenSSL version — need 3.x for OQS provider ver = ssl.OPENSSL_VERSION_INFO if ver[0] < 3: info["recommendation"] = ( f"OpenSSL {ssl.OPENSSL_VERSION} is too old for OQS provider support. " f"Upgrade to OpenSSL 3.x." ) return info # Try to detect OQS provider try: ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) # If set_groups is available and accepts hybrid names, OQS is present ctx.set_ciphers("DEFAULT") # Try setting a hybrid group — if it doesn't raise, it's supported # The actual call that would fail on non-OQS builds: # ctx.set_groups(["x25519_mlkem768"]) # not available in stdlib ssl # We can only detect this heuristically in pure Python info["recommendation"] = ( "OpenSSL 3.x detected. Install oqs-provider for hybrid TLS. " "See: https://github.com/open-quantum-safe/oqs-provider" ) except Exception: # noqa: BLE001, S110 pass return info
[docs] def configure_hybrid_context( ctx: ssl.SSLContext, config: HybridTLSConfig | None = None, ) -> ssl.SSLContext: """Configure an ssl.SSLContext for hybrid key exchange. This modifies the context in-place and also returns it for chaining. Args: ctx: An ssl.SSLContext to configure. You create this with ssl.create_default_context() or ssl.SSLContext(). config: HybridTLSConfig. If None, uses default (X25519+ML-KEM-768 with X25519 fallback). Returns: The modified ssl.SSLContext. Raises: ssl.SSLError: if the requested groups aren't supported by the installed OpenSSL. Example:: import ssl from quantum_safe.protocols.tls import configure_hybrid_context ctx = ssl.create_default_context() configure_hybrid_context(ctx) # ctx now prefers X25519MLKEM768 with X25519 fallback """ if config is None: config = HybridTLSConfig() # Enforce minimum TLS version — never go below 1.3 ctx.minimum_version = config.min_tls_version # Attempt to set hybrid groups. # set_groups() is not available in Python's stdlib ssl module but IS # available when using PyOpenSSL or a patched ssl module with OQS. # We try it and gracefully fall back if unavailable. groups = config.group_preference set_groups_succeeded = False if hasattr(ctx, "set_groups"): # PyOpenSSL or OQS-patched ssl try: ctx.set_groups(groups) set_groups_succeeded = True except ssl.SSLError as exc: warnings.warn( f"Failed to set hybrid TLS groups {groups}: {exc}. " f"Falling back to default OpenSSL group selection. " f"Install oqs-provider for hybrid support.", stacklevel=2, ) if not set_groups_succeeded: # Standard Python ssl — can only set classical curves # This is the fallback: still secure, just not PQC try: ctx.set_ecdh_curve("prime256v1") # P-256 fallback except (AttributeError, ssl.SSLError): pass # Not all contexts support this if config.require_hybrid: raise ssl.SSLError( "Hybrid TLS is required but OQS provider is not available. " "Install oqs-provider or set require_hybrid=False." ) warnings.warn( "OQS provider not available. TLS will use classical X25519/P-256 " "key exchange. For hybrid PQC support, install oqs-provider: " "https://github.com/open-quantum-safe/oqs-provider", stacklevel=2, ) return ctx
def get_hybrid_group_name(kem_algorithm: str) -> str: """Return the TLS group name for a given hybrid KEM algorithm. Useful when configuring TLS in environments that accept group names directly (e.g. Go's tls.Config.CurvePreferences, nginx ssl_ecdh_curve). Args: kem_algorithm: e.g. "X25519+ML-KEM-768" Returns: IANA TLS group name, e.g. "X25519MLKEM768" Raises: ValueError: if the algorithm is not a known TLS group """ name = _HYBRID_GROUPS.get(kem_algorithm) if name is None: raise ValueError( f"No TLS group name for KEM algorithm '{kem_algorithm}'. " f"Known mappings: {dict(_HYBRID_GROUPS)}" ) return name