Coverage for aiocoap/transports/tinydtls.py: 84%
205 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-05 18:37 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-05 18:37 +0000
1# SPDX-FileCopyrightText: Christian Amsüss and the aiocoap contributors
2#
3# SPDX-License-Identifier: MIT
5"""This module implements a MessageInterface that handles coaps:// using a
6wrapped tinydtls library.
8This currently only implements the client side. To have a test server, run::
10 $ git clone https://github.com/obgm/libcoap.git --recursive
11 $ cd libcoap
12 $ ./autogen.sh
13 $ ./configure --with-tinydtls --disable-shared --disable-documentation
14 $ make
15 $ ./examples/coap-server -k secretPSK
17(Using TinyDTLS in libcoap is important; with the default OpenSSL build, I've
18seen DTLS1.0 responses to DTLS1.3 requests, which are hard to debug.)
20The test server with its built-in credentials can then be accessed using::
22 $ echo '{"coaps://localhost/*": {"dtls": {"psk": {"ascii": "secretPSK"}, "client-identity": {"ascii": "client_Identity"}}}}' > testserver.json
23 $ ./aiocoap-client coaps://localhost --credentials testserver.json
25While it is planned to allow more programmatical construction of the
26credentials store, the currently recommended way of storing DTLS credentials is
27to load a structured data object into the client_credentials store of the context:
29>>> c = await aiocoap.Context.create_client_context() # doctest: +SKIP
30>>> c.client_credentials.load_from_dict(
31... {'coaps://localhost/*': {'dtls': {
32... 'psk': b'secretPSK',
33... 'client-identity': b'client_Identity',
34... }}}) # doctest: +SKIP
36where, compared to the JSON example above, byte strings can be used directly
37rather than expressing them as 'ascii'/'hex' (`{'hex': '30383135'}` style works
38as well) to work around JSON's limitation of not having raw binary strings.
40Bear in mind that the aiocoap CoAPS support is highly experimental; for
41example, while requests to this server do complete, error messages are still
42shown during client shutdown.
43"""
45import asyncio
46import weakref
47import functools
48import time
49import warnings
51from ..util import hostportjoin, hostportsplit
52from ..message import Message
53from .. import interfaces, error
54from ..numbers import COAPS_PORT
55from ..credentials import CredentialsMissingError
57# tinyDTLS passes address information around in its session data, but the way
58# it's used here that will be ignored; this is the data that is sent to / read
59# from the tinyDTLS functions
60_SENTINEL_ADDRESS = "::1"
61_SENTINEL_PORT = 1234
63DTLS_EVENT_CONNECT = 0x01DC
64DTLS_EVENT_CONNECTED = 0x01DE
65DTLS_EVENT_RENEGOTIATE = 0x01DF
67LEVEL_NOALERT = 0 # seems only to be issued by tinydtls-internal events
69# from RFC 5246
70LEVEL_WARNING = 1
71LEVEL_FATAL = 2
72CODE_CLOSE_NOTIFY = 0
74level_names = {
75 LEVEL_NOALERT: "no alert",
76 LEVEL_WARNING: "warning",
77 LEVEL_FATAL: "fatal",
78}
80# tinydtls can not be debugged in the Python way; if you need to get more
81# information out of it, use the following line:
82# dtls.setLogLevel(dtls.DTLS_LOG_DEBUG)
84# FIXME this should be exposed by the dtls wrapper
85DTLS_TICKS_PER_SECOND = 1000
86DTLS_CLOCK_OFFSET = time.time()
89# Currently kept a bit private by not inheriting from NetworkError -- thus
90# they'll be wrapped in a NetworkError when they fly out of a request.
91class CloseNotifyReceived(Exception):
92 """The DTLS connection a request was sent on raised was closed by the
93 server while the request was being processed"""
96class FatalDTLSError(Exception):
97 """The DTLS connection a request was sent on raised a fatal error while the
98 request was being processed"""
101class DTLSClientConnection(interfaces.EndpointAddress):
102 # FIXME not only does this not do error handling, it seems not to even
103 # survive its 2**16th message exchange.
105 is_multicast = False
106 is_multicast_locally = False
107 hostinfo = None # stored at initualization time
108 uri_base = property(lambda self: "coaps://" + self.hostinfo)
109 # Not necessarily very usable given we don't implement responding to server
110 # connection, but valid anyway
111 uri_base_local = property(lambda self: "coaps://" + self.hostinfo_local)
112 scheme = "coaps"
114 @property
115 def hostinfo_local(self):
116 # See TCP's.hostinfo_local
117 host, port, *_ = self._transport.get_extra_info("socket").getsockname()
118 if port == COAPS_PORT:
119 port = None
120 return hostportjoin(host, port)
122 @property
123 def blockwise_key(self):
124 return (self._host, self._port, self._pskId, self._psk)
126 def __init__(self, host, port, pskId, psk, coaptransport):
127 self._ready = False
128 self._queue = [] # stores sent packages while connection is being built
130 self._host = host
131 self._port = port
132 self._pskId = pskId
133 self._psk = psk
134 self.coaptransport = coaptransport
135 self.hostinfo = hostportjoin(host, None if port == COAPS_PORT else port)
137 self._startup = asyncio.ensure_future(self._start())
139 def _remove_from_pool(self):
140 """Remove self from the MessageInterfaceTinyDTLS's pool, so that it
141 will not be used in new requests.
143 This is idempotent (to allow quick removal and still remove it in a
144 finally clause) and not thread safe.
145 """
146 poolkey = (self._host, self._port, self._pskId)
147 if self.coaptransport._pool.get(poolkey) is self:
148 del self.coaptransport._pool[poolkey]
150 def send(self, message):
151 if self._queue is not None:
152 self._queue.append(message)
153 else:
154 # most of the time that will have returned long ago
155 self._retransmission_task.cancel()
157 self._dtls_socket.write(self._connection, message)
159 self._retransmission_task = asyncio.create_task(
160 self._run_retransmissions(),
161 name="DTLS handshake retransmissions",
162 )
164 log = property(lambda self: self.coaptransport.log)
166 def _build_accessor(self, method, deadvalue):
167 """Think self._build_accessor('_write')() == self._write(), just that
168 it's returning a weak wrapper that allows refcounting-based GC to
169 happen when the remote falls out of use"""
170 weakself = weakref.ref(self)
172 def wrapper(*args, __weakself=weakself, __method=method, __deadvalue=deadvalue):
173 self = __weakself()
174 if self is None:
175 warnings.warn(
176 "DTLS module did not shut down the DTLSSocket "
177 "perfectly; it still tried to call %s in vain" % __method
178 )
179 return __deadvalue
180 return getattr(self, __method)(*args)
182 wrapper.__name__ = "_build_accessor(%s)" % method
183 return wrapper
185 async def _start(self):
186 from DTLSSocket import dtls
188 self._dtls_socket = None
190 self._connection = None
192 try:
193 self._transport, _ = await self.coaptransport.loop.create_datagram_endpoint(
194 self.SingleConnection.factory(self),
195 remote_addr=(self._host, self._port),
196 )
198 self._dtls_socket = dtls.DTLS(
199 read=self._build_accessor("_read", 0),
200 write=self._build_accessor("_write", 0),
201 event=self._build_accessor("_event", 0),
202 pskId=self._pskId,
203 pskStore={self._pskId: self._psk},
204 )
205 self._connection = self._dtls_socket.connect(
206 _SENTINEL_ADDRESS, _SENTINEL_PORT
207 )
209 self._retransmission_task = asyncio.create_task(
210 self._run_retransmissions(),
211 name="DTLS handshake retransmissions",
212 )
214 self._connecting = asyncio.get_running_loop().create_future()
215 await self._connecting
217 queue = self._queue
218 self._queue = None
220 for message in queue:
221 # could be a tad more efficient by stopping the retransmissions
222 # in a go, then doing just the punch line and then starting it,
223 # but practically this will be a single thing most of the time
224 # anyway
225 self.send(message)
227 return
229 except Exception as e:
230 self.coaptransport.ctx.dispatch_error(e, self)
231 finally:
232 if self._queue is None:
233 # all worked, we're done here
234 return
236 self.shutdown()
238 async def _run_retransmissions(self):
239 while True:
240 when = self._dtls_socket.checkRetransmit() / DTLS_TICKS_PER_SECOND
241 if when == 0:
242 return
243 now = time.time() - DTLS_CLOCK_OFFSET
244 await asyncio.sleep(when - now)
246 def shutdown(self):
247 self._remove_from_pool()
249 self._startup.cancel()
250 self._retransmission_task.cancel()
252 if self._connection is not None:
253 # This also does what `.close()` does -- that would happen at
254 # __del__, but let's wait for that.
255 self._dtls_socket.resetPeer(self._connection)
256 # At least on Python 3.12 with websockets present in tests, this
257 # needs to be dropped now or segfaults happen.
258 self._connection = None
259 # doing this here allows the dtls socket to send a final word, but
260 # by closing this, we protect the nascent next connection from any
261 # delayed ICMP errors that might still wind up in the old socket
262 self._transport.close()
264 def __del__(self):
265 # Breaking the loops between the DTLS object and this here to allow for
266 # an orderly Alet (fatal, close notify) to go out -- and also because
267 # DTLSSocket throws `TypeError: 'NoneType' object is not subscriptable`
268 # from its destructor while the cyclical dependency is taken down.
269 self.shutdown()
271 def _inject_error(self, e):
272 """Put an error to all pending operations on this remote, just as if it
273 were raised inside the main loop."""
275 self.coaptransport.ctx.dispatch_error(e, self)
277 self.shutdown()
279 # dtls callbacks
281 def _read(self, sender, data):
282 # ignoring sender: it's only _SENTINEL_*
284 try:
285 message = Message.decode(data, self)
286 except error.UnparsableMessage:
287 self.log.warning("Ignoring unparsable message from %s", sender)
288 return len(data)
290 self.coaptransport.ctx.dispatch_message(message)
292 return len(data)
294 def _write(self, recipient, data):
295 # ignoring recipient: it's only _SENTINEL_*
296 try:
297 t = self._transport
298 except Exception:
299 # tinydtls sends callbacks very very late during shutdown (ie.
300 # `hasattr` and `AttributeError` are all not available any more,
301 # and even if the DTLSClientConnection class had a ._transport, it
302 # would already be gone), and it seems even a __del__ doesn't help
303 # break things up into the proper sequence.
304 return 0
305 t.sendto(data)
306 return len(data)
308 def _event(self, level, code):
309 if (level, code) == (LEVEL_NOALERT, DTLS_EVENT_CONNECT):
310 return
311 elif (level, code) == (LEVEL_NOALERT, DTLS_EVENT_CONNECTED):
312 self._connecting.set_result(True)
313 elif (level, code) == (LEVEL_FATAL, CODE_CLOSE_NOTIFY):
314 self._inject_error(CloseNotifyReceived())
315 elif level == LEVEL_FATAL:
316 self._inject_error(FatalDTLSError(code))
317 else:
318 self.log.warning("Unhandled alert level %d code %d", level, code)
320 # transport protocol
322 class SingleConnection:
323 @classmethod
324 def factory(cls, parent):
325 return functools.partial(cls, weakref.ref(parent))
327 def __init__(self, parent):
328 self.parent = parent #: DTLSClientConnection
330 def connection_made(self, transport):
331 # only for for shutdown
332 self.transport = transport
334 def connection_lost(self, exc):
335 pass
337 def error_received(self, exc):
338 parent = self.parent()
339 if parent is None:
340 self.transport.close()
341 return
342 parent._inject_error(exc)
344 def datagram_received(self, data, addr):
345 parent = self.parent()
346 if parent is None:
347 self.transport.close()
348 return
349 parent._dtls_socket.handleMessage(parent._connection, data)
352class MessageInterfaceTinyDTLS(interfaces.MessageInterface):
353 def __init__(self, ctx: interfaces.MessageManager, log, loop):
354 self._pool: weakref.WeakValueDictionary = weakref.WeakValueDictionary(
355 {}
356 ) # see _connection_for_address
358 self.ctx = ctx
360 self.log = log
361 self.loop = loop
363 self._shutting_down = False
365 def _connection_for_address(self, host, port, pskId, psk):
366 """Return a DTLSConnection to a given address. This will always give
367 the same result for the same host/port combination, at least for as
368 long as that result is kept alive (eg. by messages referring to it in
369 their .remote) and while the connection has not failed."""
371 try:
372 return self._pool[(host, port, pskId)]
373 except KeyError:
374 self.log.info(
375 "No DTLS connection active to (%s, %s, %s), creating one",
376 host,
377 port,
378 pskId,
379 )
380 connection = DTLSClientConnection(host, port, pskId, psk, self)
381 self._pool[(host, port, pskId)] = connection
382 return connection
384 @classmethod
385 async def create_client_transport_endpoint(
386 cls, ctx: interfaces.MessageManager, log, loop
387 ):
388 return cls(ctx, log, loop)
390 async def recognize_remote(self, remote):
391 return (
392 isinstance(remote, DTLSClientConnection) and remote in self._pool.values()
393 )
395 async def determine_remote(self, request):
396 if request.requested_scheme != "coaps":
397 return None
399 if self._shutting_down:
400 raise error.LibraryShutdown
402 if request.unresolved_remote:
403 host, port = hostportsplit(request.unresolved_remote)
404 port = port or COAPS_PORT
405 elif request.opt.uri_host:
406 host = request.opt.uri_host
407 port = request.opt.uri_port or COAPS_PORT
408 else:
409 raise ValueError(
410 "No location found to send message to (neither in .opt.uri_host nor in .remote)"
411 )
413 dtlsparams = self.ctx.client_credentials.credentials_from_request(request)
414 try:
415 pskId, psk = dtlsparams.as_dtls_psk()
416 except AttributeError:
417 raise CredentialsMissingError(
418 "Credentials for requested URI are not compatible with DTLS-PSK"
419 )
420 result = self._connection_for_address(host, port, pskId, psk)
421 return result
423 def send(self, message):
424 message.remote.send(message.encode())
426 async def shutdown(self):
427 self._shutting_down = True
429 remaining_connections = list(self._pool.values())
430 for c in remaining_connections:
431 c.shutdown()