Skip to content

Commit 2a0da16

Browse files
authored
fix: enable legacy-renegotiation TLS compat on demand for ISY-994 (#508)
1 parent 5896072 commit 2a0da16

2 files changed

Lines changed: 121 additions & 2 deletions

File tree

pyisy/connection.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@
5757

5858
EMPTY_XML_RESPONSE = '<?xml version="1.0" encoding="UTF-8"?>'
5959

60+
# ``ssl.OP_LEGACY_SERVER_CONNECT`` was added to the stdlib ``ssl``
61+
# module in Python 3.12; CI still runs on 3.11. The underlying OpenSSL
62+
# flag ``SSL_OP_LEGACY_SERVER_CONNECT`` has had the stable value
63+
# ``0x4`` for years, so fall back to the literal when the attribute is
64+
# missing.
65+
OP_LEGACY_SERVER_CONNECT = getattr(ssl, "OP_LEGACY_SERVER_CONNECT", 0x4)
66+
6067

6168
class Connection:
6269
"""Connection object to manage connection to and interaction with ISY."""
@@ -222,8 +229,28 @@ async def request(
222229
# a modern OpenSSL distro with ``MinProtocol=TLSv1.2``).
223230
# * ``verify_ssl=True`` against the controller's self-signed
224231
# cert (``ClientConnectorCertificateError``).
225-
# Always raise — retrying won't recover from a config
226-
# mismatch, and callers (HA Core) need a definitive failure
232+
# * ISY-994 firmware (pre-RFC-5746) rejected by OpenSSL 3.x
233+
# with ``UNSAFE_LEGACY_RENEGOTIATION_DISABLED``. We
234+
# identify ISY-994 by the failure itself (the only peer
235+
# class that fails this way) and degrade the SSL context
236+
# once for the lifetime of the ``Connection`` — modern
237+
# peers (eisy/Polisy IoX, ISY-994 firmware that does
238+
# RFC 5746) stay strict.
239+
if (
240+
self.sslcontext is not None
241+
and not (self.sslcontext.options & OP_LEGACY_SERVER_CONNECT)
242+
and "UNSAFE_LEGACY_RENEGOTIATION_DISABLED" in str(err)
243+
):
244+
_LOGGER.warning(
245+
"Enabling ISY-994 legacy-renegotiation TLS compatibility for "
246+
"this controller; eisy/Polisy IoX peers do not need this. "
247+
"Original error: %s",
248+
err,
249+
)
250+
self.sslcontext.options |= OP_LEGACY_SERVER_CONNECT
251+
return await self.request(url, retries=retries, ok404=ok404, delay=delay, retry404=retry404)
252+
# Always raise — retrying a real version/cert mismatch won't
253+
# recover, and callers (HA Core) need a definitive failure
227254
# to translate into ``ConfigEntryNotReady`` rather than a
228255
# silent ``None`` that looks like a transient miss. The SSL
229256
# detail rides along in the exception chain.
@@ -434,6 +461,13 @@ def get_sslcontext(
434461
# Allow older ciphers for original ISY-994 hardware (TLS 1.1/1.2 only;
435462
# set_ciphers does not affect TLS 1.3 ciphersuites).
436463
context.set_ciphers("DEFAULT:!aNULL:!eNULL:!MD5:!3DES:!DES:!RC4:!IDEA:!SEED:!aDSS:!SRP:!PSK")
464+
# Note: ``OP_LEGACY_SERVER_CONNECT`` (ISY-994 RFC-5746 compat) is
465+
# NOT set here. ``Connection.request()`` enables it on demand the
466+
# first time the peer rejects the handshake with
467+
# ``UNSAFE_LEGACY_RENEGOTIATION_DISABLED`` — that way modern peers
468+
# (eisy/Polisy IoX, ISY-994 firmware that honors RFC 5746) keep
469+
# strict TLS, and only the controllers that actually need it
470+
# degrade.
437471
return context
438472

439473

tests/test_connection_extras.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from pyisy.connection import (
1717
EMPTY_XML_RESPONSE,
18+
OP_LEGACY_SERVER_CONNECT,
1819
Connection,
1920
get_sslcontext,
2021
)
@@ -62,6 +63,17 @@ def test_get_sslcontext_auto_pins_min_v12_no_max() -> None:
6263
assert ctx.check_hostname is False
6364

6465

66+
def test_get_sslcontext_does_not_preset_legacy_renegotiation() -> None:
67+
"""``OP_LEGACY_SERVER_CONNECT`` (the ISY-994 RFC-5746 compat flag)
68+
must NOT be set by default — modern peers (eisy/Polisy IoX, ISY-994
69+
firmware that honors RFC 5746) keep strict TLS. The flag is enabled
70+
on demand by ``Connection.request()`` only when the peer rejects
71+
the handshake with ``UNSAFE_LEGACY_RENEGOTIATION_DISABLED``."""
72+
ctx = get_sslcontext(use_https=True)
73+
assert ctx is not None
74+
assert not (ctx.options & OP_LEGACY_SERVER_CONNECT)
75+
76+
6577
def test_get_sslcontext_verify_ssl_true_flips_cert_verification() -> None:
6678
"""``verify_ssl=True`` is the opt-in for users who installed a
6779
properly-signed cert on their controller. It flips both
@@ -352,6 +364,79 @@ async def test_request_ssl_error_raises_on_test_connection_path(
352364
await conn.request(url, retries=None)
353365

354366

367+
async def test_request_legacy_reneg_failure_enables_compat_and_retries() -> None:
368+
"""First handshake fails with ``UNSAFE_LEGACY_RENEGOTIATION_DISABLED``
369+
(signature of ISY-994's pre-RFC-5746 TLS stack against modern OpenSSL).
370+
``request()`` must:
371+
372+
1. Flip ``OP_LEGACY_SERVER_CONNECT`` on the existing SSL context
373+
(modern peers — eisy/Polisy IoX, ISY-994 firmware that honors
374+
RFC 5746 — never reach this branch and stay strict).
375+
2. Log a one-time WARNING explaining the degradation.
376+
3. Retry the request and surface the second response normally.
377+
"""
378+
from unittest.mock import MagicMock
379+
380+
https_conn = Connection(address="h", port=443, username="u", password="p", use_https=True)
381+
try:
382+
# Sanity: flag is OFF on a fresh connection (verifies the
383+
# context-builder default; the request-path retry is what
384+
# actually flips it).
385+
assert https_conn.sslcontext is not None
386+
assert not (https_conn.sslcontext.options & OP_LEGACY_SERVER_CONNECT)
387+
388+
url = https_conn.compile_url(["config"])
389+
reneg_err = aiohttp.ClientConnectorSSLError(
390+
MagicMock(),
391+
ssl.SSLError(
392+
1,
393+
"[SSL: UNSAFE_LEGACY_RENEGOTIATION_DISABLED] unsafe legacy renegotiation disabled",
394+
),
395+
)
396+
with aioresponses() as mocked:
397+
# First call: handshake refusal. Second call (after the
398+
# retry flips the flag): success.
399+
mocked.get(url, exception=reneg_err)
400+
mocked.get(url, status=200, body="<configuration/>")
401+
result = await https_conn.request(url)
402+
403+
assert result == "<configuration/>"
404+
assert https_conn.sslcontext.options & OP_LEGACY_SERVER_CONNECT
405+
finally:
406+
await https_conn.close()
407+
408+
409+
async def test_request_legacy_reneg_does_not_trigger_for_unrelated_ssl_errors() -> None:
410+
"""Only the specific ``UNSAFE_LEGACY_RENEGOTIATION_DISABLED``
411+
failure flips the flag. A generic protocol error
412+
(``UNSUPPORTED_PROTOCOL``, cert verify failure, etc.) must surface
413+
as ``ISYConnectionError`` without weakening the SSL context — those
414+
are real config mismatches the user needs to fix, not ISY-994
415+
legacy compat."""
416+
from unittest.mock import MagicMock
417+
418+
from pyisy.exceptions import ISYConnectionError
419+
420+
https_conn = Connection(address="h", port=443, username="u", password="p", use_https=True)
421+
try:
422+
url = https_conn.compile_url(["config"])
423+
proto_err = aiohttp.ClientConnectorSSLError(
424+
MagicMock(),
425+
ssl.SSLError(1, "[SSL: UNSUPPORTED_PROTOCOL] unsupported protocol"),
426+
)
427+
with aioresponses() as mocked:
428+
mocked.get(url, exception=proto_err)
429+
with pytest.raises(ISYConnectionError, match="SSL/TLS error"):
430+
await https_conn.request(url)
431+
432+
# Flag must remain OFF — the user's security posture isn't
433+
# silently weakened on every SSL failure.
434+
assert https_conn.sslcontext is not None
435+
assert not (https_conn.sslcontext.options & OP_LEGACY_SERVER_CONNECT)
436+
finally:
437+
await https_conn.close()
438+
439+
355440
async def test_request_non_rest_url_does_not_crash(conn: Connection) -> None:
356441
"""Regression for #488: ``request()`` derives its debug-log endpoint
357442
from the URL by splitting on ``"rest"``. ``get_description()`` builds

0 commit comments

Comments
 (0)