@@ -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+
95190class 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 (
0 commit comments