Skip to content

Commit b48142f

Browse files
gijzelaerrclaude
andcommitted
feat(tls): use pyOpenSSL for export_keying_material (RFC 5705)
CPython's ssl module does not expose export_keying_material on SSLObject/SSLSocket, which blocks password legitimation on TLS- connected PLCs (the OMS exporter secret cannot be derived). Add a _BioTLS wrapper that prefers pyOpenSSL when available — it supports export_keying_material via its Connection object with memory BIOs. Falls back to stdlib ssl (oms_secret=None) when pyOpenSSL is not installed. pyOpenSSL added to the s7commplus optional dependency group. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b1b5797 commit b48142f

3 files changed

Lines changed: 145 additions & 45 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ Documentation = "https://python-snap7.readthedocs.io/en/latest/"
3333

3434
[project.optional-dependencies]
3535
test = ["pytest", "pytest-asyncio", "pytest-cov", "pytest-html", "hypothesis", "mypy", "types-setuptools", "ruff", "tox", "tox-uv", "types-click", "uv"]
36-
s7commplus = ["cryptography"]
36+
s7commplus = ["cryptography", "pyOpenSSL"]
3737
cli = ["rich", "click" ]
3838
demo = ["psutil", "rich", "click"]
3939
doc = ["sphinx", "sphinx_rtd_theme"]

s7/connection.py

Lines changed: 137 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,101 @@ def _set_s7_groups(ctx: ssl.SSLContext) -> None:
9292
logger.warning("Could not restrict TLS groups — PLC may reject unsupported groups in ClientHello")
9393

9494

