Skip to content

Commit 3bbce48

Browse files
gijzelaerrclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 51c1546 commit 3bbce48

2 files changed

Lines changed: 16 additions & 14 deletions

File tree

s7/_s7commplus_async_client.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -242,24 +242,25 @@ async def _activate_tls(
242242
tls_key: Optional[str] = None,
243243
tls_ca: Optional[str] = None,
244244
) -> None:
245-
"""Activate TLS 1.3 over the COTP connection."""
245+
"""Activate TLS over the COTP connection."""
246246
if self._writer is None:
247247
from snap7.error import S7ConnectionError
248248

249249
raise S7ConnectionError("Cannot activate TLS: not connected")
250250

251251
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
252-
# S7-1500 FW < V3.0 only supports TLS 1.2; newer firmware negotiates 1.3.
252+
# S7-1500 PLCs support TLS 1.2; some newer firmware also speaks 1.3,
253+
# but many PLCs reject ClientHellos containing TLS 1.3 extensions
254+
# (supported_versions, key_share, psk_key_exchange_modes) that they
255+
# don't understand. Pin to TLS 1.2 for maximum compatibility.
253256
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
257+
ctx.maximum_version = ssl.TLSVersion.TLSv1_2
254258

255259
# OpenSSL 3.5+ enables post-quantum key exchange (ML-KEM) by default,
256260
# producing ~1500-byte ClientHellos that S7-1500 PLCs cannot handle.
257261
# Restrict to secp256r1 — supported by all S7 TLS firmware versions.
258262
ctx.set_ecdh_curve("prime256v1")
259263

260-
if hasattr(ctx, "set_ciphersuites"):
261-
ctx.set_ciphersuites("TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256")
262-
263264
if tls_cert and tls_key:
264265
ctx.load_cert_chain(tls_cert, tls_key)
265266

@@ -291,7 +292,7 @@ async def _activate_tls(
291292
logger.warning(f"Could not extract OMS exporter secret: {e}")
292293
self._oms_secret = None
293294

294-
logger.info("TLS 1.3 activated (tunneled inside COTP frames)")
295+
logger.info("TLS activated (tunneled inside COTP frames)")
295296

296297
async def _do_tls_handshake(self) -> None:
297298
"""Perform the TLS handshake, tunneling records through COTP DT frames."""

s7/connection.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
- V1: Early S7-1200 (FW >= V4.0). Simple session handshake.
88
- V2: Adds integrity checking and session authentication.
99
- V3: Adds public-key-based key exchange.
10-
- V3 + TLS: TIA Portal V17+. Standard TLS 1.3 with per-device certificates.
10+
- V3 + TLS: TIA Portal V17+. TLS 1.2 with per-device certificates.
1111
1212
The wire protocol (VLQ encoding, data types, function codes, object model) is
1313
the same across all versions -- only the session authentication layer differs.
@@ -941,7 +941,7 @@ def _activate_tls(
941941
tls_key: Optional[str] = None,
942942
tls_ca: Optional[str] = None,
943943
) -> None:
944-
"""Activate TLS 1.3 tunneled inside COTP data frames.
944+
"""Activate TLS tunneled inside COTP data frames.
945945
946946
The S7CommPlus protocol transports TLS records as the payload
947947
of COTP DT frames — TPKT and COTP headers stay unencrypted on
@@ -958,7 +958,7 @@ def _activate_tls(
958958
Args:
959959
tls_cert: Path to client TLS certificate (PEM)
960960
tls_key: Path to client private key (PEM)
961-
tls_ca: Path to CA certificate for PLC verification (PEM)
961+
tls_ca: Path to PLC CA certificate (PEM)
962962
"""
963963
ctx = self._setup_ssl_context(
964964
cert_path=tls_cert,
@@ -990,7 +990,7 @@ def _activate_tls(
990990
logger.warning(f"Could not extract OMS exporter secret: {e}")
991991
self._oms_secret = None
992992

993-
logger.info("TLS 1.3 activated (tunneled inside COTP frames)")
993+
logger.info("TLS activated (tunneled inside COTP frames)")
994994

995995
def _do_tls_handshake(self) -> None:
996996
"""Perform TLS handshake, tunneling records through COTP."""
@@ -1023,17 +1023,18 @@ def _setup_ssl_context(
10231023
Configured SSLContext
10241024
"""
10251025
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
1026-
# S7-1500 FW < V3.0 only supports TLS 1.2; newer firmware negotiates 1.3.
1026+
# S7-1500 PLCs support TLS 1.2; some newer firmware also speaks 1.3,
1027+
# but many PLCs reject ClientHellos containing TLS 1.3 extensions
1028+
# (supported_versions, key_share, psk_key_exchange_modes) that they
1029+
# don't understand. Pin to TLS 1.2 for maximum compatibility.
10271030
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
1031+
ctx.maximum_version = ssl.TLSVersion.TLSv1_2
10281032

10291033
# OpenSSL 3.5+ enables post-quantum key exchange (ML-KEM) by default,
10301034
# producing ~1500-byte ClientHellos that S7-1500 PLCs cannot handle.
10311035
# Restrict to secp256r1 — supported by all S7 TLS firmware versions.
10321036
ctx.set_ecdh_curve("prime256v1")
10331037

1034-
if hasattr(ctx, "set_ciphersuites"):
1035-
ctx.set_ciphersuites("TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256")
1036-
10371038
if cert_path and key_path:
10381039
ctx.load_cert_chain(cert_path, key_path)
10391040

0 commit comments

Comments
 (0)