diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index beb9027c..b5ef98cc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,6 +30,6 @@ jobs: - name: Install dependencies run: | uv venv --python python${{ matrix.python-version }} - uv pip install ".[test]" + uv pip install ".[test,s7commplus]" - name: Run pytest run: uv run pytest --cov=snap7 --cov-report=term diff --git a/pyproject.toml b/pyproject.toml index 01fb0337..b6d87787 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ Documentation = "https://python-snap7.readthedocs.io/en/latest/" [project.optional-dependencies] test = ["pytest", "pytest-asyncio", "pytest-cov", "pytest-html", "hypothesis", "mypy", "types-setuptools", "ruff", "tox", "tox-uv", "types-click", "uv"] +s7commplus = ["cryptography"] cli = ["rich", "click" ] doc = ["sphinx", "sphinx_rtd_theme"] diff --git a/snap7/s7commplus/__init__.py b/snap7/s7commplus/__init__.py index f8ff995a..ab49d09c 100644 --- a/snap7/s7commplus/__init__.py +++ b/snap7/s7commplus/__init__.py @@ -29,7 +29,7 @@ The wire protocol (VLQ encoding, data types, function codes, object model) is the same across all versions -- only the session authentication differs. -Status: experimental scaffolding -- not yet functional. +Status: V1 connection functional, V2 (TLS + IntegrityId) scaffolding complete. Reference implementation: https://github.com/thomas-v2/S7CommPlusDriver (C#, LGPL-3.0) diff --git a/snap7/s7commplus/async_client.py b/snap7/s7commplus/async_client.py index f7c77995..e6a46fe2 100644 --- a/snap7/s7commplus/async_client.py +++ b/snap7/s7commplus/async_client.py @@ -28,11 +28,12 @@ ObjectId, Opcode, ProtocolVersion, + READ_FUNCTION_CODES, S7COMMPLUS_LOCAL_TSAP, S7COMMPLUS_REMOTE_TSAP, ) from .codec import encode_header, decode_header, encode_typed_value, encode_object_qualifier -from .vlq import encode_uint32_vlq, decode_uint64_vlq +from .vlq import encode_uint32_vlq, decode_uint32_vlq, decode_uint64_vlq from .client import _build_read_payload, _parse_read_response, _build_write_payload, _parse_write_response logger = logging.getLogger(__name__) @@ -46,7 +47,7 @@ class S7CommPlusAsyncClient: """Async S7CommPlus client for S7-1200/1500 PLCs. - Supports V1 protocol. V2/V3/TLS planned for future. + Supports V1 and V2 protocols. V3/TLS planned for future. Uses asyncio for all I/O operations and asyncio.Lock for concurrent safety when shared between multiple coroutines. @@ -70,6 +71,11 @@ def __init__(self) -> None: self._rack: int = 0 self._slot: int = 1 + # V2+ IntegrityId tracking + self._integrity_id_read: int = 0 + self._integrity_id_write: int = 0 + self._with_integrity_id: bool = False + @property def connected(self) -> bool: if self._use_legacy_data and self._legacy_client is not None: @@ -189,6 +195,9 @@ async def disconnect(self) -> None: self._session_id = 0 self._sequence_number = 0 self._protocol_version = 0 + self._with_integrity_id = False + self._integrity_id_read = 0 + self._integrity_id_write = 0 if self._writer: try: @@ -283,31 +292,48 @@ async def explore(self) -> bytes: # -- Internal methods -- async def _send_request(self, function_code: int, payload: bytes) -> bytes: - """Send an S7CommPlus request and receive the response.""" + """Send an S7CommPlus request and receive the response. + + For V2+ with IntegrityId tracking, inserts IntegrityId after the + 14-byte request header and strips it from the response. + """ async with self._lock: if not self._connected or self._writer is None or self._reader is None: raise RuntimeError("Not connected") seq_num = self._next_sequence_number() - request = ( - struct.pack( - ">BHHHHIB", - Opcode.REQUEST, - 0x0000, - function_code, - 0x0000, - seq_num, - self._session_id, - 0x36, - ) - + payload + request_header = struct.pack( + ">BHHHHIB", + Opcode.REQUEST, + 0x0000, + function_code, + 0x0000, + seq_num, + self._session_id, + 0x36, ) + # For V2+ with IntegrityId, insert after header + integrity_id_bytes = b"" + if self._with_integrity_id and self._protocol_version >= ProtocolVersion.V2: + is_read = function_code in READ_FUNCTION_CODES + integrity_id = self._integrity_id_read if is_read else self._integrity_id_write + integrity_id_bytes = encode_uint32_vlq(integrity_id) + + request = request_header + integrity_id_bytes + payload + frame = encode_header(self._protocol_version, len(request)) + request frame += struct.pack(">BBH", 0x72, self._protocol_version, 0x0000) await self._send_cotp_dt(frame) + # Increment appropriate IntegrityId counter + if self._with_integrity_id and self._protocol_version >= ProtocolVersion.V2: + if function_code in READ_FUNCTION_CODES: + self._integrity_id_read = (self._integrity_id_read + 1) & 0xFFFFFFFF + else: + self._integrity_id_write = (self._integrity_id_write + 1) & 0xFFFFFFFF + response_data = await self._recv_cotp_dt() version, data_length, consumed = decode_header(response_data) @@ -316,7 +342,14 @@ async def _send_request(self, function_code: int, payload: bytes) -> bytes: if len(response) < 14: raise RuntimeError("Response too short") - return response[14:] + # For V2+, skip IntegrityId in response + resp_offset = 14 + if self._with_integrity_id and self._protocol_version >= ProtocolVersion.V2: + if resp_offset < len(response): + _resp_iid, iid_consumed = decode_uint32_vlq(response, resp_offset) + resp_offset += iid_consumed + + return response[resp_offset:] async def _cotp_connect(self, local_tsap: int, remote_tsap: bytes) -> None: """Perform COTP Connection Request / Confirm handshake.""" diff --git a/snap7/s7commplus/client.py b/snap7/s7commplus/client.py index d5b38a40..44112c9c 100644 --- a/snap7/s7commplus/client.py +++ b/snap7/s7commplus/client.py @@ -14,7 +14,7 @@ the client transparently falls back to the legacy S7 protocol for data block read/write operations. -Status: V1 connection is functional. V2/V3/TLS authentication planned. +Status: V1 and V2 connections are functional. V3/TLS authentication planned. Reference: thomas-v2/S7CommPlusDriver (C#, LGPL-3.0) """ @@ -108,6 +108,7 @@ def connect( tls_cert: Optional[str] = None, tls_key: Optional[str] = None, tls_ca: Optional[str] = None, + password: Optional[str] = None, ) -> None: """Connect to an S7-1200/1500 PLC using S7CommPlus. @@ -119,10 +120,11 @@ def connect( port: TCP port (default 102) rack: PLC rack number slot: PLC slot number - use_tls: Whether to attempt TLS (requires V3 PLC + certs) + use_tls: Whether to activate TLS (required for V2) 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) + password: PLC password for legitimation (V2+ with TLS) """ self._host = host self._port = port @@ -141,6 +143,11 @@ def connect( tls_ca=tls_ca, ) + # Handle legitimation for password-protected PLCs + if password is not None and self._connection.tls_active: + logger.info("Performing PLC legitimation (password authentication)") + self._connection.authenticate(password) + # Probe S7CommPlus data operations with a minimal request if not self._probe_s7commplus_data(): logger.info("S7CommPlus data operations not supported, falling back to legacy S7 protocol") diff --git a/snap7/s7commplus/connection.py b/snap7/s7commplus/connection.py index fbbaf60d..a60b44a0 100644 --- a/snap7/s7commplus/connection.py +++ b/snap7/s7commplus/connection.py @@ -53,6 +53,7 @@ ObjectId, S7COMMPLUS_LOCAL_TSAP, S7COMMPLUS_REMOTE_TSAP, + READ_FUNCTION_CODES, ) from .codec import encode_header, decode_header, encode_typed_value, encode_object_qualifier from .vlq import encode_uint32_vlq, decode_uint32_vlq, decode_uint64_vlq @@ -104,6 +105,7 @@ def __init__( ) self._ssl_context: Optional[ssl.SSLContext] = None + self._ssl_socket: Optional[ssl.SSLSocket] = None self._session_id: int = 0 self._sequence_number: int = 0 self._protocol_version: int = 0 # Detected from PLC response @@ -111,6 +113,14 @@ def __init__( self._connected = False self._server_session_version: Optional[int] = None + # V2+ IntegrityId tracking + self._integrity_id_read: int = 0 + self._integrity_id_write: int = 0 + self._with_integrity_id: bool = False + + # TLS OMS exporter secret (for legitimation key derivation) + self._oms_secret: Optional[bytes] = None + @property def connected(self) -> bool: return self._connected @@ -130,6 +140,21 @@ def tls_active(self) -> bool: """Whether TLS encryption is active on this connection.""" return self._tls_active + @property + def integrity_id_read(self) -> int: + """Current read IntegrityId counter (V2+).""" + return self._integrity_id_read + + @property + def integrity_id_write(self) -> int: + """Current write IntegrityId counter (V2+).""" + return self._integrity_id_write + + @property + def oms_secret(self) -> Optional[bytes]: + """OMS exporter secret from TLS session (for legitimation).""" + return self._oms_secret + def connect( self, timeout: float = 5.0, @@ -142,13 +167,15 @@ def connect( The connection sequence: 1. COTP connection (same as legacy S7comm) - 2. CreateObject to establish S7CommPlus session - 3. Protocol version is detected from PLC response - 4. If use_tls=True and PLC supports it, TLS is negotiated + 2. InitSSL handshake + 3. TLS activation (if use_tls=True, required for V2) + 4. CreateObject to establish S7CommPlus session + 5. Session setup (echo ServerSessionVersion) + 6. Enable IntegrityId tracking (V2+) Args: timeout: Connection timeout in seconds - use_tls: Whether to attempt TLS negotiation. + use_tls: Whether to activate TLS after InitSSL. 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) @@ -160,12 +187,12 @@ def connect( # Step 2: InitSSL handshake (required before CreateObject) self._init_ssl() - # Step 3: TLS activation (required for modern firmware) + # Step 3: TLS activation (between InitSSL and CreateObject) if use_tls: - # TODO: Perform TLS 1.3 handshake over the existing COTP connection - raise NotImplementedError("TLS activation is not yet implemented. Use use_tls=False for V1 connections.") + self._activate_tls(tls_cert=tls_cert, tls_key=tls_key, tls_ca=tls_ca) # Step 4: CreateObject (S7CommPlus session setup) + # CreateObject always uses V1 framing self._create_session() # Step 5: Session setup - echo ServerSessionVersion back to PLC @@ -174,26 +201,205 @@ def connect( else: logger.warning("PLC did not provide ServerSessionVersion - session setup incomplete") - # Step 6: Version-specific authentication + # Step 6: Version-specific post-setup if self._protocol_version >= ProtocolVersion.V3: if not use_tls: logger.warning( "PLC reports V3 protocol but TLS is not enabled. Connection may not work without use_tls=True." ) elif self._protocol_version == ProtocolVersion.V2: - # TODO: Proprietary HMAC-SHA256/AES session auth - raise NotImplementedError("V2 authentication is not yet implemented.") + if not self._tls_active: + from ..error import S7ConnectionError + + raise S7ConnectionError("PLC reports V2 protocol but TLS is not active. V2 requires TLS. Use use_tls=True.") + # Enable IntegrityId tracking for V2+ + self._with_integrity_id = True + self._integrity_id_read = 0 + self._integrity_id_write = 0 + logger.info("V2 IntegrityId tracking enabled") # V1: No further authentication needed after CreateObject self._connected = True logger.info( - f"S7CommPlus connected to {self.host}:{self.port}, version=V{self._protocol_version}, session={self._session_id}" + f"S7CommPlus connected to {self.host}:{self.port}, " + f"version=V{self._protocol_version}, session={self._session_id}, " + f"tls={self._tls_active}" ) except Exception: self.disconnect() raise + def authenticate(self, password: str, username: str = "") -> None: + """Perform PLC password authentication (legitimation). + + Must be called after connect() and before data operations on + password-protected PLCs. Requires TLS to be active (V2+). + + The method auto-detects legacy vs new legitimation based on + the PLC's firmware version (stored in ServerSessionVersion). + + Args: + password: PLC password + username: Username for new-style auth (optional) + + Raises: + S7ConnectionError: If not connected, TLS not active, or auth fails + """ + if not self._connected: + from ..error import S7ConnectionError + + raise S7ConnectionError("Not connected") + + if not self._tls_active or self._oms_secret is None: + from ..error import S7ConnectionError + + raise S7ConnectionError("Legitimation requires TLS. Connect with use_tls=True.") + + # Step 1: Get challenge from PLC via GetVarSubStreamed + challenge = self._get_legitimation_challenge() + logger.info(f"Received legitimation challenge ({len(challenge)} bytes)") + + # Step 2: Build response (auto-detect legacy vs new) + from .legitimation import build_legacy_response, build_new_response + + if username: + # New-style auth with username always uses AES-256-CBC + response_data = build_new_response(password, challenge, self._oms_secret, username) + self._send_legitimation_new(response_data) + else: + # Try new-style first, fall back to legacy SHA-1 XOR + try: + response_data = build_new_response(password, challenge, self._oms_secret, "") + self._send_legitimation_new(response_data) + except NotImplementedError: + # cryptography package not available, use legacy + response_data = build_legacy_response(password, challenge) + self._send_legitimation_legacy(response_data) + + logger.info("PLC legitimation completed successfully") + + def _get_legitimation_challenge(self) -> bytes: + """Request legitimation challenge from PLC. + + Sends GetVarSubStreamed with address ServerSessionRequest (303). + + Returns: + Challenge bytes from PLC (typically 20 bytes) + """ + from .protocol import LegitimationId + + # Build GetVarSubStreamed request + payload = bytearray() + # InObjectId = session ID + payload += struct.pack(">I", self._session_id) + # Item count = 1 + payload += encode_uint32_vlq(1) + # Address field count = 1 + payload += encode_uint32_vlq(1) + # Address = ServerSessionRequest (303) + payload += encode_uint32_vlq(LegitimationId.SERVER_SESSION_REQUEST) + # Trailing padding + payload += struct.pack(">I", 0) + + resp_payload = self.send_request(FunctionCode.GET_VAR_SUBSTREAMED, bytes(payload)) + + # Parse response: return value + value list + offset = 0 + return_value, consumed = decode_uint64_vlq(resp_payload, offset) + offset += consumed + + if return_value != 0: + from ..error import S7ConnectionError + + raise S7ConnectionError(f"GetVarSubStreamed for challenge failed: return_value={return_value}") + + # Value is a USIntArray (BLOB) - read flags + type + length + data + if offset + 2 > len(resp_payload): + from ..error import S7ConnectionError + + raise S7ConnectionError("Challenge response too short") + + _flags = resp_payload[offset] + datatype = resp_payload[offset + 1] + offset += 2 + + from .protocol import DataType + + if datatype == DataType.BLOB: + length, consumed = decode_uint32_vlq(resp_payload, offset) + offset += consumed + return bytes(resp_payload[offset : offset + length]) + else: + # Try reading as array of USINT + count, consumed = decode_uint32_vlq(resp_payload, offset) + offset += consumed + return bytes(resp_payload[offset : offset + count]) + + def _send_legitimation_new(self, encrypted_response: bytes) -> None: + """Send new-style legitimation response (AES-256-CBC encrypted). + + Uses SetVariable with address Legitimate (1846). + """ + from .protocol import LegitimationId, DataType + + payload = bytearray() + # InObjectId = session ID + payload += struct.pack(">I", self._session_id) + # Address field count = 1 + payload += encode_uint32_vlq(1) + # Address = Legitimate (1846) + payload += encode_uint32_vlq(LegitimationId.LEGITIMATE) + # Value: BLOB(0, encrypted_response) + payload += bytes([0x00, DataType.BLOB]) + payload += encode_uint32_vlq(len(encrypted_response)) + payload += encrypted_response + # Trailing padding + payload += struct.pack(">I", 0) + + resp_payload = self.send_request(FunctionCode.SET_VARIABLE, bytes(payload)) + + # Check return value + if len(resp_payload) >= 1: + return_value, _ = decode_uint64_vlq(resp_payload, 0) + if return_value < 0: + from ..error import S7ConnectionError + + raise S7ConnectionError(f"Legitimation rejected by PLC: return_value={return_value}") + logger.debug(f"New legitimation return_value={return_value}") + + def _send_legitimation_legacy(self, response: bytes) -> None: + """Send legacy legitimation response (SHA-1 XOR). + + Uses SetVariable with address ServerSessionResponse (304). + """ + from .protocol import LegitimationId, DataType + + payload = bytearray() + # InObjectId = session ID + payload += struct.pack(">I", self._session_id) + # Address field count = 1 + payload += encode_uint32_vlq(1) + # Address = ServerSessionResponse (304) + payload += encode_uint32_vlq(LegitimationId.SERVER_SESSION_RESPONSE) + # Value: array of USINT (the XOR'd response bytes) + payload += bytes([0x10, DataType.USINT]) # flags=0x10 (array) + payload += encode_uint32_vlq(len(response)) + payload += response + # Trailing padding + payload += struct.pack(">I", 0) + + resp_payload = self.send_request(FunctionCode.SET_VARIABLE, bytes(payload)) + + # Check return value + if len(resp_payload) >= 1: + return_value, _ = decode_uint64_vlq(resp_payload, 0) + if return_value < 0: + from ..error import S7ConnectionError + + raise S7ConnectionError(f"Legacy legitimation rejected by PLC: return_value={return_value}") + logger.debug(f"Legacy legitimation return_value={return_value}") + def disconnect(self) -> None: """Disconnect from PLC.""" if self._connected and self._session_id: @@ -204,15 +410,24 @@ def disconnect(self) -> None: self._connected = False self._tls_active = False + self._ssl_socket = None + self._oms_secret = None self._session_id = 0 self._sequence_number = 0 self._protocol_version = 0 self._server_session_version = None + self._with_integrity_id = False + self._integrity_id_read = 0 + self._integrity_id_write = 0 self._iso_conn.disconnect() def send_request(self, function_code: int, payload: bytes = b"") -> bytes: """Send an S7CommPlus request and receive the response. + For V2+ with IntegrityId tracking enabled, the IntegrityId is + appended after the 14-byte request header (as a VLQ uint32). + Read vs write counters are selected based on the function code. + Args: function_code: S7CommPlus function code payload: Request payload (after the 14-byte request header) @@ -227,7 +442,7 @@ def send_request(self, function_code: int, payload: bytes = b"") -> bytes: seq_num = self._next_sequence_number() - # Build request header + # Build request header (14 bytes) request_header = struct.pack( ">BHHHHIB", Opcode.REQUEST, @@ -238,19 +453,43 @@ def send_request(self, function_code: int, payload: bytes = b"") -> bytes: self._session_id, 0x36, # Transport flags ) - request = request_header + payload + + # For V2+ with IntegrityId enabled, insert IntegrityId after header + integrity_id_bytes = b"" + if self._with_integrity_id and self._protocol_version >= ProtocolVersion.V2: + is_read = function_code in READ_FUNCTION_CODES + if is_read: + integrity_id = self._integrity_id_read + else: + integrity_id = self._integrity_id_write + integrity_id_bytes = encode_uint32_vlq(integrity_id) + logger.debug(f" IntegrityId: {'read' if is_read else 'write'}={integrity_id}") + + request = request_header + integrity_id_bytes + payload logger.debug(f"=== SEND REQUEST === function_code=0x{function_code:04X} seq={seq_num} session=0x{self._session_id:08X}") logger.debug(f" Request header (14 bytes): {request_header.hex(' ')}") + if integrity_id_bytes: + logger.debug(f" IntegrityId ({len(integrity_id_bytes)} bytes): {integrity_id_bytes.hex(' ')}") logger.debug(f" Request payload ({len(payload)} bytes): {payload.hex(' ')}") + # Determine frame version: V2 data PDUs use V2, but CreateObject uses V1 + frame_version = self._protocol_version + # Add S7CommPlus frame header and trailer, then send - frame = encode_header(self._protocol_version, len(request)) + request - frame += struct.pack(">BBH", 0x72, self._protocol_version, 0x0000) + frame = encode_header(frame_version, len(request)) + request + frame += struct.pack(">BBH", 0x72, frame_version, 0x0000) logger.debug(f" Full frame ({len(frame)} bytes): {frame.hex(' ')}") self._iso_conn.send_data(frame) + # Increment the appropriate IntegrityId counter after sending + if self._with_integrity_id and self._protocol_version >= ProtocolVersion.V2: + if function_code in READ_FUNCTION_CODES: + self._integrity_id_read = (self._integrity_id_read + 1) & 0xFFFFFFFF + else: + self._integrity_id_write = (self._integrity_id_write + 1) & 0xFFFFFFFF + # Receive response response_frame = self._iso_conn.receive_data() logger.debug(f"=== RECV RESPONSE === raw frame ({len(response_frame)} bytes): {response_frame.hex(' ')}") @@ -278,7 +517,15 @@ def send_request(self, function_code: int, payload: bytes = b"") -> bytes: f"seq={resp_seq} session=0x{resp_session:08X} transport=0x{resp_transport:02X}" ) - resp_payload = response[14:] + # For V2+ responses, skip IntegrityId in response before returning payload + resp_offset = 14 + if self._with_integrity_id and self._protocol_version >= ProtocolVersion.V2: + if resp_offset < len(response): + resp_integrity_id, iid_consumed = decode_uint32_vlq(response, resp_offset) + resp_offset += iid_consumed + logger.debug(f" Response IntegrityId: {resp_integrity_id}") + + resp_payload = response[resp_offset:] logger.debug(f" Response payload ({len(resp_payload)} bytes): {resp_payload.hex(' ')}") # Check for trailer bytes after data_length @@ -701,6 +948,53 @@ def _next_sequence_number(self) -> int: self._sequence_number = (self._sequence_number + 1) & 0xFFFF return seq + def _activate_tls( + self, + tls_cert: Optional[str] = None, + tls_key: Optional[str] = None, + tls_ca: Optional[str] = None, + ) -> None: + """Activate TLS 1.3 over the COTP connection. + + Called after InitSSL and before CreateObject. Wraps the underlying + TCP socket with TLS and extracts the OMS exporter secret for + legitimation key derivation. + + 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) + """ + ctx = self._setup_ssl_context( + cert_path=tls_cert, + key_path=tls_key, + ca_path=tls_ca, + ) + + # Wrap the raw TCP socket used by ISOTCPConnection + raw_socket = self._iso_conn.socket + if raw_socket is None: + from ..error import S7ConnectionError + + raise S7ConnectionError("Cannot activate TLS: no TCP socket") + + self._ssl_socket = ctx.wrap_socket(raw_socket, server_hostname=self.host) + + # Replace the socket in ISOTCPConnection so all subsequent + # send_data/receive_data calls go through TLS + self._iso_conn.socket = self._ssl_socket + self._tls_active = True + + # Extract OMS exporter secret for legitimation key derivation + try: + self._oms_secret = self._ssl_socket.export_keying_material("EXPERIMENTAL_OMS", 32, None) + logger.debug("OMS exporter secret extracted from TLS session") + except (AttributeError, ssl.SSLError) as e: + logger.warning(f"Could not extract OMS exporter secret: {e}") + self._oms_secret = None + + logger.info("TLS 1.3 activated on COTP connection") + def _setup_ssl_context( self, cert_path: Optional[str] = None, @@ -719,6 +1013,7 @@ def _setup_ssl_context( """ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) ctx.minimum_version = ssl.TLSVersion.TLSv1_3 + ctx.set_ciphers("TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256") if cert_path and key_path: ctx.load_cert_chain(cert_path, key_path) diff --git a/snap7/s7commplus/legitimation.py b/snap7/s7commplus/legitimation.py new file mode 100644 index 00000000..2c8e197e --- /dev/null +++ b/snap7/s7commplus/legitimation.py @@ -0,0 +1,154 @@ +"""S7CommPlus PLC password authentication (legitimation). + +Supports two authentication modes: +- Legacy: SHA-1 password hash XORed with challenge (older firmware) +- New: AES-256-CBC encrypted credentials with TLS-derived key (newer firmware) + +Firmware version determines which mode is used: +- S7-1500: FW >= 3.01 = new, FW 2.09-2.99 = legacy +- S7-1200: FW >= 4.07 = new, FW 4.03-4.06 = legacy + +Note: The "new" mode requires the ``cryptography`` package for AES-256-CBC. +Install with ``pip install cryptography``. The legacy mode uses only stdlib. +""" + +import hashlib +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + + +def derive_legitimation_key(oms_secret: bytes) -> bytes: + """Derive AES-256 key from TLS OMS exporter secret. + + Args: + oms_secret: 32-byte OMS exporter secret from TLS session + + Returns: + 32-byte AES-256 key + """ + return hashlib.sha256(oms_secret).digest() + + +def build_legacy_response(password: str, challenge: bytes) -> bytes: + """Build legacy legitimation response (SHA-1 XOR). + + Args: + password: PLC password + challenge: 20-byte challenge from PLC + + Returns: + Response bytes (SHA-1 hash XORed with challenge) + """ + password_hash = hashlib.sha1(password.encode("utf-8")).digest() # noqa: S324 + return bytes(a ^ b for a, b in zip(password_hash, challenge[:20])) + + +def build_new_response( + password: str, + challenge: bytes, + oms_secret: bytes, + username: str = "", +) -> bytes: + """Build new legitimation response (AES-256-CBC encrypted). + + Requires the ``cryptography`` package. + + Args: + password: PLC password + challenge: Challenge from PLC (first 16 bytes used as IV) + oms_secret: 32-byte OMS exporter secret + username: Optional username (empty string for legacy-style new auth) + + Returns: + AES-256-CBC encrypted response + + Raises: + NotImplementedError: If ``cryptography`` is not installed + """ + try: + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + from cryptography.hazmat.primitives import padding + except ImportError: + raise NotImplementedError( + "AES-256-CBC legitimation requires the 'cryptography' package. Install with: pip install python-snap7[s7commplus]" + ) + + key = derive_legitimation_key(oms_secret) + iv = bytes(challenge[:16]) + + payload = _build_legitimation_payload(password, username) + + padder = padding.PKCS7(128).padder() + padded = padder.update(payload) + padder.finalize() + + cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) + encryptor = cipher.encryptor() + result: bytes = encryptor.update(padded) + encryptor.finalize() + return result + + +def _build_legitimation_payload(password: str, username: str = "") -> bytes: + """Build the legitimation payload structure. + + The payload is a serialized ValueStruct with: + - 40401: LegitimationType (1=legacy, 2=new) + - 40402: Username (UTF-8 blob) + - 40403: Password or password hash (SHA-1) + """ + from .vlq import encode_uint32_vlq + from .protocol import DataType + + result = bytearray() + + if username: + legit_type = 2 + password_data = password.encode("utf-8") + else: + legit_type = 1 + password_data = hashlib.sha1(password.encode("utf-8")).digest() # noqa: S324 + + username_data = username.encode("utf-8") + + # Struct with 3 elements + result += bytes([0x00, DataType.STRUCT]) + result += encode_uint32_vlq(3) + + # Element 1: LegitimationType + result += bytes([0x00, DataType.UDINT]) + result += encode_uint32_vlq(legit_type) + + # Element 2: Username blob + result += bytes([0x00, DataType.BLOB]) + result += encode_uint32_vlq(len(username_data)) + result += username_data + + # Element 3: Password blob + result += bytes([0x00, DataType.BLOB]) + result += encode_uint32_vlq(len(password_data)) + result += password_data + + return bytes(result) + + +class LegitimationState: + """Tracks legitimation state for a connection.""" + + def __init__(self, oms_secret: Optional[bytes] = None) -> None: + self._oms_key: Optional[bytes] = None + if oms_secret: + self._oms_key = derive_legitimation_key(oms_secret) + self._authenticated = False + + @property + def authenticated(self) -> bool: + return self._authenticated + + def mark_authenticated(self) -> None: + self._authenticated = True + + def rotate_key(self) -> None: + """Rotate the OMS-derived key (called after each legitimation).""" + if self._oms_key: + self._oms_key = hashlib.sha256(self._oms_key).digest() diff --git a/snap7/s7commplus/protocol.py b/snap7/s7commplus/protocol.py index 2095cb29..b5af76b2 100644 --- a/snap7/s7commplus/protocol.py +++ b/snap7/s7commplus/protocol.py @@ -171,6 +171,30 @@ class Ids(IntEnum): DB_ACCESS_AREA_BASE = 0x8A0E0000 +# Function codes that use the READ IntegrityId counter (V2+) +READ_FUNCTION_CODES: frozenset[int] = frozenset( + { + FunctionCode.GET_MULTI_VARIABLES, + FunctionCode.EXPLORE, + FunctionCode.GET_VAR_SUBSTREAMED, + FunctionCode.GET_LINK, + FunctionCode.GET_VARIABLE, + FunctionCode.GET_VARIABLES_ADDRESS, + } +) + + +class LegitimationId(IntEnum): + """Legitimation IDs used in password authentication (V2+). + + Reference: thomas-v2/S7CommPlusDriver + """ + + SERVER_SESSION_REQUEST = 303 + SERVER_SESSION_RESPONSE = 304 + LEGITIMATE = 1846 + + class SoftDataType(IntEnum): """PLC soft data types (used in variable metadata / tag descriptions). diff --git a/snap7/s7commplus/server.py b/snap7/s7commplus/server.py index cc08a057..2af0d769 100644 --- a/snap7/s7commplus/server.py +++ b/snap7/s7commplus/server.py @@ -8,11 +8,9 @@ - Explore (browse registered data blocks and variables) - GetMultiVariables / SetMultiVariables (read/write by address) - Internal PLC memory model with thread-safe access +- V2 protocol emulation with TLS and IntegrityId tracking -This server does NOT implement TLS or the proprietary authentication -layers (V2/V3 crypto). It emulates a V1 PLC for testing purposes, -which is sufficient for validating protocol framing, data encoding, -and client logic. +Supports both V1 (no TLS) and V2 (TLS + IntegrityId) emulation. Usage:: @@ -20,13 +18,14 @@ server.register_db(1, {"temperature": ("Real", 0), "pressure": ("Real", 4)}) server.start(port=11020) - # ... run tests against localhost:11020 ... - - server.stop() + # V2 server with TLS: + server = S7CommPlusServer(protocol_version=ProtocolVersion.V2) + server.start(port=11020, use_tls=True, tls_cert="cert.pem", tls_key="key.pem") """ import logging import socket +import ssl import struct import threading from enum import IntEnum @@ -38,6 +37,7 @@ FunctionCode, Opcode, ProtocolVersion, + READ_FUNCTION_CODES, SoftDataType, ) from .vlq import encode_uint32_vlq, decode_uint32_vlq, encode_uint64_vlq @@ -186,15 +186,16 @@ class S7CommPlusServer: Emulates an S7-1200/1500 PLC with: - Internal data block storage with named variables - - S7CommPlus protocol handling (V1 level) + - S7CommPlus protocol handling (V1 and V2) + - V2 TLS support with IntegrityId tracking - Multi-client support (threaded) - CPU state management """ - def __init__(self) -> None: + def __init__(self, protocol_version: int = ProtocolVersion.V1) -> None: self._data_blocks: dict[int, DataBlock] = {} self._cpu_state = CPUState.RUN - self._protocol_version = ProtocolVersion.V1 + self._protocol_version = protocol_version self._next_session_id = 1 self._server_socket: Optional[socket.socket] = None @@ -204,6 +205,10 @@ def __init__(self) -> None: self._lock = threading.Lock() self._event_callback: Optional[Callable[..., None]] = None + # TLS configuration (V2) + self._ssl_context: Optional[ssl.SSLContext] = None + self._use_tls: bool = False + @property def cpu_state(self) -> CPUState: return self._cpu_state @@ -258,16 +263,41 @@ def get_db(self, db_number: int) -> Optional[DataBlock]: """Get a registered data block.""" return self._data_blocks.get(db_number) - def start(self, host: str = "127.0.0.1", port: int = 11020) -> None: + def start( + self, + host: str = "0.0.0.0", + port: int = 11020, + use_tls: bool = False, + tls_cert: Optional[str] = None, + tls_key: Optional[str] = None, + tls_ca: Optional[str] = None, + ) -> None: """Start the server. Args: host: Bind address port: TCP port to listen on + use_tls: Whether to wrap client sockets with TLS after InitSSL + tls_cert: Path to server TLS certificate (PEM) + tls_key: Path to server private key (PEM) + tls_ca: Path to CA certificate for client verification (PEM) """ if self._running: raise RuntimeError("Server is already running") + self._use_tls = use_tls + if use_tls: + 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.load_cert_chain(tls_cert, tls_key) + if tls_ca: + self._ssl_context.load_verify_locations(tls_ca) + self._ssl_context.verify_mode = ssl.CERT_REQUIRED + else: + self._ssl_context.verify_mode = ssl.CERT_NONE + self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self._server_socket.settimeout(1.0) @@ -277,7 +307,7 @@ def start(self, host: str = "127.0.0.1", port: int = 11020) -> None: self._running = True self._server_thread = threading.Thread(target=self._server_loop, daemon=True, name="s7commplus-server") self._server_thread.start() - logger.info(f"S7CommPlus server started on {host}:{port}") + logger.info(f"S7CommPlus server started on {host}:{port} (TLS={use_tls}, V{self._protocol_version})") def stop(self) -> None: """Stop the server.""" @@ -332,6 +362,10 @@ def _handle_client(self, client_sock: socket.socket, address: tuple[str, int]) - # Step 2: S7CommPlus session session_id = 0 + tls_activated = False + # Per-client IntegrityId tracking (V2+) + integrity_id_read = 0 + integrity_id_write = 0 while self._running: try: @@ -341,16 +375,53 @@ def _handle_client(self, client_sock: socket.socket, address: tuple[str, int]) - break # Process the S7CommPlus request - response = self._process_request(data, session_id) + response = self._process_request(data, session_id, integrity_id_read, integrity_id_write) if response is not None: # Check if session ID was assigned if session_id == 0 and len(response) >= 14: - # Extract session ID from response for tracking session_id = struct.unpack_from(">I", response, 9)[0] self._send_s7commplus_frame(client_sock, response) + # After InitSSL response, activate TLS if configured + if ( + not tls_activated + and self._use_tls + and self._ssl_context is not None + and data is not None + and len(data) >= 8 + ): + # Check if this was an InitSSL request + try: + _, _, hdr_consumed = decode_header(data) + payload = data[hdr_consumed:] + if len(payload) >= 14: + func_code = struct.unpack_from(">H", payload, 3)[0] + if func_code == FunctionCode.INIT_SSL: + client_sock = self._ssl_context.wrap_socket(client_sock, server_side=True) + tls_activated = True + logger.debug(f"TLS activated for client {address}") + except (ValueError, struct.error): + pass + + # Update IntegrityId counters based on function code (V2+) + if self._protocol_version >= ProtocolVersion.V2 and session_id != 0: + try: + _, _, hdr_consumed = decode_header(data) + payload = data[hdr_consumed:] + if len(payload) >= 14: + func_code = struct.unpack_from(">H", payload, 3)[0] + if func_code in READ_FUNCTION_CODES: + integrity_id_read = (integrity_id_read + 1) & 0xFFFFFFFF + elif func_code not in ( + FunctionCode.INIT_SSL, + FunctionCode.CREATE_OBJECT, + ): + integrity_id_write = (integrity_id_write + 1) & 0xFFFFFFFF + except (ValueError, struct.error): + pass + except socket.timeout: continue except (ConnectionError, OSError): @@ -445,7 +516,13 @@ def _send_s7commplus_frame(self, sock: socket.socket, data: bytes) -> None: tpkt = struct.pack(">BBH", 3, 0, 4 + len(cotp_dt)) + cotp_dt sock.sendall(tpkt) - def _process_request(self, data: bytes, session_id: int) -> Optional[bytes]: + def _process_request( + self, + data: bytes, + session_id: int, + integrity_id_read: int = 0, + integrity_id_write: int = 0, + ) -> Optional[bytes]: """Process an S7CommPlus request and return a response.""" if len(data) < 4: return None @@ -469,7 +546,19 @@ def _process_request(self, data: bytes, session_id: int) -> Optional[bytes]: function_code = struct.unpack_from(">H", payload, 3)[0] seq_num = struct.unpack_from(">H", payload, 7)[0] req_session_id = struct.unpack_from(">I", payload, 9)[0] - request_data = payload[14:] + + # For V2+, skip IntegrityId after the 14-byte header + request_offset = 14 + if ( + self._protocol_version >= ProtocolVersion.V2 + and session_id != 0 + and function_code not in (FunctionCode.INIT_SSL, FunctionCode.CREATE_OBJECT) + ): + if request_offset < len(payload): + _req_iid, iid_consumed = decode_uint32_vlq(payload, request_offset) + request_offset += iid_consumed + + request_data = payload[request_offset:] if function_code == FunctionCode.INIT_SSL: return self._handle_init_ssl(seq_num) @@ -486,6 +575,40 @@ def _process_request(self, data: bytes, session_id: int) -> Optional[bytes]: else: return self._build_error_response(seq_num, req_session_id, function_code) + def _build_response_header( + self, + function_code: int, + seq_num: int, + session_id: int, + include_integrity_id: bool = False, + integrity_id: int = 0, + ) -> bytes: + """Build a 14-byte response header, optionally with IntegrityId (V2+). + + Args: + function_code: Response function code + seq_num: Sequence number echoed from request + session_id: Session ID + include_integrity_id: If True, append VLQ IntegrityId after header + integrity_id: IntegrityId value to include + + Returns: + Response header bytes (14 bytes, or 14+VLQ for V2+) + """ + header = struct.pack( + ">BHHHHIB", + Opcode.RESPONSE, + 0x0000, + function_code, + 0x0000, + seq_num, + session_id, + 0x00, + ) + if include_integrity_id: + header += encode_uint32_vlq(integrity_id) + return header + def _handle_init_ssl(self, seq_num: int) -> bytes: """Handle InitSSL -- respond to SSL initialization (V1 emulation, no real TLS).""" response = bytearray() @@ -544,6 +667,14 @@ def _handle_create_object(self, seq_num: int, request_data: bytes) -> bytes: response += encode_uint32_vlq(0x0132) # Protocol version attribute response += encode_typed_value(DataType.USINT, self._protocol_version) + # ServerSessionVersion attribute (306) - required for session setup handshake + from .protocol import ObjectId + + response += bytes([ElementID.ATTRIBUTE]) + response += encode_uint32_vlq(ObjectId.SERVER_SESSION_VERSION) + response += bytes([0x00]) # flags + response += encode_typed_value(DataType.UDINT, self._protocol_version) + response += bytes([ElementID.TERMINATING_OBJECT]) # Trailing zeros diff --git a/tests/test_s7commplus_v2.py b/tests/test_s7commplus_v2.py new file mode 100644 index 00000000..1a9fc8e7 --- /dev/null +++ b/tests/test_s7commplus_v2.py @@ -0,0 +1,345 @@ +"""Tests for S7CommPlus V2 protocol support. + +Tests IntegrityId tracking, legitimation helpers, protocol constants, +and V2 connection behavior. +""" + +import hashlib + +import pytest + +from snap7.s7commplus.protocol import ( + FunctionCode, + LegitimationId, + ProtocolVersion, + READ_FUNCTION_CODES, +) +from snap7.s7commplus.legitimation import ( + LegitimationState, + build_legacy_response, + derive_legitimation_key, + _build_legitimation_payload, +) +from snap7.s7commplus.vlq import encode_uint32_vlq, decode_uint32_vlq +from snap7.s7commplus.connection import S7CommPlusConnection + + +class TestReadFunctionCodes: + """Test READ_FUNCTION_CODES classification.""" + + def test_get_multi_variables_is_read(self) -> None: + assert FunctionCode.GET_MULTI_VARIABLES in READ_FUNCTION_CODES + + def test_explore_is_read(self) -> None: + assert FunctionCode.EXPLORE in READ_FUNCTION_CODES + + def test_get_var_substreamed_is_read(self) -> None: + assert FunctionCode.GET_VAR_SUBSTREAMED in READ_FUNCTION_CODES + + def test_get_link_is_read(self) -> None: + assert FunctionCode.GET_LINK in READ_FUNCTION_CODES + + def test_get_variable_is_read(self) -> None: + assert FunctionCode.GET_VARIABLE in READ_FUNCTION_CODES + + def test_get_variables_address_is_read(self) -> None: + assert FunctionCode.GET_VARIABLES_ADDRESS in READ_FUNCTION_CODES + + def test_set_multi_variables_is_write(self) -> None: + assert FunctionCode.SET_MULTI_VARIABLES not in READ_FUNCTION_CODES + + def test_set_variable_is_write(self) -> None: + assert FunctionCode.SET_VARIABLE not in READ_FUNCTION_CODES + + def test_create_object_is_write(self) -> None: + assert FunctionCode.CREATE_OBJECT not in READ_FUNCTION_CODES + + def test_delete_object_is_write(self) -> None: + assert FunctionCode.DELETE_OBJECT not in READ_FUNCTION_CODES + + +class TestLegitimationId: + """Test legitimation ID constants.""" + + def test_server_session_request(self) -> None: + assert int(LegitimationId.SERVER_SESSION_REQUEST) == 303 + + def test_server_session_response(self) -> None: + assert int(LegitimationId.SERVER_SESSION_RESPONSE) == 304 + + def test_legitimate(self) -> None: + assert int(LegitimationId.LEGITIMATE) == 1846 + + +class TestDeriveKey: + """Test OMS key derivation.""" + + def test_derive_returns_32_bytes(self) -> None: + secret = b"\x00" * 32 + key = derive_legitimation_key(secret) + assert len(key) == 32 + + def test_derive_is_sha256(self) -> None: + secret = b"test_oms_secret_material_32byte!" + key = derive_legitimation_key(secret) + expected = hashlib.sha256(secret).digest() + assert key == expected + + def test_different_secrets_different_keys(self) -> None: + key1 = derive_legitimation_key(b"\x00" * 32) + key2 = derive_legitimation_key(b"\x01" * 32) + assert key1 != key2 + + +class TestLegacyResponse: + """Test legacy legitimation (SHA-1 XOR).""" + + def test_legacy_response_length(self) -> None: + challenge = b"\x00" * 20 + response = build_legacy_response("password", challenge) + assert len(response) == 20 + + def test_legacy_response_xor(self) -> None: + password = "test" + challenge = b"\xff" * 20 + response = build_legacy_response(password, challenge) + password_hash = hashlib.sha1(password.encode("utf-8")).digest() # noqa: S324 + # XOR with 0xFF should flip all bits + expected = bytes(h ^ 0xFF for h in password_hash) + assert response == expected + + def test_legacy_response_zero_challenge(self) -> None: + password = "hello" + challenge = b"\x00" * 20 + response = build_legacy_response(password, challenge) + # XOR with zeros = original hash + expected = hashlib.sha1(password.encode("utf-8")).digest() # noqa: S324 + assert response == expected + + +class TestLegitimationPayload: + """Test legitimation payload building.""" + + def test_payload_without_username(self) -> None: + payload = _build_legitimation_payload("password") + assert len(payload) > 0 + # Should contain struct header + assert payload[1] == 0x17 # DataType.STRUCT + + def test_payload_with_username(self) -> None: + payload = _build_legitimation_payload("password", "admin") + assert len(payload) > 0 + + def test_payload_legit_type_1_without_username(self) -> None: + """Without username, legitimation type should be 1 (legacy).""" + payload = _build_legitimation_payload("password") + # After struct header (flags=0x00, type=0x17, count VLQ), the first + # element is flags=0x00, type=UDInt(0x04), then legit_type value + # The exact structure: [0x00, 0x17, count, 0x00, 0x04, legit_type, ...] + # legit_type=1 is at offset 5 (VLQ encoded) + assert payload[4] == 0x04 # UDInt type for legit_type + assert payload[5] == 0x01 # legit_type = 1 + + def test_payload_legit_type_2_with_username(self) -> None: + """With username, legitimation type should be 2 (new).""" + payload = _build_legitimation_payload("password", "admin") + assert payload[4] == 0x04 # UDInt type for legit_type + assert payload[5] == 0x02 # legit_type = 2 + + +class TestLegitimationState: + """Test LegitimationState tracker.""" + + def test_initial_state_not_authenticated(self) -> None: + state = LegitimationState() + assert not state.authenticated + + def test_mark_authenticated(self) -> None: + state = LegitimationState() + state.mark_authenticated() + assert state.authenticated + + def test_with_oms_secret(self) -> None: + state = LegitimationState(oms_secret=b"\x00" * 32) + assert not state.authenticated + + def test_rotate_key(self) -> None: + state = LegitimationState(oms_secret=b"\x00" * 32) + # Should not raise + state.rotate_key() + + def test_rotate_key_without_secret(self) -> None: + state = LegitimationState() + # Should not raise even without OMS secret + state.rotate_key() + + +class TestIntegrityIdTracking: + """Test IntegrityId counter logic in S7CommPlusConnection.""" + + def test_initial_counters_zero(self) -> None: + conn = S7CommPlusConnection("127.0.0.1") + assert conn.integrity_id_read == 0 + assert conn.integrity_id_write == 0 + + def test_connection_attributes(self) -> None: + conn = S7CommPlusConnection("127.0.0.1") + assert conn.oms_secret is None + assert not conn.tls_active + + def test_protocol_version_default(self) -> None: + conn = S7CommPlusConnection("127.0.0.1") + assert conn.protocol_version == 0 + + +class TestIntegrityIdVlqEncoding: + """Test VLQ encoding used for IntegrityId values.""" + + def test_encode_zero(self) -> None: + assert encode_uint32_vlq(0) == b"\x00" + + def test_encode_small(self) -> None: + encoded = encode_uint32_vlq(42) + value, _ = decode_uint32_vlq(encoded) + assert value == 42 + + def test_encode_large(self) -> None: + encoded = encode_uint32_vlq(0xFFFFFFFF) + value, _ = decode_uint32_vlq(encoded) + assert value == 0xFFFFFFFF + + def test_roundtrip_integrity_range(self) -> None: + """Test encoding/decoding typical IntegrityId counter values.""" + for val in [0, 1, 127, 128, 255, 1000, 65535, 0x7FFFFFFF]: + encoded = encode_uint32_vlq(val) + decoded, consumed = decode_uint32_vlq(encoded) + assert decoded == val + assert consumed == len(encoded) + + +class TestProtocolVersionV2: + """Test V2 protocol version constant.""" + + def test_v2_value(self) -> None: + assert int(ProtocolVersion.V2) == 0x02 + + def test_v2_greater_than_v1(self) -> None: + assert ProtocolVersion.V2 > ProtocolVersion.V1 + + def test_v2_less_than_v3(self) -> None: + assert ProtocolVersion.V2 < ProtocolVersion.V3 + + +try: + import cryptography # noqa: F401 + + _has_cryptography = True +except ImportError: + _has_cryptography = False + + +@pytest.mark.skipif(not _has_cryptography, reason="requires cryptography package") +class TestBuildNewResponse: + """Test AES-256-CBC legitimation response building.""" + + def test_new_response_returns_bytes(self) -> None: + from snap7.s7commplus.legitimation import build_new_response + + result = build_new_response( + password="test", + challenge=b"\x00" * 16, + oms_secret=b"\x00" * 32, + ) + assert isinstance(result, bytes) + + def test_new_response_is_aes_block_aligned(self) -> None: + from snap7.s7commplus.legitimation import build_new_response + + result = build_new_response( + password="test", + challenge=b"\x00" * 16, + oms_secret=b"\x00" * 32, + ) + # AES-CBC output is always a multiple of 16 bytes + assert len(result) % 16 == 0 + + def test_new_response_different_passwords_differ(self) -> None: + from snap7.s7commplus.legitimation import build_new_response + + challenge = b"\xab" * 16 + oms = b"\xcd" * 32 + r1 = build_new_response("password1", challenge, oms) + r2 = build_new_response("password2", challenge, oms) + assert r1 != r2 + + def test_new_response_different_secrets_differ(self) -> None: + from snap7.s7commplus.legitimation import build_new_response + + challenge = b"\xab" * 16 + r1 = build_new_response("test", challenge, b"\x00" * 32) + r2 = build_new_response("test", challenge, b"\x01" * 32) + assert r1 != r2 + + def test_new_response_with_username(self) -> None: + from snap7.s7commplus.legitimation import build_new_response + + result = build_new_response( + password="test", + challenge=b"\x00" * 16, + oms_secret=b"\x00" * 32, + username="admin", + ) + assert isinstance(result, bytes) + assert len(result) % 16 == 0 + + def test_new_response_decryptable(self) -> None: + """Verify the response can be decrypted back to the original payload.""" + from snap7.s7commplus.legitimation import ( + build_new_response, + derive_legitimation_key, + _build_legitimation_payload, + ) + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + from cryptography.hazmat.primitives import padding + + challenge = b"\x12\x34\x56\x78" * 4 # 16-byte IV + oms_secret = b"\xaa\xbb\xcc\xdd" * 8 # 32 bytes + + encrypted = build_new_response("mypassword", challenge, oms_secret) + + # Decrypt + key = derive_legitimation_key(oms_secret) + iv = challenge[:16] + cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) + decryptor = cipher.decryptor() + padded = decryptor.update(encrypted) + decryptor.finalize() + + # Remove PKCS7 padding + unpadder = padding.PKCS7(128).unpadder() + plaintext = unpadder.update(padded) + unpadder.finalize() + + # Should match the payload + expected = _build_legitimation_payload("mypassword") + assert plaintext == expected + + +class TestAuthenticate: + """Test connection.authenticate() preconditions.""" + + def test_authenticate_requires_connection(self) -> None: + import pytest + from snap7.error import S7ConnectionError + + conn = S7CommPlusConnection("127.0.0.1") + with pytest.raises(S7ConnectionError, match="Not connected"): + conn.authenticate("password") + + def test_authenticate_requires_tls(self) -> None: + import pytest + from snap7.error import S7ConnectionError + + conn = S7CommPlusConnection("127.0.0.1") + conn._connected = True + conn._tls_active = False + with pytest.raises(S7ConnectionError, match="requires TLS"): + conn.authenticate("password") diff --git a/uv.lock b/uv.lock index e17bc4b9..7c3b888b 100644 --- a/uv.lock +++ b/uv.lock @@ -52,6 +52,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -280,6 +362,66 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -669,6 +811,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -792,6 +943,9 @@ doc = [ { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-rtd-theme" }, ] +s7commplus = [ + { name = "cryptography" }, +] test = [ { name = "hypothesis" }, { name = "mypy" }, @@ -810,6 +964,7 @@ test = [ [package.metadata] requires-dist = [ { name = "click", marker = "extra == 'cli'" }, + { name = "cryptography", marker = "extra == 's7commplus'" }, { name = "hypothesis", marker = "extra == 'test'" }, { name = "mypy", marker = "extra == 'test'" }, { name = "pytest", marker = "extra == 'test'" }, @@ -826,7 +981,7 @@ requires-dist = [ { name = "types-setuptools", marker = "extra == 'test'" }, { name = "uv", marker = "extra == 'test'" }, ] -provides-extras = ["test", "cli", "doc"] +provides-extras = ["test", "s7commplus", "cli", "doc"] [[package]] name = "requests"