feat(protocol): add DTLS ClientHello liveness probe + wire it into the bridge
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).
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
"""DTLS ClientHello probe — a cheap, deterministic liveness + diagnostic
|
||||
primitive that sits in front of a full handshake.
|
||||
|
||||
Two problems this solves:
|
||||
|
||||
1. Liveness. A 1-byte UDP probe cannot tell a silent port from a real
|
||||
DTLS server: anything that doesn't return ICMP-unreachable looks
|
||||
"live", so a discovery loop pays the full HANDSHAKE_TIMEOUT_S
|
||||
(12 s) on every false-positive port. A real DTLS server, by
|
||||
contrast, answers a ClientHello with a HelloVerifyRequest (RFC 6347
|
||||
§4.2.1 stateless cookie exchange) in ~1 RTT, *before* any
|
||||
certificate work. So one ClientHello round-trip distinguishes the
|
||||
real port from dead ones deterministically and cheaply — then the
|
||||
expensive cert handshake is committed to exactly one port.
|
||||
|
||||
2. Diagnosis. The full handshake collapses "no DTLS server here",
|
||||
"server up but rejected my cert", and "server up but no shared
|
||||
cipher/version" into one opaque timeout/error. Everything the
|
||||
server volunteers about itself — chosen cipher, its cert chain, its
|
||||
CertificateRequest, or a fatal Alert — arrives in its first flight,
|
||||
*before* we send our own certificate. Driving the handshake only
|
||||
that far (no client cert required) characterizes a device. This is
|
||||
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).
|
||||
"""
|
||||
|
||||
import socket
|
||||
import time
|
||||
|
||||
from OpenSSL import SSL
|
||||
|
||||
from .coap import split_dtls
|
||||
from .dtls_session import _OCF_ROOT_CA, _load_pem_chain
|
||||
|
||||
# DTLS record content types (RFC 6347 §4.1)
|
||||
_CT_CHANGE_CIPHER_SPEC = 20
|
||||
_CT_ALERT = 21
|
||||
_CT_HANDSHAKE = 22
|
||||
_CT_APP_DATA = 23
|
||||
|
||||
# Handshake message types (RFC 5246 §7.4 / RFC 6347)
|
||||
_HS_NAMES = {
|
||||
0: 'HelloRequest',
|
||||
1: 'ClientHello',
|
||||
2: 'ServerHello',
|
||||
3: 'HelloVerifyRequest',
|
||||
11: 'Certificate',
|
||||
12: 'ServerKeyExchange',
|
||||
13: 'CertificateRequest',
|
||||
14: 'ServerHelloDone',
|
||||
15: 'CertificateVerify',
|
||||
16: 'ClientKeyExchange',
|
||||
20: 'Finished',
|
||||
}
|
||||
|
||||
# TLS alert descriptions (RFC 5246 §7.2) — the ones a picky OCF stack
|
||||
# actually sends are called out; the rest are here so a probe never
|
||||
# reports a bare number.
|
||||
_ALERT_NAMES = {
|
||||
0: 'close_notify',
|
||||
10: 'unexpected_message',
|
||||
20: 'bad_record_mac',
|
||||
40: 'handshake_failure',
|
||||
42: 'bad_certificate',
|
||||
43: 'unsupported_certificate',
|
||||
44: 'certificate_revoked',
|
||||
45: 'certificate_expired',
|
||||
46: 'certificate_unknown',
|
||||
47: 'illegal_parameter',
|
||||
48: 'unknown_ca',
|
||||
49: 'access_denied',
|
||||
50: 'decode_error',
|
||||
51: 'decrypt_error',
|
||||
70: 'protocol_version',
|
||||
71: 'insufficient_security',
|
||||
80: 'internal_error',
|
||||
86: 'inappropriate_fallback',
|
||||
90: 'user_canceled',
|
||||
112: 'unrecognized_name',
|
||||
116: 'certificate_required',
|
||||
}
|
||||
|
||||
# Outcome classes, coarsest first.
|
||||
DEAD = 'dead' # no DTLS response at all — silent/non-DTLS port
|
||||
LIVE = 'live' # DTLS server confirmed (HelloVerifyRequest/ServerHello)
|
||||
COMPLETED = 'completed' # full handshake succeeded (cert accepted)
|
||||
REJECTED = 'rejected' # server sent a fatal Alert
|
||||
|
||||
|
||||
class ProbeResult:
|
||||
"""What a single ClientHello probe learned about one host:port."""
|
||||
|
||||
def __init__(self, host, port):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.outcome = DEAD
|
||||
self.rtt_s = None
|
||||
# Ordered, de-duplicated handshake message names the server sent.
|
||||
self.handshake_msgs = []
|
||||
# (level, description_name) if a fatal/warning Alert was seen.
|
||||
self.alert = None
|
||||
# Raw inbound datagrams, for callers that want to dig deeper.
|
||||
self.datagrams = []
|
||||
self.error = None
|
||||
|
||||
@property
|
||||
def is_dtls_server(self):
|
||||
"""True when a DTLS server was proven present, regardless of
|
||||
whether it liked our credentials."""
|
||||
return self.outcome in (LIVE, COMPLETED, REJECTED)
|
||||
|
||||
def __repr__(self):
|
||||
bits = [f'{self.host}:{self.port}', self.outcome]
|
||||
if self.rtt_s is not None:
|
||||
bits.append(f'{self.rtt_s * 1000:.0f}ms')
|
||||
if self.handshake_msgs:
|
||||
bits.append('+'.join(self.handshake_msgs))
|
||||
if self.alert:
|
||||
bits.append(f'alert={self.alert[1]}')
|
||||
if self.error:
|
||||
bits.append(f'err={self.error}')
|
||||
return f'<ProbeResult {" ".join(bits)}>'
|
||||
|
||||
|
||||
def classify_datagram(dgram):
|
||||
"""Parse one inbound UDP datagram into a list of
|
||||
(content_type, detail) tuples — detail is the handshake message name
|
||||
for handshake records, an (level, description_name) tuple for alerts,
|
||||
or None otherwise. Pure; safe to unit-test on captured bytes."""
|
||||
out = []
|
||||
for rec in split_dtls(dgram):
|
||||
ct = rec[0]
|
||||
frag = rec[13:]
|
||||
if ct == _CT_HANDSHAKE and frag:
|
||||
out.append((ct, _HS_NAMES.get(frag[0], f'hs{frag[0]}')))
|
||||
elif ct == _CT_ALERT and len(frag) >= 2:
|
||||
out.append((ct, (frag[0], _ALERT_NAMES.get(frag[1], str(frag[1])))))
|
||||
else:
|
||||
out.append((ct, None))
|
||||
return out
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Never raises on a network/handshake failure — those are folded into
|
||||
the ProbeResult so a discovery loop can race many ports safely.
|
||||
"""
|
||||
result = ProbeResult(host, port)
|
||||
|
||||
ctx = SSL.Context(SSL.DTLS_METHOD)
|
||||
ctx.load_verify_locations(_OCF_ROOT_CA)
|
||||
# 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')
|
||||
if cert_pem is not None:
|
||||
_load_pem_chain(ctx, cert_pem, key_pem)
|
||||
elif cert_path is not None:
|
||||
ctx.use_certificate_chain_file(cert_path)
|
||||
ctx.use_privatekey_file(key_path)
|
||||
ctx.check_privatekey()
|
||||
|
||||
conn = SSL.Connection(ctx, 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)
|
||||
|
||||
t0 = time.time()
|
||||
seen = set()
|
||||
retransmits = 0
|
||||
try:
|
||||
while time.time() - t0 < timeout:
|
||||
try:
|
||||
conn.do_handshake()
|
||||
result.outcome = COMPLETED
|
||||
if result.rtt_s is None:
|
||||
result.rtt_s = time.time() - t0
|
||||
break
|
||||
except SSL.WantReadError:
|
||||
pass
|
||||
except SSL.Error as e:
|
||||
# A fatal Alert lands here; the alert record was already
|
||||
# captured below, so classification still works.
|
||||
result.error = str(e)
|
||||
break
|
||||
|
||||
try:
|
||||
o = conn.bio_read(65535)
|
||||
if o:
|
||||
for r in split_dtls(o):
|
||||
sock.sendto(r, dest)
|
||||
except SSL.WantReadError:
|
||||
pass
|
||||
|
||||
try:
|
||||
d, _ = sock.recvfrom(65535)
|
||||
except socket.timeout:
|
||||
# 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
|
||||
# write BIO for the next iteration to flush. A live server
|
||||
# answers within a flight or two; a silent/non-DTLS port
|
||||
# never does, so we give up only after `retries`
|
||||
# retransmits — one dropped ClientHello no longer reads as
|
||||
# a false DEAD.
|
||||
to = conn.DTLSv1_get_timeout()
|
||||
if to is not None and to <= 0:
|
||||
if retransmits >= retries:
|
||||
break
|
||||
conn.DTLSv1_handle_timeout()
|
||||
retransmits += 1
|
||||
continue
|
||||
if not d:
|
||||
continue
|
||||
|
||||
if result.rtt_s is None:
|
||||
result.rtt_s = time.time() - t0
|
||||
result.datagrams.append(d)
|
||||
server_flight = False
|
||||
for ct, detail in classify_datagram(d):
|
||||
if ct == _CT_HANDSHAKE:
|
||||
if detail not in seen:
|
||||
seen.add(detail)
|
||||
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)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _main(argv):
|
||||
import concurrent.futures as cf
|
||||
|
||||
if len(argv) < 2:
|
||||
print('usage: python -m smartthings_local.protocol.dtls_probe '
|
||||
'HOST PORT [PORT...] [--cert FILE --key FILE] [--stateless]')
|
||||
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
|
||||
ports = []
|
||||
it = iter(argv[1:])
|
||||
for a in it:
|
||||
if a == '--cert':
|
||||
cert_path = next(it)
|
||||
elif a == '--key':
|
||||
key_path = next(it)
|
||||
elif a == '--stateless':
|
||||
stateless = True
|
||||
else:
|
||||
ports.append(int(a))
|
||||
|
||||
# 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.
|
||||
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
|
||||
for p in ports}
|
||||
results = [f.result() for f in cf.as_completed(futs)]
|
||||
|
||||
for r in sorted(results, key=lambda r: r.port):
|
||||
print(r)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
raise SystemExit(_main(sys.argv[1:]))
|
||||
Reference in New Issue
Block a user