feat(protocol): bound DTLS endpoint probing

This commit is contained in:
Jason Morcos
2026-08-03 12:45:14 -07:00
parent d677c72f89
commit dd453ebdfb
7 changed files with 1166 additions and 201 deletions
+470 -64
View File
@@ -23,19 +23,25 @@ Two problems this solves:
how you tell an OCF-PKI-wall device (rejects at cert-verify) from a
cipher/version mismatch without a cert it would ever accept.
Reuses split_dtls() (the record framer) and the same memory-BIO pump as
DtlsCoapSession.connect(), so the ClientHello on the wire is byte-for-byte
what our real client emits (same cipher list, same @SECLEVEL=0).
The production probe generates its frozen ClientHello through the same OpenSSL
memory-BIO profile as DtlsCoapSession.connect(), including the exact cipher
list, security level, and MTU. The opt-in diagnostic drive retains the full
memory-BIO pump for characterizing later server flights.
"""
import concurrent.futures as cf
import math
import socket
import time
import warnings
from dataclasses import dataclass
from OpenSSL import SSL
from ..errors import ProbeError
from .coap import split_dtls
from .dtls_session import _OCF_ROOT_CA, _load_pem_chain
from .dtls_session import _DTLS_CIPHERS, _OCF_ROOT_CA, _load_pem_chain
from .endpoint import open_connected_udp_socket
# DTLS record content types (RFC 6347 §4.1)
_CT_CHANGE_CIPHER_SPEC = 20
@@ -91,6 +97,349 @@ LIVE = 'live' # DTLS server confirmed (HelloVerifyRequest/ServerHello
COMPLETED = 'completed' # full handshake succeeded (cert accepted)
REJECTED = 'rejected' # server sent a fatal Alert
# Aggregate stateless-probe outcomes.
SELECTED = 'selected'
UNREACHABLE = 'unreachable'
AMBIGUOUS = 'ambiguous'
# First-flight response classes retained by the production liveness API.
HELLO_VERIFY_REQUEST = 'hello_verify_request'
SERVER_HELLO = 'server_hello'
ALERT = 'alert'
_DTLS_VERSIONS = frozenset((b'\xfe\xff', b'\xfe\xfd'))
@dataclass(frozen=True, slots=True)
class DtlsLivenessResult:
"""Bounded, non-sensitive result for one stateless port probe."""
port: int
response_kind: str | None
attempts: int
rtt_s: float | None = None
alert: tuple[int, str] | None = None
error_code: str | None = None
@property
def is_dtls_server(self):
"""Return whether a structurally valid first-flight reply arrived."""
return self.response_kind is not None
@dataclass(frozen=True, slots=True)
class DtlsPortProbeResult:
"""Selection result for one bounded concurrent probe set."""
outcome: str
selected_port: int | None
results: tuple[DtlsLivenessResult, ...]
@property
def live_ports(self):
"""Return proven listeners in caller-supplied order."""
return tuple(
result.port for result in self.results if result.is_dtls_server)
def _validate_liveness_options(port, retries, timeout, mtu):
if isinstance(port, bool) or not isinstance(port, int):
raise TypeError('port must be an integer')
if not 1 <= port <= 65535:
raise ValueError('port must be between 1 and 65535')
if isinstance(retries, bool) or not isinstance(retries, int):
raise TypeError('retries must be an integer')
if not 0 <= retries <= 4:
raise ValueError('retries must be between zero and four')
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
raise TypeError('timeout must be a number')
if not math.isfinite(timeout) or not 0 < timeout <= 30:
raise ValueError('timeout must be greater than zero and at most 30')
if isinstance(mtu, bool) or not isinstance(mtu, int):
raise TypeError('mtu must be an integer')
if not 576 <= mtu <= 16384:
raise ValueError('mtu is outside the safe UDP range')
def _validate_probe_family(family):
if isinstance(family, bool) or not isinstance(family, int):
raise TypeError('family must be an address-family integer')
if family not in (socket.AF_UNSPEC, socket.AF_INET, socket.AF_INET6):
raise ValueError('family must be AF_UNSPEC, AF_INET, or AF_INET6')
def _client_hello_flight(*, mtu):
"""Build and freeze the same narrow first flight as a real session."""
context = SSL.Context(SSL.DTLS_METHOD)
context.load_verify_locations(_OCF_ROOT_CA)
context.set_verify(SSL.VERIFY_PEER, lambda *args: True)
context.set_cipher_list(_DTLS_CIPHERS)
connection = SSL.Connection(context, None)
connection.set_connect_state()
connection.set_ciphertext_mtu(mtu)
try:
connection.do_handshake()
except SSL.WantReadError:
pass
records = []
while True:
try:
outbound = connection.bio_read(65535)
except SSL.WantReadError:
break
if not outbound:
break
records.extend(split_dtls(outbound))
if not records:
raise ProbeError()
return tuple(records)
def _is_complete_hello_verify(body):
"""Validate the DTLS version and length-prefixed cookie."""
return (
len(body) >= 3
and body[:2] in _DTLS_VERSIONS
and len(body) == 3 + body[2]
)
def _is_complete_server_hello(body):
"""Validate the fixed fields, session ID, and optional extensions."""
if len(body) < 38 or body[:2] not in _DTLS_VERSIONS:
return False
session_id_length = body[34]
if session_id_length > 32:
return False
fixed_end = 38 + session_id_length
if len(body) == fixed_end:
return True
if len(body) < fixed_end + 2:
return False
extensions_length = int.from_bytes(body[fixed_end:fixed_end + 2], 'big')
return len(body) == fixed_end + 2 + extensions_length
def _parse_liveness_response(datagram):
"""Return the kind and validated alert for an epoch-zero first flight."""
records = split_dtls(datagram)
if not records or sum(map(len, records)) != len(datagram):
return None, None
fallback_kind = None
fallback_alert = None
for record in records:
if len(record) < 13 or record[1:3] not in _DTLS_VERSIONS:
continue
if record[3:5] != b'\x00\x00':
continue
fragment = record[13:]
if record[0] == _CT_HANDSHAKE:
offset = 0
while offset + 12 <= len(fragment):
header = fragment[offset:offset + 12]
message_length = int.from_bytes(header[1:4], 'big')
fragment_offset = int.from_bytes(header[6:9], 'big')
fragment_length = int.from_bytes(header[9:12], 'big')
end = offset + 12 + fragment_length
if end > len(fragment):
break
if fragment_offset == 0 and fragment_length == message_length:
body = fragment[offset + 12:end]
if header[0] == 3 and _is_complete_hello_verify(body):
return HELLO_VERIFY_REQUEST, None
if header[0] == 2 and _is_complete_server_hello(body):
if fallback_kind is None:
fallback_kind = SERVER_HELLO
offset = end
elif record[0] == _CT_ALERT and len(fragment) == 2:
level, description = fragment
fallback_kind = ALERT
fallback_alert = (
level,
_ALERT_NAMES.get(description, str(description)),
)
if level == 2:
return fallback_kind, fallback_alert
return fallback_kind, fallback_alert
def _classify_liveness_response(datagram):
"""Classify a structurally complete epoch-zero DTLS first flight."""
return _parse_liveness_response(datagram)[0]
def _probe_dtls_port_with_flight(
host, port, *, flight, timeout, retries, family):
"""Send one frozen ClientHello flight on a connected UDP socket."""
attempt_budget = float(timeout) / (retries + 1)
attempts = 0
sock = None
try:
sock, _endpoint = open_connected_udp_socket(
host,
port,
family=family,
timeout=attempt_budget,
)
started = time.monotonic()
for attempts in range(1, retries + 2):
for record in flight:
if sock.send(record) != len(record):
raise OSError('short UDP send')
attempt_deadline = started + attempts * attempt_budget
while True:
remaining = attempt_deadline - time.monotonic()
if remaining <= 0:
break
sock.settimeout(remaining)
try:
datagram = sock.recv(65535)
except TimeoutError:
break
response_kind, alert = _parse_liveness_response(datagram)
if response_kind is None:
# A connected UDP socket already rejects other peers. An
# unrelated or malformed datagram from the appliance must
# not consume a retransmission or count as DTLS proof.
continue
return DtlsLivenessResult(
port=port,
response_kind=response_kind,
attempts=attempts,
rtt_s=time.monotonic() - started,
alert=alert,
)
return DtlsLivenessResult(
port=port,
response_kind=None,
attempts=attempts,
error_code='no_dtls_response',
)
except OSError:
return DtlsLivenessResult(
port=port,
response_kind=None,
attempts=attempts,
error_code='endpoint_unavailable',
)
finally:
if sock is not None:
try:
sock.close()
except OSError:
pass
def probe_dtls_port(
host, port, *, timeout=3.0, retries=2, mtu=1200,
family=socket.AF_UNSPEC):
"""Prove one DTLS listener without sending a cookie-bearing flight.
The ClientHello is generated once. Packet-loss retries resend those exact
bytes and no response is ever fed back into OpenSSL, so this function
cannot emit a second ClientHello or allocate a server association.
``timeout`` bounds socket I/O after synchronous platform name resolution;
resolver timing remains controlled by the operating system.
"""
_validate_liveness_options(port, retries, timeout, mtu)
_validate_probe_family(family)
try:
flight = _client_hello_flight(mtu=mtu)
except Exception: # noqa: BLE001 - return only a fixed failure code
return DtlsLivenessResult(
port=port,
response_kind=None,
attempts=0,
error_code='client_hello_unavailable',
)
return _probe_dtls_port_with_flight(
host,
port,
flight=flight,
timeout=timeout,
retries=retries,
family=family,
)
def probe_dtls_ports(
host, ports, *, preferred_port=None, timeout=3.0, retries=2,
mtu=1200, family=socket.AF_UNSPEC):
"""Probe a bounded port set concurrently and select without guessing.
One proven listener is selected. If multiple listeners answer, a proven
``preferred_port`` wins; otherwise the explicit outcome is ``ambiguous``.
Results preserve the caller's de-duplicated port order. Each worker's
``timeout`` starts after synchronous platform name resolution.
"""
_validate_probe_family(family)
ordered_ports = tuple(dict.fromkeys(ports))
if not ordered_ports:
return DtlsPortProbeResult(UNREACHABLE, None, ())
if len(ordered_ports) > 32:
raise ValueError('at most 32 DTLS ports may be probed')
for port in ordered_ports:
_validate_liveness_options(port, retries, timeout, mtu)
if preferred_port is not None:
_validate_liveness_options(preferred_port, retries, timeout, mtu)
try:
flight = _client_hello_flight(mtu=mtu)
except Exception: # noqa: BLE001 - duplicate one fixed result per port
results = tuple(
DtlsLivenessResult(
port=port,
response_kind=None,
attempts=0,
error_code='client_hello_unavailable',
)
for port in ordered_ports
)
return DtlsPortProbeResult(UNREACHABLE, None, results)
by_port = {}
with cf.ThreadPoolExecutor(
max_workers=len(ordered_ports),
thread_name_prefix='smartthings-dtls-probe') as executor:
futures = {
executor.submit(
_probe_dtls_port_with_flight,
host,
port,
flight=flight,
timeout=timeout,
retries=retries,
family=family,
): port
for port in ordered_ports
}
for future in cf.as_completed(futures):
port = futures[future]
try:
by_port[port] = future.result()
except Exception: # noqa: BLE001 - isolate one bounded worker
by_port[port] = DtlsLivenessResult(
port=port,
response_kind=None,
attempts=0,
error_code='probe_worker_failed',
)
results = tuple(by_port[port] for port in ordered_ports)
live_ports = tuple(
result.port for result in results if result.is_dtls_server)
if preferred_port is not None and preferred_port in live_ports:
return DtlsPortProbeResult(SELECTED, preferred_port, results)
if len(live_ports) == 1:
return DtlsPortProbeResult(SELECTED, live_ports[0], results)
if live_ports:
return DtlsPortProbeResult(AMBIGUOUS, None, results)
return DtlsPortProbeResult(UNREACHABLE, None, results)
class ProbeResult:
"""What a single ClientHello probe learned about one host:port."""
@@ -147,38 +496,80 @@ def classify_datagram(dgram):
def probe(host, port, *, cert_pem=None, key_pem=None,
cert_path=None, key_path=None,
stateless=True, retries=2, timeout=3.0, mtu=1280):
"""Send a DTLS ClientHello to host:port and classify the server's
first flight.
stateless=True, retries=2, timeout=3.0, mtu=1200,
family=socket.AF_UNSPEC):
"""Run the backward-compatible stateless liveness probe.
Two modes:
stateless=True (default) — a *liveness* gate. Stop the instant the
server proves itself with a HelloVerifyRequest (or ServerHello),
and never send the cookie'd second ClientHello. By RFC 6347
§4.2.1 the server answers the first ClientHello WITHOUT allocating
association state, so a stateless probe leaves the device
completely untouched — no orphaned association, no ~8 s §4.2.8
cooldown for a later real connect from a different source port.
Outcome is DEAD or LIVE. This is the mode a discovery/reconnect
loop should use in front of a real handshake.
stateless=False — a *diagnostic* drive. Continue the handshake as
far as the server's own flight goes (up to ServerHelloDone, or to
COMPLETED with a client cert), capturing its cipher, cert chain,
CertificateRequest, or a fatal Alert. This deliberately commits
association state on the device, so keep it out of hot reconnect
paths; it is the tool for characterizing an OCF-PKI-wall device
(#16) — trust rejection vs cipher/version mismatch.
A single dropped ClientHello would otherwise read as a false DEAD, so
the silent path services OpenSSL's DTLS retransmit timer and re-sends
up to `retries` times before giving up. A live server still answers
on the first RTT — retransmit only lengthens the silent path.
Production callers should prefer :func:`probe_dtls_port`, whose immutable
result cannot retain remote datagrams or host names. This adapter preserves
the original ``ProbeResult`` shape. ``stateless=False`` remains only as a
deprecated compatibility path to the explicitly named stateful diagnostic.
Never raises on a network/handshake failure — those are folded into
the ProbeResult so a discovery loop can race many ports safely.
"""
if not stateless:
warnings.warn(
'probe(stateless=False) is deprecated; use '
'diagnose_dtls_handshake() explicitly',
DeprecationWarning,
stacklevel=2,
)
return diagnose_dtls_handshake(
host,
port,
cert_pem=cert_pem,
key_pem=key_pem,
cert_path=cert_path,
key_path=key_path,
retries=retries,
timeout=timeout,
mtu=mtu,
family=family,
)
result = ProbeResult(host, port)
liveness = probe_dtls_port(
host,
port,
timeout=timeout,
retries=retries,
mtu=mtu,
family=family,
)
if liveness.response_kind == HELLO_VERIFY_REQUEST:
result.outcome = LIVE
result.handshake_msgs.append('HelloVerifyRequest')
elif liveness.response_kind == SERVER_HELLO:
result.outcome = LIVE
result.handshake_msgs.append('ServerHello')
elif liveness.response_kind == ALERT:
result.alert = liveness.alert
result.outcome = (
REJECTED
if liveness.alert is not None and liveness.alert[0] == 2
else LIVE
)
result.rtt_s = liveness.rtt_s
if liveness.error_code not in (None, 'no_dtls_response'):
result.error = ProbeError()
return result
def diagnose_dtls_handshake(
host, port, *, cert_pem=None, key_pem=None,
cert_path=None, key_path=None,
retries=2, timeout=3.0, mtu=1200,
family=socket.AF_UNSPEC):
"""Opt in to a stateful DTLS handshake for protocol diagnosis.
Unlike :func:`probe_dtls_port`, this function feeds the server flight back
into OpenSSL. It can therefore emit a cookie-bearing second ClientHello and
allocate appliance-side association state. Keep it out of discovery,
reconnect, and other production liveness paths.
"""
_validate_liveness_options(port, retries, timeout, mtu)
_validate_probe_family(family)
result = ProbeResult(host, port)
ctx = SSL.Context(SSL.DTLS_METHOD)
@@ -186,7 +577,7 @@ def probe(host, port, *, cert_pem=None, key_pem=None,
# Accept the chain unconditionally: a probe classifies what the server
# sends, it does not gate on our trust decision.
ctx.set_verify(SSL.VERIFY_PEER, lambda *a: True)
ctx.set_cipher_list(b'ECDHE-ECDSA-AES128-GCM-SHA256:@SECLEVEL=0')
ctx.set_cipher_list(_DTLS_CIPHERS)
if cert_pem is not None:
_load_pem_chain(ctx, cert_pem, key_pem)
elif cert_path is not None:
@@ -198,20 +589,28 @@ def probe(host, port, *, cert_pem=None, key_pem=None,
conn.set_connect_state()
conn.set_ciphertext_mtu(mtu)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(0.5)
dest = (host, port)
try:
sock, _endpoint = open_connected_udp_socket(
host,
port,
family=family,
timeout=min(0.5, timeout),
)
except OSError:
result.error = ProbeError()
return result
t0 = time.time()
started = time.monotonic()
deadline = started + timeout
seen = set()
retransmits = 0
try:
while time.time() - t0 < timeout:
while time.monotonic() < deadline:
try:
conn.do_handshake()
result.outcome = COMPLETED
if result.rtt_s is None:
result.rtt_s = time.time() - t0
result.rtt_s = time.monotonic() - started
break
except SSL.WantReadError:
pass
@@ -225,13 +624,18 @@ def probe(host, port, *, cert_pem=None, key_pem=None,
o = conn.bio_read(65535)
if o:
for r in split_dtls(o):
sock.sendto(r, dest)
if sock.send(r) != len(r):
raise OSError('short UDP send')
except SSL.WantReadError:
pass
remaining = deadline - time.monotonic()
if remaining <= 0:
break
sock.settimeout(min(0.5, remaining))
try:
d, _ = sock.recvfrom(65535)
except socket.timeout:
d = sock.recv(65535)
except TimeoutError:
# No answer to the last flight. Service OpenSSL's DTLS
# retransmit timer: once it has counted down to 0,
# handle_timeout() re-queues the previous flight into the
@@ -251,9 +655,8 @@ def probe(host, port, *, cert_pem=None, key_pem=None,
continue
if result.rtt_s is None:
result.rtt_s = time.time() - t0
result.rtt_s = time.monotonic() - started
result.datagrams.append(d)
server_flight = False
for ct, detail in classify_datagram(d):
if ct == _CT_HANDSHAKE:
if detail not in seen:
@@ -261,23 +664,14 @@ def probe(host, port, *, cert_pem=None, key_pem=None,
result.handshake_msgs.append(detail)
if result.outcome == DEAD:
result.outcome = LIVE
if detail in ('HelloVerifyRequest', 'ServerHello'):
server_flight = True
elif ct == _CT_ALERT and detail is not None:
level, name = detail
result.alert = (level, name)
if level == 2: # fatal
result.outcome = REJECTED
# Stateless liveness: the server proved itself with a
# HelloVerifyRequest/ServerHello, which it answered without
# allocating state. Stop before feeding this flight back to
# OpenSSL — doing so would make it emit the cookie'd second
# ClientHello, the message that actually commits association
# state on the device. Not writing it keeps the probe
# zero-footprint.
if stateless and server_flight:
break
conn.bio_write(d)
except OSError:
result.error = ProbeError()
finally:
sock.close()
@@ -289,14 +683,11 @@ def _main(argv):
if len(argv) < 2:
print('usage: python -m smartthings_local.protocol.dtls_probe '
'HOST PORT [PORT...] [--cert FILE --key FILE] [--stateless]')
'HOST PORT [PORT...] [--diagnostic --cert FILE --key FILE]')
return 2
host = argv[0]
cert_path = key_path = None
# CLI defaults to the diagnostic drive so `HOST PORT` characterizes a
# device (cipher/cert/Alert). Pass --stateless for the zero-footprint
# liveness gate a reconnect loop would use.
stateless = False
diagnostic = False
ports = []
it = iter(argv[1:])
for a in it:
@@ -304,16 +695,31 @@ def _main(argv):
cert_path = next(it)
elif a == '--key':
key_path = next(it)
elif a == '--diagnostic':
diagnostic = True
elif a == '--stateless':
stateless = True
# Compatibility no-op: stateless is now the fail-safe default.
pass
else:
ports.append(int(a))
if not ports:
print('at least one PORT is required')
return 2
ports = list(dict.fromkeys(ports))
if len(ports) > 32:
print('at most 32 PORT values may be probed')
return 2
if (cert_path is None) != (key_path is None):
print('--cert and --key must be supplied together')
return 2
if not diagnostic and (cert_path is not None or key_path is not None):
print('--cert/--key require the explicit --diagnostic mode')
return 2
# Race the ports: a ClientHello probe is cheap, so fan out and let the
# live one answer in ~1 RTT instead of serializing 12 s timeouts.
target = diagnose_dtls_handshake if diagnostic else probe
with cf.ThreadPoolExecutor(max_workers=max(1, len(ports))) as ex:
futs = {ex.submit(probe, host, p, cert_path=cert_path,
key_path=key_path, stateless=stateless): p
futs = {ex.submit(target, host, p, cert_path=cert_path,
key_path=key_path): p
for p in ports}
results = [f.result() for f in cf.as_completed(futs)]
+2 -1
View File
@@ -48,6 +48,7 @@ import logging
logger = logging.getLogger(__name__)
_OCF_ROOT_CA = str(Path(__file__).parent / 'ocf_root_ca.pem')
_DTLS_CIPHERS = b'ECDHE-ECDSA-AES128-GCM-SHA256:@SECLEVEL=0'
# Diagnostic logging — when DEBUG_BRIDGE=1 in env, the bridge dumps
@@ -202,7 +203,7 @@ class DtlsCoapSession:
# intermediate is SHA-1 signed). This is the only channel that reaches
# the OpenSSL instance cryptography bundles — ctypes and cffi bindings
# do not expose SSL_CTX_set_security_level on this build.
ctx.set_cipher_list(b'ECDHE-ECDSA-AES128-GCM-SHA256:@SECLEVEL=0')
ctx.set_cipher_list(_DTLS_CIPHERS)
if self.cert_pem is not None:
_load_pem_chain(ctx, self.cert_pem, self.key_pem)
else: