From a186bf5c0047b9c0bb1fd392aadf7072eb2cde17 Mon Sep 17 00:00:00 2001 From: Gijs Molenaar Date: Mon, 6 Jul 2026 21:10:46 +0200 Subject: [PATCH 1/9] fix(tls): allow TLS 1.2 for S7-1500 PLCs (#760) The TLS context forced minimum_version to TLS 1.3, but S7-1500 PLCs with firmware < V3.0 (e.g. 1512SP-1 FW V4.1.2) only support TLS 1.2. The ClientHello offered only TLS 1.3 cipher suites and versions, so the PLC rejected the connection with a TCP RST. Lower the minimum to TLS 1.2 so Python's ssl module auto-negotiates the highest mutually-supported version. Also surface the CreateObject return value: when non-zero the PLC is rejecting the session (typically because TLS is required), but the old code silently fell through to the misleading "V1-initial SessionKey" warning. Co-Authored-By: Claude Opus 4.6 (1M context) --- s7/_s7commplus_async_client.py | 5 ++++- s7/codec.py | 8 ++++---- s7/connection.py | 10 ++++++---- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/s7/_s7commplus_async_client.py b/s7/_s7commplus_async_client.py index 19eaaab6..48d0e650 100644 --- a/s7/_s7commplus_async_client.py +++ b/s7/_s7commplus_async_client.py @@ -821,13 +821,16 @@ async def _create_session(self) -> None: # Parse response body: ReturnValue(VLQ) + ObjectIdCount + ObjectIds(VLQ). # The usable session id is ObjectIds[0] (NOT the header SessionId field). body = response[14:] - object_ids, obj_end = parse_create_object_session_id(body) + object_ids, obj_end, return_value = parse_create_object_session_id(body) if object_ids: self._session_id = object_ids[0] else: self._session_id = struct.unpack_from(">I", response, 9)[0] self._protocol_version = version + if return_value != 0: + logger.warning(f"CreateObject returned error 0x{return_value:X} — PLC may require TLS (use_tls=True)") + self._server_session_version = parse_server_session_version(response[14 + obj_end :]) if self._server_session_version is not None: logger.info(f"ServerSessionVersion captured: {len(self._server_session_version)} bytes") diff --git a/s7/codec.py b/s7/codec.py index 2ddde01e..41b5ee8e 100644 --- a/s7/codec.py +++ b/s7/codec.py @@ -612,15 +612,15 @@ def skip_typed_value(data: bytes, offset: int, datatype: int, flags: int) -> int return offset -def parse_create_object_session_id(body: bytes) -> tuple[list[int], int]: +def parse_create_object_session_id(body: bytes) -> tuple[list[int], int, int]: """Parse a CreateObject response body (after the 14-byte response header). Body layout: ReturnValue (UInt64 VLQ) + ObjectIdCount (1 byte) + ObjectIds (UInt32 VLQ each). The usable session id is ``ObjectIds[0]`` (not the header SessionId field). - Returns ``(object_ids, offset)`` where ``offset`` points just past the ObjectIds. + Returns ``(object_ids, offset, return_value)`` where ``offset`` points just past the ObjectIds. """ - _return_value, consumed = decode_uint64_vlq(body, 0) + return_value, consumed = decode_uint64_vlq(body, 0) boff = consumed obj_count = body[boff] if boff < len(body) else 0 boff += 1 @@ -629,7 +629,7 @@ def parse_create_object_session_id(body: bytes) -> tuple[list[int], int]: oid, c = decode_uint32_vlq(body, boff) boff += c object_ids.append(oid) - return object_ids, boff + return object_ids, boff, return_value def parse_server_session_version(payload: bytes) -> Optional[bytes]: diff --git a/s7/connection.py b/s7/connection.py index 4df3af64..0a525ea6 100644 --- a/s7/connection.py +++ b/s7/connection.py @@ -759,7 +759,7 @@ def _create_session(self) -> None: # Parse response body: ReturnValue(UInt64 VLQ) + ObjectIdCount + ObjectIds(VLQ). # The usable session id is ObjectIds[0] (NOT the header SessionId field). body = response[14:] - object_ids, obj_end = parse_create_object_session_id(body) + object_ids, obj_end, return_value = parse_create_object_session_id(body) if object_ids: self._session_id = object_ids[0] else: @@ -778,6 +778,9 @@ def _create_session(self) -> None: logger.debug(f"CreateObject response payload: {response[14:].hex(' ')}") logger.debug(f"Session created: id=0x{self._session_id:08X} ({self._session_id}), version=V{version}") + if return_value != 0: + logger.warning(f"CreateObject returned error 0x{return_value:X} — PLC may require TLS (use_tls=True)") + # Parse response payload to extract ServerSessionVersion self._server_session_version = parse_server_session_version(response[14 + obj_end :]) if self._server_session_version is not None: @@ -1020,12 +1023,11 @@ def _setup_ssl_context( Configured SSLContext """ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - ctx.minimum_version = ssl.TLSVersion.TLSv1_3 + # S7-1500 FW < V3.0 only supports TLS 1.2; newer firmware negotiates 1.3. + ctx.minimum_version = ssl.TLSVersion.TLSv1_2 - # TLS 1.3 ciphersuites are configured differently from TLS 1.2 if hasattr(ctx, "set_ciphersuites"): ctx.set_ciphersuites("TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256") - # If set_ciphersuites not available, TLS 1.3 uses its mandatory defaults if cert_path and key_path: ctx.load_cert_chain(cert_path, key_path) From 51c1546312aec5b0abbaa938f552c60b570244ea Mon Sep 17 00:00:00 2001 From: Gijs Molenaar Date: Tue, 7 Jul 2026 09:29:46 +0200 Subject: [PATCH 2/9] fix(tls): disable post-quantum key exchange for S7 PLCs OpenSSL 3.5+ enables ML-KEM (post-quantum) key exchange by default, producing ~1500-byte ClientHellos that S7-1500 PLCs cannot handle, causing connection resets during the TLS handshake. Restrict key exchange to secp256r1 (prime256v1) which is supported by all S7 firmware versions with TLS. Also apply the TLS 1.2 minimum version fix to the async client, which was missed in the prior commit. Co-Authored-By: Claude Opus 4.6 (1M context) --- s7/_s7commplus_async_client.py | 8 +++++++- s7/connection.py | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/s7/_s7commplus_async_client.py b/s7/_s7commplus_async_client.py index 48d0e650..eb94004b 100644 --- a/s7/_s7commplus_async_client.py +++ b/s7/_s7commplus_async_client.py @@ -249,7 +249,13 @@ async def _activate_tls( raise S7ConnectionError("Cannot activate TLS: not connected") ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - ctx.minimum_version = ssl.TLSVersion.TLSv1_3 + # S7-1500 FW < V3.0 only supports TLS 1.2; newer firmware negotiates 1.3. + ctx.minimum_version = ssl.TLSVersion.TLSv1_2 + + # OpenSSL 3.5+ enables post-quantum key exchange (ML-KEM) by default, + # producing ~1500-byte ClientHellos that S7-1500 PLCs cannot handle. + # Restrict to secp256r1 — supported by all S7 TLS firmware versions. + ctx.set_ecdh_curve("prime256v1") if hasattr(ctx, "set_ciphersuites"): ctx.set_ciphersuites("TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256") diff --git a/s7/connection.py b/s7/connection.py index 0a525ea6..30a54d06 100644 --- a/s7/connection.py +++ b/s7/connection.py @@ -1026,6 +1026,11 @@ def _setup_ssl_context( # S7-1500 FW < V3.0 only supports TLS 1.2; newer firmware negotiates 1.3. ctx.minimum_version = ssl.TLSVersion.TLSv1_2 + # OpenSSL 3.5+ enables post-quantum key exchange (ML-KEM) by default, + # producing ~1500-byte ClientHellos that S7-1500 PLCs cannot handle. + # Restrict to secp256r1 — supported by all S7 TLS firmware versions. + ctx.set_ecdh_curve("prime256v1") + if hasattr(ctx, "set_ciphersuites"): ctx.set_ciphersuites("TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256") From 3bbce48b4926b7788bf0ed5e67e18e9d4956efe4 Mon Sep 17 00:00:00 2001 From: Gijs Molenaar Date: Tue, 7 Jul 2026 11:16:26 +0200 Subject: [PATCH 3/9] fix(tls): cap at TLS 1.2 to avoid TLS 1.3 extensions that S7 PLCs reject OpenSSL offers TLS 1.3 by default, adding extensions (supported_versions, key_share, psk_key_exchange_modes) to the ClientHello that S7-1500 PLCs don't understand and RST the connection. Setting maximum_version to TLS 1.2 produces a clean TLS 1.2 ClientHello without these extensions. The set_ciphersuites() call (TLS 1.3 only) is now unnecessary and removed. Co-Authored-By: Claude Opus 4.6 (1M context) --- s7/_s7commplus_async_client.py | 13 +++++++------ s7/connection.py | 17 +++++++++-------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/s7/_s7commplus_async_client.py b/s7/_s7commplus_async_client.py index eb94004b..c6799456 100644 --- a/s7/_s7commplus_async_client.py +++ b/s7/_s7commplus_async_client.py @@ -242,24 +242,25 @@ async def _activate_tls( tls_key: Optional[str] = None, tls_ca: Optional[str] = None, ) -> None: - """Activate TLS 1.3 over the COTP connection.""" + """Activate TLS over the COTP connection.""" if self._writer is None: from snap7.error import S7ConnectionError raise S7ConnectionError("Cannot activate TLS: not connected") ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - # S7-1500 FW < V3.0 only supports TLS 1.2; newer firmware negotiates 1.3. + # S7-1500 PLCs support TLS 1.2; some newer firmware also speaks 1.3, + # but many PLCs reject ClientHellos containing TLS 1.3 extensions + # (supported_versions, key_share, psk_key_exchange_modes) that they + # don't understand. Pin to TLS 1.2 for maximum compatibility. ctx.minimum_version = ssl.TLSVersion.TLSv1_2 + ctx.maximum_version = ssl.TLSVersion.TLSv1_2 # OpenSSL 3.5+ enables post-quantum key exchange (ML-KEM) by default, # producing ~1500-byte ClientHellos that S7-1500 PLCs cannot handle. # Restrict to secp256r1 — supported by all S7 TLS firmware versions. ctx.set_ecdh_curve("prime256v1") - if hasattr(ctx, "set_ciphersuites"): - ctx.set_ciphersuites("TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256") - if tls_cert and tls_key: ctx.load_cert_chain(tls_cert, tls_key) @@ -291,7 +292,7 @@ async def _activate_tls( logger.warning(f"Could not extract OMS exporter secret: {e}") self._oms_secret = None - logger.info("TLS 1.3 activated (tunneled inside COTP frames)") + logger.info("TLS activated (tunneled inside COTP frames)") async def _do_tls_handshake(self) -> None: """Perform the TLS handshake, tunneling records through COTP DT frames.""" diff --git a/s7/connection.py b/s7/connection.py index 30a54d06..83590234 100644 --- a/s7/connection.py +++ b/s7/connection.py @@ -7,7 +7,7 @@ - V1: Early S7-1200 (FW >= V4.0). Simple session handshake. - V2: Adds integrity checking and session authentication. - V3: Adds public-key-based key exchange. -- V3 + TLS: TIA Portal V17+. Standard TLS 1.3 with per-device certificates. +- V3 + TLS: TIA Portal V17+. TLS 1.2 with per-device certificates. The wire protocol (VLQ encoding, data types, function codes, object model) is the same across all versions -- only the session authentication layer differs. @@ -941,7 +941,7 @@ def _activate_tls( tls_key: Optional[str] = None, tls_ca: Optional[str] = None, ) -> None: - """Activate TLS 1.3 tunneled inside COTP data frames. + """Activate TLS tunneled inside COTP data frames. The S7CommPlus protocol transports TLS records as the payload of COTP DT frames — TPKT and COTP headers stay unencrypted on @@ -958,7 +958,7 @@ def _activate_tls( Args: tls_cert: Path to client TLS certificate (PEM) tls_key: Path to client private key (PEM) - tls_ca: Path to CA certificate for PLC verification (PEM) + tls_ca: Path to PLC CA certificate (PEM) """ ctx = self._setup_ssl_context( cert_path=tls_cert, @@ -990,7 +990,7 @@ def _activate_tls( logger.warning(f"Could not extract OMS exporter secret: {e}") self._oms_secret = None - logger.info("TLS 1.3 activated (tunneled inside COTP frames)") + logger.info("TLS activated (tunneled inside COTP frames)") def _do_tls_handshake(self) -> None: """Perform TLS handshake, tunneling records through COTP.""" @@ -1023,17 +1023,18 @@ def _setup_ssl_context( Configured SSLContext """ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - # S7-1500 FW < V3.0 only supports TLS 1.2; newer firmware negotiates 1.3. + # S7-1500 PLCs support TLS 1.2; some newer firmware also speaks 1.3, + # but many PLCs reject ClientHellos containing TLS 1.3 extensions + # (supported_versions, key_share, psk_key_exchange_modes) that they + # don't understand. Pin to TLS 1.2 for maximum compatibility. ctx.minimum_version = ssl.TLSVersion.TLSv1_2 + ctx.maximum_version = ssl.TLSVersion.TLSv1_2 # OpenSSL 3.5+ enables post-quantum key exchange (ML-KEM) by default, # producing ~1500-byte ClientHellos that S7-1500 PLCs cannot handle. # Restrict to secp256r1 — supported by all S7 TLS firmware versions. ctx.set_ecdh_curve("prime256v1") - if hasattr(ctx, "set_ciphersuites"): - ctx.set_ciphersuites("TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256") - if cert_path and key_path: ctx.load_cert_chain(cert_path, key_path) From 39f5531d0f5c5c96349aec73ca8da440f16ad4e7 Mon Sep 17 00:00:00 2001 From: Gijs Molenaar Date: Tue, 7 Jul 2026 13:27:07 +0200 Subject: [PATCH 4/9] fix(tls): restrict cipher suites and extensions for S7 PLC compatibility S7 PLCs have a minimal TLS stack that rejects ClientHellos with unknown cipher suites or extensions. Restrict to ECDHE+AES-GCM (matching TIA Portal), disable session_ticket and encrypt_then_mac extensions. Shrinks ClientHello from 164 to 127 bytes. Also fix emulated server to accept TLS 1.2 (was requiring 1.3, incompatible with client TLS 1.2 cap from previous commit). Co-Authored-By: Claude Opus 4.6 (1M context) --- s7/_s7commplus_async_client.py | 9 ++++++--- s7/_s7commplus_server.py | 2 +- s7/connection.py | 9 ++++++--- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/s7/_s7commplus_async_client.py b/s7/_s7commplus_async_client.py index c6799456..f86cad57 100644 --- a/s7/_s7commplus_async_client.py +++ b/s7/_s7commplus_async_client.py @@ -256,10 +256,13 @@ async def _activate_tls( ctx.minimum_version = ssl.TLSVersion.TLSv1_2 ctx.maximum_version = ssl.TLSVersion.TLSv1_2 - # OpenSSL 3.5+ enables post-quantum key exchange (ML-KEM) by default, - # producing ~1500-byte ClientHellos that S7-1500 PLCs cannot handle. - # Restrict to secp256r1 — supported by all S7 TLS firmware versions. + # S7 PLCs have a minimal TLS stack that rejects ClientHellos with + # unknown cipher suites, signature algorithms, or extensions. + # Restrict to ECDHE+AES-GCM (what TIA Portal uses) and secp256r1. + ctx.set_ciphers("ECDHE+AESGCM") ctx.set_ecdh_curve("prime256v1") + ctx.options |= ssl.OP_NO_TICKET + ctx.options |= 0x00080000 # SSL_OP_NO_ENCRYPT_THEN_MAC if tls_cert and tls_key: ctx.load_cert_chain(tls_cert, tls_key) diff --git a/s7/_s7commplus_server.py b/s7/_s7commplus_server.py index adc31574..7f50252d 100644 --- a/s7/_s7commplus_server.py +++ b/s7/_s7commplus_server.py @@ -291,7 +291,7 @@ def start( if not tls_cert or not tls_key: raise ValueError("TLS requires tls_cert and tls_key") self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_3 + self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2 self._ssl_context.load_cert_chain(tls_cert, tls_key) if tls_ca: self._ssl_context.load_verify_locations(tls_ca) diff --git a/s7/connection.py b/s7/connection.py index 83590234..7f2029d7 100644 --- a/s7/connection.py +++ b/s7/connection.py @@ -1030,10 +1030,13 @@ def _setup_ssl_context( ctx.minimum_version = ssl.TLSVersion.TLSv1_2 ctx.maximum_version = ssl.TLSVersion.TLSv1_2 - # OpenSSL 3.5+ enables post-quantum key exchange (ML-KEM) by default, - # producing ~1500-byte ClientHellos that S7-1500 PLCs cannot handle. - # Restrict to secp256r1 — supported by all S7 TLS firmware versions. + # S7 PLCs have a minimal TLS stack that rejects ClientHellos with + # unknown cipher suites, signature algorithms, or extensions. + # Restrict to ECDHE+AES-GCM (what TIA Portal uses) and secp256r1. + ctx.set_ciphers("ECDHE+AESGCM") ctx.set_ecdh_curve("prime256v1") + ctx.options |= ssl.OP_NO_TICKET + ctx.options |= 0x00080000 # SSL_OP_NO_ENCRYPT_THEN_MAC if cert_path and key_path: ctx.load_cert_chain(cert_path, key_path) From 5db042dbbbe451d72ed9cfe2f8f0db01a869996c Mon Sep 17 00:00:00 2001 From: Gijs Molenaar Date: Tue, 7 Jul 2026 14:06:58 +0200 Subject: [PATCH 5/9] fix(tls): restrict signature algorithms and add RSA cipher fallback The PLC's minimal TLS stack rejects ClientHellos containing modern signature algorithms (Ed25519, Ed448, RSA-PSS) instead of ignoring them per RFC 5246. Use ctypes to call SSL_CTX_ctrl(SET_SIGALGS_LIST) to restrict to classic RSA/ECDSA with SHA-2 only. Also add RSA-only cipher suites as fallback for PLCs that don't support ECDHE key exchange, and drop ECDSA ciphers since PLCs use RSA certificates. Co-Authored-By: Claude Opus 4.6 (1M context) --- s7/_s7commplus_async_client.py | 13 +++---- s7/connection.py | 68 ++++++++++++++++++++++++++++++---- 2 files changed, 65 insertions(+), 16 deletions(-) diff --git a/s7/_s7commplus_async_client.py b/s7/_s7commplus_async_client.py index f86cad57..ea26e026 100644 --- a/s7/_s7commplus_async_client.py +++ b/s7/_s7commplus_async_client.py @@ -13,6 +13,8 @@ import struct from typing import Any, Optional +from .connection import _S7_CIPHERS, _S7_SIGALGS, _set_ctx_sigalgs + from .protocol import ( DataType, ElementID, @@ -249,21 +251,16 @@ async def _activate_tls( raise S7ConnectionError("Cannot activate TLS: not connected") ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - # S7-1500 PLCs support TLS 1.2; some newer firmware also speaks 1.3, - # but many PLCs reject ClientHellos containing TLS 1.3 extensions - # (supported_versions, key_share, psk_key_exchange_modes) that they - # don't understand. Pin to TLS 1.2 for maximum compatibility. ctx.minimum_version = ssl.TLSVersion.TLSv1_2 ctx.maximum_version = ssl.TLSVersion.TLSv1_2 - # S7 PLCs have a minimal TLS stack that rejects ClientHellos with - # unknown cipher suites, signature algorithms, or extensions. - # Restrict to ECDHE+AES-GCM (what TIA Portal uses) and secp256r1. - ctx.set_ciphers("ECDHE+AESGCM") + ctx.set_ciphers(_S7_CIPHERS) ctx.set_ecdh_curve("prime256v1") ctx.options |= ssl.OP_NO_TICKET ctx.options |= 0x00080000 # SSL_OP_NO_ENCRYPT_THEN_MAC + _set_ctx_sigalgs(ctx, _S7_SIGALGS) + if tls_cert and tls_key: ctx.load_cert_chain(tls_cert, tls_key) diff --git a/s7/connection.py b/s7/connection.py index 7f2029d7..520f8a65 100644 --- a/s7/connection.py +++ b/s7/connection.py @@ -38,9 +38,12 @@ Reference: thomas-v2/S7CommPlusDriver (C#, LGPL-3.0) """ +import ctypes +import ctypes.util import logging import ssl import struct +import sys from typing import Optional, Type from types import TracebackType @@ -68,6 +71,60 @@ logger = logging.getLogger(__name__) +# S7 PLC compatible signature algorithms — only classic RSA/ECDSA with SHA-2. +# S7 PLCs have minimal TLS stacks that reject ClientHellos containing modern +# algorithms (Ed25519, Ed448, RSA-PSS) instead of ignoring them per RFC 5246. +_S7_SIGALGS = "RSA+SHA256:RSA+SHA384:RSA+SHA512:ECDSA+SHA256:ECDSA+SHA384" + +# S7 PLC compatible cipher suites — ECDHE-RSA preferred, RSA-only as fallback. +# No ECDSA ciphers since PLCs use RSA certificates. CBC ciphers included for +# older firmware that doesn't support GCM. +_S7_CIPHERS = ( + "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256" +) + +# SSL_CTRL_SET_SIGALGS_LIST = 98 (OpenSSL 1.0.2+) +# SSL_CTX_set1_sigalgs_list is a macro that calls SSL_CTX_ctrl with this code. +_SSL_CTRL_SET_SIGALGS_LIST = 98 + + +def _set_ctx_sigalgs(ctx: ssl.SSLContext, sigalgs: str) -> bool: + """Restrict TLS signature algorithms via OpenSSL's C API. + + Python's ssl module doesn't expose SSL_CTX_set1_sigalgs_list, so this + calls SSL_CTX_ctrl directly via ctypes. Returns True on success, False + (with a debug log) on any platform where this isn't possible. + """ + if sys.implementation.name != "cpython": + logger.debug("Cannot restrict TLS sigalgs: not CPython") + return False + try: + libssl_name = ctypes.util.find_library("ssl") + if not libssl_name: + logger.debug("Cannot restrict TLS sigalgs: libssl not found") + return False + libssl = ctypes.CDLL(libssl_name) + ssl_ctx_ctrl = libssl.SSL_CTX_ctrl + ssl_ctx_ctrl.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_long, ctypes.c_void_p] + ssl_ctx_ctrl.restype = ctypes.c_long + + # CPython stores SSL_CTX* right after PyObject_HEAD (refcount + type ptr) + header = ctypes.sizeof(ctypes.c_ssize_t) + ctypes.sizeof(ctypes.c_void_p) + ctx_ptr = ctypes.c_void_p.from_address(id(ctx) + header).value + if not ctx_ptr: + logger.debug("Cannot restrict TLS sigalgs: SSL_CTX pointer is NULL") + return False + + ok = ssl_ctx_ctrl(ctypes.c_void_p(ctx_ptr), _SSL_CTRL_SET_SIGALGS_LIST, 0, sigalgs.encode()) + if ok == 1: + logger.debug("TLS signature algorithms restricted to: %s", sigalgs) + return True + logger.debug("SSL_CTX_ctrl(SET_SIGALGS_LIST) returned %d", ok) + return False + except Exception: + logger.debug("Cannot restrict TLS sigalgs", exc_info=True) + return False + class S7CommPlusConnection: """S7CommPlus connection with multi-version support. @@ -1023,21 +1080,16 @@ def _setup_ssl_context( Configured SSLContext """ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - # S7-1500 PLCs support TLS 1.2; some newer firmware also speaks 1.3, - # but many PLCs reject ClientHellos containing TLS 1.3 extensions - # (supported_versions, key_share, psk_key_exchange_modes) that they - # don't understand. Pin to TLS 1.2 for maximum compatibility. ctx.minimum_version = ssl.TLSVersion.TLSv1_2 ctx.maximum_version = ssl.TLSVersion.TLSv1_2 - # S7 PLCs have a minimal TLS stack that rejects ClientHellos with - # unknown cipher suites, signature algorithms, or extensions. - # Restrict to ECDHE+AES-GCM (what TIA Portal uses) and secp256r1. - ctx.set_ciphers("ECDHE+AESGCM") + ctx.set_ciphers(_S7_CIPHERS) ctx.set_ecdh_curve("prime256v1") ctx.options |= ssl.OP_NO_TICKET ctx.options |= 0x00080000 # SSL_OP_NO_ENCRYPT_THEN_MAC + _set_ctx_sigalgs(ctx, _S7_SIGALGS) + if cert_path and key_path: ctx.load_cert_chain(cert_path, key_path) From 2ed7d823468d49681fe993bca71477105ff51384 Mon Sep 17 00:00:00 2001 From: Gijs Molenaar Date: Tue, 7 Jul 2026 14:19:09 +0200 Subject: [PATCH 6/9] fix(tls): add RSA cipher fallback and document sigalgs workaround MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add RSA-only cipher suites as fallback for PLCs that don't support ECDHE key exchange, and drop ECDSA ciphers (PLCs use RSA certs). The PLC's minimal TLS stack also rejects ClientHellos with modern signature algorithms (Ed25519, Ed448, RSA-PSS) that Python's OpenSSL advertises by default. CPython's ssl module doesn't expose SSL_CTX_set1_sigalgs_list, so document restricting them via OPENSSL_CONF — same approach as the post-quantum Groups fix (#752). Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/connecting.rst | 51 ++++++++++++++++----------------- s7/_s7commplus_async_client.py | 5 +--- s7/connection.py | 52 ---------------------------------- 3 files changed, 26 insertions(+), 82 deletions(-) diff --git a/doc/connecting.rst b/doc/connecting.rst index 740ff7fb..4515d0c2 100644 --- a/doc/connecting.rst +++ b/doc/connecting.rst @@ -152,29 +152,30 @@ with the ``s7commplus`` extra: ``browse()`` and other CommPlus-only operations are not yet supported on those firmwares — see issue #710. -TLS handshake fails on OpenSSL 3.5+ (post-quantum key share) -------------------------------------------------------------- - -On systems with **OpenSSL ≥ 3.5** (e.g. Debian 13 and other recent -distributions) the TLS 1.3 ``ClientHello`` advertises a post-quantum -hybrid key-exchange group, ``X25519MLKEM768``, by default. Its key -share is roughly 1.2 KB, and the S7-1500's TLS stack rejects the -oversized ``ClientHello`` — it drops the connection mid-handshake, so -``connect(use_tls=True)`` fails with a *connection reset by peer*. - -The PLC mandates TLS 1.3 (it refuses TLS 1.2 outright), and CPython's -``ssl`` module exposes no API to restrict the TLS 1.3 -``supported_groups`` list. The fix is to restrict the offered groups -through OpenSSL's own configuration — via the ``OPENSSL_CONF`` -environment variable — to the classic ECDHE curves that every -S7-1200/1500 supports. +TLS handshake rejected by the PLC (connection reset) +------------------------------------------------------ + +S7 PLCs have a minimal TLS stack that rejects ``ClientHello`` messages +containing features it does not recognise. Two common causes: + +* **Post-quantum key share (OpenSSL ≥ 3.5)** — the default + ``ClientHello`` advertises the ``X25519MLKEM768`` hybrid group whose + ~1.2 KB key share the PLC drops. +* **Modern signature algorithms** — OpenSSL advertises Ed25519, Ed448, + and RSA-PSS variants in the ``signature_algorithms`` extension. + Instead of ignoring unknown algorithms (as TLS 1.2 requires), the PLC + treats them as a fatal error and sends a TCP RST. + +CPython's ``ssl`` module exposes no API for either the +``supported_groups`` or ``signature_algorithms`` lists. The fix is to +restrict both through OpenSSL's own configuration via the +``OPENSSL_CONF`` environment variable. 1. Create an OpenSSL configuration file, e.g. ``s7-openssl.cnf``: .. code-block:: ini - # Restrict the TLS key-exchange groups to classic ECDHE curves so the - # ClientHello stays small enough for the S7-1500 to accept. + # Restrict TLS parameters for S7 PLC compatibility. openssl_conf = openssl_init [openssl_init] @@ -184,7 +185,10 @@ S7-1200/1500 supports. system_default = system_default_sect [system_default_sect] + # Classic ECDHE curves only — no post-quantum groups. Groups = x25519:secp256r1:secp384r1 + # Classic signature algorithms only — no Ed25519, Ed448, or RSA-PSS. + SignatureAlgorithms = RSA+SHA256:RSA+SHA384:RSA+SHA512:ECDSA+SHA256:ECDSA+SHA384 2. Point ``OPENSSL_CONF`` at it **before** the Python process starts. OpenSSL reads this configuration once, when it initialises, so @@ -200,14 +204,9 @@ S7-1200/1500 supports. # or for the whole shell session export OPENSSL_CONF=/path/to/s7-openssl.cnf -With the groups restricted to classic curves, the TLS 1.3 handshake no -longer offers ``X25519MLKEM768`` and the S7-1500 completes it normally. - -.. note:: - - This only affects OpenSSL ≥ 3.5. On older OpenSSL releases the - default key-exchange groups are already classic ECDHE curves, so no - extra configuration is needed. +With both settings applied, the ``ClientHello`` contains only +algorithms that S7 PLCs understand and the handshake completes +normally. PLC Password Authentication ---------------------------- diff --git a/s7/_s7commplus_async_client.py b/s7/_s7commplus_async_client.py index ea26e026..32bfcd4b 100644 --- a/s7/_s7commplus_async_client.py +++ b/s7/_s7commplus_async_client.py @@ -13,8 +13,7 @@ import struct from typing import Any, Optional -from .connection import _S7_CIPHERS, _S7_SIGALGS, _set_ctx_sigalgs - +from .connection import _S7_CIPHERS from .protocol import ( DataType, ElementID, @@ -259,8 +258,6 @@ async def _activate_tls( ctx.options |= ssl.OP_NO_TICKET ctx.options |= 0x00080000 # SSL_OP_NO_ENCRYPT_THEN_MAC - _set_ctx_sigalgs(ctx, _S7_SIGALGS) - if tls_cert and tls_key: ctx.load_cert_chain(tls_cert, tls_key) diff --git a/s7/connection.py b/s7/connection.py index 520f8a65..afa9b8c9 100644 --- a/s7/connection.py +++ b/s7/connection.py @@ -38,12 +38,9 @@ Reference: thomas-v2/S7CommPlusDriver (C#, LGPL-3.0) """ -import ctypes -import ctypes.util import logging import ssl import struct -import sys from typing import Optional, Type from types import TracebackType @@ -71,11 +68,6 @@ logger = logging.getLogger(__name__) -# S7 PLC compatible signature algorithms — only classic RSA/ECDSA with SHA-2. -# S7 PLCs have minimal TLS stacks that reject ClientHellos containing modern -# algorithms (Ed25519, Ed448, RSA-PSS) instead of ignoring them per RFC 5246. -_S7_SIGALGS = "RSA+SHA256:RSA+SHA384:RSA+SHA512:ECDSA+SHA256:ECDSA+SHA384" - # S7 PLC compatible cipher suites — ECDHE-RSA preferred, RSA-only as fallback. # No ECDSA ciphers since PLCs use RSA certificates. CBC ciphers included for # older firmware that doesn't support GCM. @@ -83,48 +75,6 @@ "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256" ) -# SSL_CTRL_SET_SIGALGS_LIST = 98 (OpenSSL 1.0.2+) -# SSL_CTX_set1_sigalgs_list is a macro that calls SSL_CTX_ctrl with this code. -_SSL_CTRL_SET_SIGALGS_LIST = 98 - - -def _set_ctx_sigalgs(ctx: ssl.SSLContext, sigalgs: str) -> bool: - """Restrict TLS signature algorithms via OpenSSL's C API. - - Python's ssl module doesn't expose SSL_CTX_set1_sigalgs_list, so this - calls SSL_CTX_ctrl directly via ctypes. Returns True on success, False - (with a debug log) on any platform where this isn't possible. - """ - if sys.implementation.name != "cpython": - logger.debug("Cannot restrict TLS sigalgs: not CPython") - return False - try: - libssl_name = ctypes.util.find_library("ssl") - if not libssl_name: - logger.debug("Cannot restrict TLS sigalgs: libssl not found") - return False - libssl = ctypes.CDLL(libssl_name) - ssl_ctx_ctrl = libssl.SSL_CTX_ctrl - ssl_ctx_ctrl.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_long, ctypes.c_void_p] - ssl_ctx_ctrl.restype = ctypes.c_long - - # CPython stores SSL_CTX* right after PyObject_HEAD (refcount + type ptr) - header = ctypes.sizeof(ctypes.c_ssize_t) + ctypes.sizeof(ctypes.c_void_p) - ctx_ptr = ctypes.c_void_p.from_address(id(ctx) + header).value - if not ctx_ptr: - logger.debug("Cannot restrict TLS sigalgs: SSL_CTX pointer is NULL") - return False - - ok = ssl_ctx_ctrl(ctypes.c_void_p(ctx_ptr), _SSL_CTRL_SET_SIGALGS_LIST, 0, sigalgs.encode()) - if ok == 1: - logger.debug("TLS signature algorithms restricted to: %s", sigalgs) - return True - logger.debug("SSL_CTX_ctrl(SET_SIGALGS_LIST) returned %d", ok) - return False - except Exception: - logger.debug("Cannot restrict TLS sigalgs", exc_info=True) - return False - class S7CommPlusConnection: """S7CommPlus connection with multi-version support. @@ -1088,8 +1038,6 @@ def _setup_ssl_context( ctx.options |= ssl.OP_NO_TICKET ctx.options |= 0x00080000 # SSL_OP_NO_ENCRYPT_THEN_MAC - _set_ctx_sigalgs(ctx, _S7_SIGALGS) - if cert_path and key_path: ctx.load_cert_chain(cert_path, key_path) From f2a9b69801b3ce9ba5236485d7380d2bb9cd1a25 Mon Sep 17 00:00:00 2001 From: Gijs Molenaar Date: Tue, 7 Jul 2026 14:46:22 +0200 Subject: [PATCH 7/9] fix(tls): disable extended_master_secret extension for PLC compatibility S7 PLCs may reject ClientHellos containing the extended_master_secret extension (RFC 7627). Set SSL_OP_NO_EXTENDED_MASTER_SECRET (0x00000001, OpenSSL 3.0+) to suppress it. On OpenSSL 1.1.1 this value is the deprecated SSL_OP_MICROSOFT_SESS_ID_BUG no-op, so the flag is harmless on older builds. Co-Authored-By: Claude Opus 4.6 (1M context) --- s7/_s7commplus_async_client.py | 1 + s7/connection.py | 1 + 2 files changed, 2 insertions(+) diff --git a/s7/_s7commplus_async_client.py b/s7/_s7commplus_async_client.py index 32bfcd4b..30924216 100644 --- a/s7/_s7commplus_async_client.py +++ b/s7/_s7commplus_async_client.py @@ -257,6 +257,7 @@ async def _activate_tls( ctx.set_ecdh_curve("prime256v1") ctx.options |= ssl.OP_NO_TICKET ctx.options |= 0x00080000 # SSL_OP_NO_ENCRYPT_THEN_MAC + ctx.options |= 0x00000001 # SSL_OP_NO_EXTENDED_MASTER_SECRET (OpenSSL 3.0+) if tls_cert and tls_key: ctx.load_cert_chain(tls_cert, tls_key) diff --git a/s7/connection.py b/s7/connection.py index afa9b8c9..8c5dae75 100644 --- a/s7/connection.py +++ b/s7/connection.py @@ -1037,6 +1037,7 @@ def _setup_ssl_context( ctx.set_ecdh_curve("prime256v1") ctx.options |= ssl.OP_NO_TICKET ctx.options |= 0x00080000 # SSL_OP_NO_ENCRYPT_THEN_MAC + ctx.options |= 0x00000001 # SSL_OP_NO_EXTENDED_MASTER_SECRET (OpenSSL 3.0+) if cert_path and key_path: ctx.load_cert_chain(cert_path, key_path) From 8fa3384e2e12c95a3ea9b4bd27de878e43a2558e Mon Sep 17 00:00:00 2001 From: Gijs Molenaar Date: Wed, 8 Jul 2026 11:19:07 +0200 Subject: [PATCH 8/9] fix(tls): switch to RSA-only ciphers to eliminate EC extensions ECDHE ciphers force OpenSSL to include ec_point_formats and supported_groups extensions in the ClientHello. Python's ssl module always advertises compressed EC point formats alongside uncompressed, and there is no API to suppress them. S7-1500 PLCs send a bare TCP RST when they see these extensions. Switching to RSA-only ciphers (AES-GCM + AES-CBC-SHA256) removes both extensions entirely, producing a minimal ClientHello with only the signature_algorithms extension remaining. Co-Authored-By: Claude Opus 4.6 (1M context) --- s7/_s7commplus_async_client.py | 1 - s7/connection.py | 13 ++++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/s7/_s7commplus_async_client.py b/s7/_s7commplus_async_client.py index 30924216..d90d90e8 100644 --- a/s7/_s7commplus_async_client.py +++ b/s7/_s7commplus_async_client.py @@ -254,7 +254,6 @@ async def _activate_tls( ctx.maximum_version = ssl.TLSVersion.TLSv1_2 ctx.set_ciphers(_S7_CIPHERS) - ctx.set_ecdh_curve("prime256v1") ctx.options |= ssl.OP_NO_TICKET ctx.options |= 0x00080000 # SSL_OP_NO_ENCRYPT_THEN_MAC ctx.options |= 0x00000001 # SSL_OP_NO_EXTENDED_MASTER_SECRET (OpenSSL 3.0+) diff --git a/s7/connection.py b/s7/connection.py index 8c5dae75..a9fe02fe 100644 --- a/s7/connection.py +++ b/s7/connection.py @@ -68,12 +68,12 @@ logger = logging.getLogger(__name__) -# S7 PLC compatible cipher suites — ECDHE-RSA preferred, RSA-only as fallback. -# No ECDSA ciphers since PLCs use RSA certificates. CBC ciphers included for -# older firmware that doesn't support GCM. -_S7_CIPHERS = ( - "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256" -) +# RSA-only cipher suites for S7 PLC compatibility. ECDHE ciphers are excluded +# because they force OpenSSL to include ec_point_formats and supported_groups +# extensions in the ClientHello; S7-1500 PLCs send a bare TCP RST when they see +# compressed EC point formats (which Python's ssl module always advertises with +# ECDHE, and there is no API to suppress them). +_S7_CIPHERS = "AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256" class S7CommPlusConnection: @@ -1034,7 +1034,6 @@ def _setup_ssl_context( ctx.maximum_version = ssl.TLSVersion.TLSv1_2 ctx.set_ciphers(_S7_CIPHERS) - ctx.set_ecdh_curve("prime256v1") ctx.options |= ssl.OP_NO_TICKET ctx.options |= 0x00080000 # SSL_OP_NO_ENCRYPT_THEN_MAC ctx.options |= 0x00000001 # SSL_OP_NO_EXTENDED_MASTER_SECRET (OpenSSL 3.0+) From 016418eed80d3ca6d77d6bc30bceb0973c2bbd49 Mon Sep 17 00:00:00 2001 From: Gijs Molenaar Date: Fri, 10 Jul 2026 10:35:25 +0200 Subject: [PATCH 9/9] fix(tls): configure test server with RSA-only ciphers matching client The async TLS tests failed because the S7CommPlusServer used OpenSSL's default cipher suite, which excludes static RSA key exchange in OpenSSL 3.x. The client restricts to RSA-only TLS 1.2 ciphers (_S7_CIPHERS) for S7 PLC compatibility, leaving zero cipher overlap and causing a silent handshake failure (SSLWantReadError -> IncompleteReadError). Co-Authored-By: Claude Opus 4.6 (1M context) --- s7/_s7commplus_server.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/s7/_s7commplus_server.py b/s7/_s7commplus_server.py index 7f50252d..de65223e 100644 --- a/s7/_s7commplus_server.py +++ b/s7/_s7commplus_server.py @@ -41,6 +41,7 @@ READ_FUNCTION_CODES, SoftDataType, ) +from .connection import _S7_CIPHERS from .vlq import encode_uint32_vlq, decode_uint32_vlq, encode_uint64_vlq from .codec import ( encode_header, @@ -292,6 +293,9 @@ def start( raise ValueError("TLS requires tls_cert and tls_key") self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2 + # Accept the same RSA-only ciphers the client uses (static RSA key exchange + # is excluded from the OpenSSL 3.x defaults but required by S7 PLCs). + self._ssl_context.set_ciphers(_S7_CIPHERS) self._ssl_context.load_cert_chain(tls_cert, tls_key) if tls_ca: self._ssl_context.load_verify_locations(tls_ca)