Files
SmartThings-Local/smartthings_local/protocol/dtls_probe.py
T

328 lines
12 KiB
Python

"""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 ..errors import ProbeError
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:
# A fatal Alert lands here; the alert record was already
# captured below, so classification still works.
result.error = ProbeError()
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:]))