feat(protocol): bound DTLS handshake deadline
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
"""Shared memory-BIO driver for bounded DTLS handshakes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
from OpenSSL import SSL
|
||||
|
||||
from .coap import split_dtls
|
||||
|
||||
_HANDSHAKE_POLL_S = 0.5
|
||||
_MAX_DATAGRAM_SIZE = 65535
|
||||
|
||||
|
||||
def _drive_dtls_handshake(
|
||||
connection,
|
||||
sock,
|
||||
*,
|
||||
deadline: float,
|
||||
retries: int | None = None,
|
||||
on_datagram: Callable[[bytes], None] | None = None,
|
||||
) -> bool:
|
||||
"""Drive one memory-BIO DTLS handshake up to a monotonic deadline.
|
||||
|
||||
OpenSSL owns the retransmission schedule. ``retries`` optionally limits
|
||||
how many expired retransmission timers are serviced; a normal session is
|
||||
bounded only by its deadline, while the diagnostic probe retains its
|
||||
explicit retry budget.
|
||||
|
||||
Return ``True`` only when the handshake completes before the deadline.
|
||||
TLS and socket failures are left to the caller to classify.
|
||||
"""
|
||||
retransmits = 0
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
connection.do_handshake()
|
||||
return time.monotonic() < deadline
|
||||
except SSL.WantReadError:
|
||||
pass
|
||||
|
||||
try:
|
||||
output = connection.bio_read(_MAX_DATAGRAM_SIZE)
|
||||
except SSL.WantReadError:
|
||||
output = None
|
||||
if output:
|
||||
for record in split_dtls(output):
|
||||
if sock.send(record) != len(record):
|
||||
raise OSError("incomplete UDP send")
|
||||
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
sock.settimeout(min(_HANDSHAKE_POLL_S, remaining))
|
||||
try:
|
||||
datagram = sock.recv(_MAX_DATAGRAM_SIZE)
|
||||
except TimeoutError:
|
||||
timer = connection.DTLSv1_get_timeout()
|
||||
if timer is not None and timer <= 0:
|
||||
if retries is not None and retransmits >= retries:
|
||||
break
|
||||
connection.DTLSv1_handle_timeout()
|
||||
retransmits += 1
|
||||
continue
|
||||
|
||||
if not datagram:
|
||||
continue
|
||||
if on_datagram is not None:
|
||||
on_datagram(datagram)
|
||||
connection.bio_write(datagram)
|
||||
|
||||
return False
|
||||
@@ -39,8 +39,9 @@ from dataclasses import dataclass
|
||||
from OpenSSL import SSL
|
||||
|
||||
from ..errors import ProbeError
|
||||
from .auth import _DTLS_CIPHERS, _OCF_ROOT_CA, _load_pem_chain
|
||||
from .coap import split_dtls
|
||||
from .dtls_session import _DTLS_CIPHERS, _OCF_ROOT_CA, _load_pem_chain
|
||||
from .dtls_handshake import _drive_dtls_handshake
|
||||
from .endpoint import open_connected_udp_socket
|
||||
|
||||
# DTLS record content types (RFC 6347 §4.1)
|
||||
@@ -603,73 +604,40 @@ def diagnose_dtls_handshake(
|
||||
started = time.monotonic()
|
||||
deadline = started + timeout
|
||||
seen = set()
|
||||
retransmits = 0
|
||||
|
||||
def record_datagram(datagram):
|
||||
if result.rtt_s is None:
|
||||
result.rtt_s = time.monotonic() - started
|
||||
result.datagrams.append(datagram)
|
||||
for content_type, detail in classify_datagram(datagram):
|
||||
if content_type == _CT_HANDSHAKE:
|
||||
if detail not in seen:
|
||||
seen.add(detail)
|
||||
result.handshake_msgs.append(detail)
|
||||
if result.outcome == DEAD:
|
||||
result.outcome = LIVE
|
||||
elif content_type == _CT_ALERT and detail is not None:
|
||||
level, name = detail
|
||||
result.alert = (level, name)
|
||||
if level == 2: # fatal
|
||||
result.outcome = REJECTED
|
||||
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
conn.do_handshake()
|
||||
result.outcome = COMPLETED
|
||||
if result.rtt_s is None:
|
||||
result.rtt_s = time.monotonic() - started
|
||||
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):
|
||||
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.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
|
||||
# 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
|
||||
|
||||
completed = _drive_dtls_handshake(
|
||||
conn,
|
||||
sock,
|
||||
deadline=deadline,
|
||||
retries=retries,
|
||||
on_datagram=record_datagram,
|
||||
)
|
||||
if completed:
|
||||
result.outcome = COMPLETED
|
||||
if result.rtt_s is None:
|
||||
result.rtt_s = time.monotonic() - started
|
||||
result.datagrams.append(d)
|
||||
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
|
||||
elif ct == _CT_ALERT and detail is not None:
|
||||
level, name = detail
|
||||
result.alert = (level, name)
|
||||
if level == 2: # fatal
|
||||
result.outcome = REJECTED
|
||||
conn.bio_write(d)
|
||||
except SSL.Error:
|
||||
# A fatal Alert lands here; record_datagram() has already classified
|
||||
# the alert record before it is fed back into OpenSSL.
|
||||
result.error = ProbeError()
|
||||
except OSError:
|
||||
result.error = ProbeError()
|
||||
finally:
|
||||
|
||||
@@ -19,6 +19,7 @@ on a per-token Event the reader signals. OBSERVE notifications are
|
||||
delivered via the on_notification callback.
|
||||
"""
|
||||
import errno
|
||||
import math
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
@@ -48,6 +49,7 @@ from .auth import (
|
||||
_OCF_ROOT_CA,
|
||||
_load_pem_chain,
|
||||
)
|
||||
from .dtls_handshake import _HANDSHAKE_POLL_S, _drive_dtls_handshake
|
||||
from .endpoint import open_connected_udp_socket
|
||||
import logging
|
||||
|
||||
@@ -88,6 +90,20 @@ _ADVISORY_ERRNOS = frozenset(
|
||||
) if value is not None
|
||||
)
|
||||
|
||||
def _validate_handshake_timeout(timeout, default):
|
||||
"""Return one finite, positive DTLS handshake timeout."""
|
||||
value = default if timeout is None else timeout
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise TypeError('timeout must be a number or None')
|
||||
try:
|
||||
value = float(value)
|
||||
except OverflowError:
|
||||
raise ValueError(
|
||||
'timeout must be a positive finite number or None') from None
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
raise ValueError('timeout must be a positive finite number or None')
|
||||
return value
|
||||
|
||||
class DtlsCoapSession:
|
||||
"""Single sustained DTLS-CoAP session.
|
||||
|
||||
@@ -199,9 +215,16 @@ class DtlsCoapSession:
|
||||
|
||||
# ---- lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self):
|
||||
"""DTLS handshake. Blocks up to HANDSHAKE_TIMEOUT_S. Raises
|
||||
ConnectionError / TimeoutError on failure."""
|
||||
def connect(self, *, timeout: float | None = None):
|
||||
"""Perform a DTLS handshake within a monotonic deadline.
|
||||
|
||||
``timeout`` overrides ``HANDSHAKE_TIMEOUT_S`` for this call. OpenSSL
|
||||
owns DTLS retransmission timing while every receive is capped by the
|
||||
remaining budget, so wall-clock adjustments cannot change the bound.
|
||||
"""
|
||||
handshake_timeout = _validate_handshake_timeout(
|
||||
timeout, self.HANDSHAKE_TIMEOUT_S)
|
||||
deadline = time.monotonic() + handshake_timeout
|
||||
ctx = SSL.Context(SSL.DTLS_METHOD)
|
||||
self.auth.configure_context(ctx)
|
||||
|
||||
@@ -209,59 +232,39 @@ class DtlsCoapSession:
|
||||
conn.set_connect_state()
|
||||
conn.set_ciphertext_mtu(self.mtu)
|
||||
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise SessionTimeoutError()
|
||||
sock, endpoint = open_connected_udp_socket(
|
||||
self.host,
|
||||
self.port,
|
||||
family=self.family,
|
||||
local_port=self.local_port,
|
||||
timeout=2.0,
|
||||
timeout=min(_HANDSHAKE_POLL_S, remaining),
|
||||
)
|
||||
dest = endpoint.sockaddr
|
||||
|
||||
t0 = time.time()
|
||||
backend_failed = False
|
||||
while time.time() - t0 < self.HANDSHAKE_TIMEOUT_S:
|
||||
try:
|
||||
conn.do_handshake()
|
||||
break
|
||||
except SSL.WantReadError:
|
||||
pass
|
||||
except SSL.Error:
|
||||
sock.close()
|
||||
backend_failed = True
|
||||
break
|
||||
send_failed = False
|
||||
try:
|
||||
o = conn.bio_read(65535)
|
||||
if o:
|
||||
for r in _split_dtls(o):
|
||||
if sock.send(r) != len(r):
|
||||
raise OSError('incomplete UDP send')
|
||||
except SSL.WantReadError:
|
||||
pass
|
||||
except OSError:
|
||||
sock.close()
|
||||
send_failed = True
|
||||
if send_failed:
|
||||
raise EndpointError() from OSError('UDP send failed')
|
||||
receive_failed = False
|
||||
try:
|
||||
d = sock.recv(65535)
|
||||
if d:
|
||||
conn.bio_write(d)
|
||||
except socket.timeout:
|
||||
pass
|
||||
except OSError:
|
||||
sock.close()
|
||||
receive_failed = True
|
||||
if receive_failed:
|
||||
raise EndpointError() from OSError('UDP receive failed')
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
io_failed = False
|
||||
try:
|
||||
completed = _drive_dtls_handshake(
|
||||
conn,
|
||||
sock,
|
||||
deadline=deadline,
|
||||
)
|
||||
except SSL.Error:
|
||||
backend_failed = True
|
||||
except OSError:
|
||||
io_failed = True
|
||||
if backend_failed:
|
||||
sock.close()
|
||||
raise SessionError() from ConnectionError('DTLS backend failed')
|
||||
if io_failed:
|
||||
sock.close()
|
||||
raise EndpointError() from OSError('UDP handshake I/O failed')
|
||||
if not completed:
|
||||
sock.close()
|
||||
raise SessionTimeoutError()
|
||||
if backend_failed:
|
||||
raise SessionError() from ConnectionError('DTLS backend failed')
|
||||
|
||||
self.sock = sock
|
||||
self.conn = conn
|
||||
|
||||
Reference in New Issue
Block a user