A stateless-by-default DTLS ClientHello probe that classifies a host:port as DEAD/LIVE/COMPLETED/REJECTED in ~1 RTT off the server's first flight, sitting in front of the full handshake. Probe (smartthings_local/protocol/dtls_probe.py): - Stateless liveness mode (default): stops at HelloVerifyRequest and never sends the cookie'd second ClientHello, so by RFC 6347 §4.2.1 it leaves no association on the device — safe to run before a real connect. - Diagnostic mode (stateless=False): drives the handshake further to capture cipher/cert-chain/CertificateRequest or a fatal Alert, for OCF-PKI-wall characterization (#16). Kept out of hot reconnect paths. - Retransmit + retries: services OpenSSL's DTLS retransmit timer so a single dropped ClientHello no longer reads as a false DEAD. MQTT bridge (mqtt_demo): - Stateless pre-flight gate in session_once() rejects a silent/rebooting device or wrong port in ~3s (retries=1) instead of eating the 12s HANDSHAKE_TIMEOUT_S per reconnect. - OCF-band port autodiscovery when OCF_PORT is unset: races the band in parallel and returns on the first port to answer LIVE (~1 RTT, abandoning the dead-port probes), cached across reconnects; the stateless gate leaves no orphan, preserving the fixed-source-port §4.2.8 invariant. Validated on real hardware (dryer 49155 / oven 49154): parallel discovery resolves both ports in <1s, connect with no orphan cooldown, and a wrong pinned port rejected in ~3s. Tests: probe behaviour (retransmit recovery, stateless single-flight guard, silent-port flight budget, diagnostic continuation) and bridge port-resolution (pinned gate, parallel discovery early-exit, cache).
160 lines
5.6 KiB
Python
160 lines
5.6 KiB
Python
import socket
|
|
import time
|
|
|
|
from smartthings_local.protocol import dtls_probe as p
|
|
|
|
|
|
def _rec(content_type, frag):
|
|
"""Build one DTLS record: 13-byte header + fragment."""
|
|
return (bytes([content_type])
|
|
+ b'\xfe\xfd' # DTLS 1.2
|
|
+ b'\x00\x00' # epoch
|
|
+ b'\x00\x00\x00\x00\x00\x00' # sequence number
|
|
+ len(frag).to_bytes(2, 'big')
|
|
+ frag)
|
|
|
|
|
|
def _hs(msg_type, body=b''):
|
|
return _rec(p._CT_HANDSHAKE, bytes([msg_type]) + body)
|
|
|
|
|
|
def _alert(level, desc):
|
|
return _rec(p._CT_ALERT, bytes([level, desc]))
|
|
|
|
|
|
def test_classify_hello_verify_request():
|
|
assert p.classify_datagram(_hs(3, b'\x00' * 20)) == [
|
|
(p._CT_HANDSHAKE, 'HelloVerifyRequest')]
|
|
|
|
|
|
def test_classify_coalesced_server_flight():
|
|
# OpenSSL commonly hands back ServerHello+Certificate back-to-back.
|
|
dgram = _hs(2, b'\x00' * 30) + _hs(11, b'\x00' * 40)
|
|
assert p.classify_datagram(dgram) == [
|
|
(p._CT_HANDSHAKE, 'ServerHello'),
|
|
(p._CT_HANDSHAKE, 'Certificate')]
|
|
|
|
|
|
def test_classify_fatal_alert_names_description():
|
|
# The OCF-PKI-wall signature: fatal unsupported_certificate (43).
|
|
assert p.classify_datagram(_alert(2, 43)) == [
|
|
(p._CT_ALERT, (2, 'unsupported_certificate'))]
|
|
|
|
|
|
def test_classify_unknown_handshake_type_is_not_lost():
|
|
assert p.classify_datagram(_hs(99)) == [(p._CT_HANDSHAKE, 'hs99')]
|
|
|
|
|
|
def test_dead_port_probe_is_dead_and_never_raises():
|
|
# Nothing listens here; the probe must fold the silence into a DEAD
|
|
# result within the timeout rather than raise.
|
|
r = p.probe('127.0.0.1', 5684, timeout=1.0)
|
|
assert r.outcome == p.DEAD
|
|
assert not r.is_dtls_server
|
|
assert r.datagrams == []
|
|
|
|
|
|
def test_is_dtls_server_reflects_outcome():
|
|
r = p.ProbeResult('h', 1)
|
|
r.outcome = p.LIVE
|
|
assert r.is_dtls_server
|
|
r.outcome = p.REJECTED
|
|
assert r.is_dtls_server
|
|
r.outcome = p.DEAD
|
|
assert not r.is_dtls_server
|
|
|
|
|
|
# --- probe() behavioural tests over a scripted fake UDP socket ----------
|
|
#
|
|
# OpenSSL runs for real against a memory BIO, so the ClientHello on the
|
|
# wire is genuine; only the datagram transport is faked. `responder(fake)`
|
|
# is called on every recvfrom and returns the bytes to deliver, or None to
|
|
# simulate a lost/silent flight (which sleeps the socket timeout so
|
|
# OpenSSL's DTLS retransmit clock advances in real time).
|
|
|
|
class _FakeSock:
|
|
def __init__(self, responder):
|
|
self._responder = responder
|
|
self._timeout = 0.5
|
|
self.sends = []
|
|
self.recv_calls = 0
|
|
self.closed = False
|
|
|
|
def settimeout(self, t):
|
|
self._timeout = t
|
|
|
|
def setsockopt(self, *a):
|
|
pass
|
|
|
|
def bind(self, *a):
|
|
pass
|
|
|
|
def sendto(self, data, dest):
|
|
self.sends.append(data)
|
|
return len(data)
|
|
|
|
def recvfrom(self, n):
|
|
self.recv_calls += 1
|
|
resp = self._responder(self)
|
|
if resp is None:
|
|
time.sleep(self._timeout)
|
|
raise socket.timeout()
|
|
return resp, ('127.0.0.1', 5684)
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
|
|
def _patch_sock(monkeypatch, fake):
|
|
monkeypatch.setattr(p.socket, 'socket', lambda *a, **k: fake)
|
|
|
|
|
|
def test_stateless_probe_sends_exactly_one_clienthello(monkeypatch):
|
|
# The §4.2.8 regression guard: a HelloVerifyRequest proves liveness,
|
|
# and the stateless gate must stop there — never emitting the cookie'd
|
|
# second ClientHello that would commit association state on the device.
|
|
fake = _FakeSock(lambda f: _hs(3, b'\x00' * 20))
|
|
_patch_sock(monkeypatch, fake)
|
|
r = p.probe('127.0.0.1', 5684, stateless=True, timeout=2.0)
|
|
assert r.outcome == p.LIVE
|
|
assert len(fake.sends) == 1 # only the initial ClientHello
|
|
assert fake.recv_calls == 1 # stopped on the first flight
|
|
assert fake.closed
|
|
|
|
|
|
def test_retransmit_recovers_from_dropped_first_flight(monkeypatch):
|
|
# The first ClientHello is "lost" (recvfrom times out) until OpenSSL's
|
|
# retransmit timer fires a second flight; only then does the server
|
|
# answer. A single dropped datagram must NOT read as DEAD.
|
|
fake = _FakeSock(lambda f: _hs(3, b'\x00' * 20) if len(f.sends) >= 2
|
|
else None)
|
|
_patch_sock(monkeypatch, fake)
|
|
r = p.probe('127.0.0.1', 5684, stateless=True, retries=2, timeout=5.0)
|
|
assert r.outcome == p.LIVE
|
|
assert len(fake.sends) == 2 # initial + one retransmit
|
|
|
|
|
|
def test_silent_port_is_dead_only_after_flight_budget(monkeypatch):
|
|
# A truly silent port: DEAD, but only after the initial flight plus
|
|
# `retries` retransmits — not on the first unanswered datagram.
|
|
fake = _FakeSock(lambda f: None)
|
|
_patch_sock(monkeypatch, fake)
|
|
r = p.probe('127.0.0.1', 5684, stateless=True, retries=1, timeout=6.0)
|
|
assert r.outcome == p.DEAD
|
|
assert not r.is_dtls_server
|
|
assert len(fake.sends) == 2 # initial + retries(1) retransmit
|
|
|
|
|
|
def test_diagnostic_mode_feeds_server_flight_back(monkeypatch):
|
|
# The inverse of the stateless guard: stateless=False must NOT stop at
|
|
# the HelloVerifyRequest — it feeds the flight back into OpenSSL to
|
|
# drive the handshake onward (the #16 characterization path). The
|
|
# fed-back record here is a stub, so OpenSSL surfaces an error the
|
|
# moment it processes it, which is precisely what proves the probe did
|
|
# not short-circuit before the write.
|
|
fake = _FakeSock(lambda f: _hs(3, b'\x00' * 20))
|
|
_patch_sock(monkeypatch, fake)
|
|
r = p.probe('127.0.0.1', 5684, stateless=False, timeout=3.0)
|
|
assert r.outcome == p.LIVE # HVR still proved liveness
|
|
assert r.error is not None # OpenSSL processed the fed-back flight
|