95+
class _BioTLS:
96+
"""Thin wrapper over BIO-based TLS — either stdlib or pyOpenSSL.
97+
98+
Provides a uniform interface for write/read/handshake/export_keying_material
99+
so the connection code doesn't branch on the backend.
100+
"""
101+
102+
def __init__(self, ctx: ssl.SSLContext, hostname: Optional[str]) -> None:
103+
self._backend: str = "stdlib"
104+
try:
105+
self._init_pyopenssl(ctx, hostname)
106+
except Exception:
107+
self._init_stdlib(ctx, hostname)
108+
109+
def _init_stdlib(self, ctx: ssl.SSLContext, hostname: Optional[str]) -> None:
110+
self._backend = "stdlib"
111+
self._in_bio = ssl.MemoryBIO()
112+
self._out_bio = ssl.MemoryBIO()
113+
self._obj = ctx.wrap_bio(
114+
self._in_bio,
115+
self._out_bio,
116+
server_side=False,
117+
server_hostname=hostname,
118+
)
119+
120+
def _init_pyopenssl(self, ctx: ssl.SSLContext, hostname: Optional[str]) -> None:
121+
from OpenSSL.SSL import Context, Connection, SSLv23_METHOD, WantReadError # type: ignore[import-untyped]
122+
123+
pyctx = Context(SSLv23_METHOD)
124+
pyctx.set_min_proto_version(ctx.minimum_version)
125+
pyctx.set_cipher_list(_S7_CIPHERS.encode())
126+
for group in _S7_PREFERRED_GROUPS:
127+
try:
128+
pyctx.set_tmp_ecdh_curve(group)
129+
break
130+
except Exception:
131+
continue
132+
pyctx.set_options(ctx.options)
133+
pyctx.set_verify(0x00, lambda *a: True)
134+
conn = Connection(pyctx, None)
135+
conn.set_connect_state()
136+
if hostname:
137+
conn.set_tlsext_host_name(hostname.encode())
138+
self._pyopenssl_conn = conn
139+
self._pyopenssl_want_read = WantReadError
140+
self._backend = "pyopenssl"
141+
142+
def do_handshake(self) -> None:
143+
if self._backend == "pyopenssl":
144+
self._pyopenssl_conn.do_handshake()
145+
else:
146+
self._obj.do_handshake()
147+
148+
def write(self, data: bytes) -> None:
149+
if self._backend == "pyopenssl":
150+
self._pyopenssl_conn.write(data)
151+
else:
152+
self._obj.write(data)
153+
154+
def read(self, bufsize: int = 65536) -> bytes:
155+
if self._backend == "pyopenssl":
156+
return self._pyopenssl_conn.read(bufsize)
157+
else:
158+
return self._obj.read(bufsize)
159+
160+
def bio_write(self, data: bytes) -> None:
161+
if self._backend == "pyopenssl":
162+
self._pyopenssl_conn.bio_write(data)
163+
else:
164+
self._in_bio.write(data)
165+
166+
def bio_read(self) -> bytes:
167+
if self._backend == "pyopenssl":
168+
try:
169+
return self._pyopenssl_conn.bio_read(65536)
170+
except Exception:
171+
return b""
172+
else:
173+
return self._out_bio.read()
174+
175+
@property
176+
def want_read_error(self) -> type:
177+
if self._backend == "pyopenssl":
178+
return self._pyopenssl_want_read
179+
return ssl.SSLWantReadError
180+
181+
def export_keying_material(self, label: str, length: int) -> Optional[bytes]:
182+
if self._backend == "pyopenssl":
183+
return self._pyopenssl_conn.export_keying_material(label.encode(), length, False)
184+
try:
185+
return self._obj.export_keying_material(label, length, None)
186+
except (AttributeError, ssl.SSLError):
187+
return None
188+
189+
95190
class S7CommPlusConnection:
96191
"""S7CommPlus connection with multi-version support.
97192
@@ -121,9 +216,7 @@ def __init__(
121216
)
122217

123218
self._ssl_context: Optional[ssl.SSLContext] = None
124-
self._ssl_object: Optional[ssl.SSLObject] = None
125-
self._incoming_bio: Optional[ssl.MemoryBIO] = None
126-
self._outgoing_bio: Optional[ssl.MemoryBIO] = None
219+
self._tls: Optional[_BioTLS] = None
127220
self._session_id: int = 0
128221
self._sequence_number: int = 0
129222
self._protocol_version: int = 0 # Detected from PLC response
@@ -181,6 +274,16 @@ def oms_secret(self) -> Optional[bytes]:
181274
"""OMS exporter secret from TLS session (for legitimation)."""
182275
return self._oms_secret
183276

277+
@property
278+
def requires_substreamed(self) -> bool:
279+
"""Whether data operations must use substreamed function codes.
280+
281+
V1-initial PLCs with SessionKey auth reject GET_MULTI_VARIABLES
282+
(0x054C) and require GET_VAR_SUBSTREAMED (0x0586) /
283+
SET_VAR_SUBSTREAMED (0x057C) for all data operations.
284+
"""
285+
return self._session_key is not None
286+
184287
def connect(
185288
self,
186289
timeout: float = 5.0,
@@ -514,9 +617,7 @@ def disconnect(self) -> None:
514617
self._connected = False
515618
self._session_setup_ok = False
516619
self._tls_active = False
517-
self._ssl_object = None
518-
self._incoming_bio = None
519-
self._outgoing_bio = None
620+
self._tls = None
520621
self._oms_secret = None
521622
self._session_id = 0
522623
self._sequence_number = 0
@@ -996,33 +1097,38 @@ def _next_sequence_number(self) -> int:
9961097

9971098
def _send_s7_data(self, data: bytes) -> None:
9981099
"""Send an S7CommPlus frame, routing through TLS when active."""
999-
if self._tls_active:
1000-
self._ssl_object.write(data) # type: ignore[union-attr]
1100+
if self._tls_active and self._tls is not None:
1101+
self._tls.write(data)
10011102
self._tls_flush_outgoing()
10021103
else:
10031104
self._iso_conn.send_data(data)
10041105

10051106
def _recv_s7_data(self) -> bytes:
10061107
"""Receive an S7CommPlus frame, routing through TLS when active."""
1007-
if self._tls_active:
1108+
if self._tls_active and self._tls is not None:
10081109
while True:
10091110
try:
1010-
return self._ssl_object.read(65536) # type: ignore[union-attr]
1011-
except ssl.SSLWantReadError:
1012-
self._tls_read_incoming()
1111+
return self._tls.read(65536)
1112+
except Exception as e:
1113+
if isinstance(e, self._tls.want_read_error):
1114+
self._tls_read_incoming()
1115+
else:
1116+
raise
10131117
else:
10141118
return self._iso_conn.receive_data()
10151119

10161120
def _tls_flush_outgoing(self) -> None:
10171121
"""Send all pending TLS records through COTP framing."""
1018-
data = self._outgoing_bio.read() # type: ignore[union-attr]
1122+
assert self._tls is not None
1123+
data = self._tls.bio_read()
10191124
if data:
10201125
self._iso_conn.send_data(data)
10211126

10221127
def _tls_read_incoming(self) -> None:
10231128
"""Read a COTP frame and feed its payload to the TLS BIO."""
1129+
assert self._tls is not None
10241130
data = self._iso_conn.receive_data()
1025-
self._incoming_bio.write(data) # type: ignore[union-attr]
1131+
self._tls.bio_write(data)
10261132

10271133
def _activate_tls(
10281134
self,
@@ -1055,44 +1161,38 @@ def _activate_tls(
10551161
ca_path=tls_ca,
10561162
)
10571163

1058-
# BIO-based TLS: encrypt/decrypt bytes without touching the
1059-
# TCP socket, so TPKT/COTP framing stays unencrypted.
1060-
self._incoming_bio = ssl.MemoryBIO()
1061-
self._outgoing_bio = ssl.MemoryBIO()
1062-
self._ssl_object = ctx.wrap_bio(
1063-
self._incoming_bio,
1064-
self._outgoing_bio,
1065-
server_side=False,
1066-
server_hostname=self.host if ctx.check_hostname else None,
1067-
)
1164+
hostname = self.host if ctx.check_hostname else None
1165+
self._tls = _BioTLS(ctx, hostname)
1166+
logger.debug(f"TLS backend: {self._tls._backend}")
10681167

10691168
# TLS handshake — records tunnel through COTP frames
10701169
self._do_tls_handshake()
10711170

10721171
self._tls_active = True
10731172

1074-
# Extract OMS exporter secret for legitimation key derivation
1075-
try:
1076-
self._oms_secret = self._ssl_object.export_keying_material("EXPERIMENTAL_OMS", 32, None)
1173+
self._oms_secret = self._tls.export_keying_material("EXPERIMENTAL_OMS", 32)
1174+
if self._oms_secret is not None:
10771175
logger.debug("OMS exporter secret extracted from TLS session")
1078-
except (AttributeError, ssl.SSLError) as e:
1079-
logger.warning(f"Could not extract OMS exporter secret: {e}")
1080-
self._oms_secret = None
1176+
else:
1177+
logger.warning("Could not extract OMS exporter secret (legitimation will be unavailable)")
10811178

10821179
logger.info("TLS activated (tunneled inside COTP frames)")
10831180

10841181
def _do_tls_handshake(self) -> None:
10851182
"""Perform TLS handshake, tunneling records through COTP."""
1183+
assert self._tls is not None
10861184
while True:
10871185
try:
1088-
self._ssl_object.do_handshake() # type: ignore[union-attr]
1186+
self._tls.do_handshake()
10891187
break
1090-
except ssl.SSLWantReadError:
1091-
self._tls_flush_outgoing()
1092-
self._tls_read_incoming()
1093-
except ssl.SSLWantWriteError:
1094-
# Rare with MemoryBIO, but the SSLObject can ask to write before reading.
1095-
self._tls_flush_outgoing()
1188+
except Exception as e:
1189+
if isinstance(e, self._tls.want_read_error):
1190+
self._tls_flush_outgoing()
1191+
self._tls_read_incoming()
1192+
elif isinstance(e, ssl.SSLWantWriteError):
1193+
self._tls_flush_outgoing()
1194+
else:
1195+
raise
10961196
self._tls_flush_outgoing()
10971197

10981198
def _setup_ssl_context(

tests/test_s7_tls.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ class TestSyncTLSBioPlumbing:
249249

250250
def _make_connected_pair(self):
251251
import ssl
252-
from s7.connection import S7CommPlusConnection
252+
from s7.connection import S7CommPlusConnection, _BioTLS
253253

254254
cert_path, key_path = _generate_self_signed_cert()
255255

@@ -263,9 +263,9 @@ def _make_connected_pair(self):
263263
client_ctx.verify_mode = ssl.CERT_NONE
264264

265265
conn = S7CommPlusConnection("127.0.0.1", 102)
266-
conn._incoming_bio = ssl.MemoryBIO()
267-
conn._outgoing_bio = ssl.MemoryBIO()
268-
conn._ssl_object = client_ctx.wrap_bio(conn._incoming_bio, conn._outgoing_bio)
266+
tls = _BioTLS.__new__(_BioTLS)
267+
tls._init_stdlib(client_ctx, None)
268+
conn._tls = tls
269269

270270
# Loopback iso layer: ciphertext the client "sends" goes into the server's
271271
# incoming BIO; bytes the client "receives" are popped from inbox.
@@ -285,11 +285,11 @@ def receive_data(self) -> bytes:
285285
client_done = server_done = False
286286
for _ in range(40):
287287
try:
288-
conn._ssl_object.do_handshake()
288+
tls.do_handshake()
289289
client_done = True
290290
except ssl.SSLWantReadError:
291291
pass
292-
out = conn._outgoing_bio.read()
292+
out = tls.bio_read()
293293
if out:
294294
server_in.write(out)
295295
try:
@@ -299,7 +299,7 @@ def receive_data(self) -> bytes:
299299
pass
300300
out = server_out.read()
301301
if out:
302-
conn._incoming_bio.write(out)
302+
tls.bio_write(out)
303303
if client_done and server_done:
304304
break
305305
assert client_done and server_done, "TLS handshake did not complete over MemoryBIO"

0 commit comments

Comments
 (0)