Coverage for aiocoap/transports/tinydtls.py: 31%
204 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-17 13:16 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-17 13:16 +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
74# tinydtls can not be debugged in the Python way; if you need to get more
75# information out of it, use the following line:
76# dtls.setLogLevel(dtls.DTLS_LOG_DEBUG)
78# FIXME this should be exposed by the dtls wrapper
79DTLS_TICKS_PER_SECOND = 1000
80DTLS_CLOCK_OFFSET = time.time()
83# Currently kept a bit private by not inheriting from NetworkError -- thus
84# they'll be wrapped in a NetworkError when they fly out of a request.
85class CloseNotifyReceived(Exception):
86 """The DTLS connection a request was sent on raised was closed by the
87 server while the request was being processed"""
90class FatalDTLSError(Exception):
91 """The DTLS connection a request was sent on raised a fatal error while the
92 request was being processed"""
95class DTLSClientConnection(interfaces.EndpointAddress):
96 # FIXME not only does this not do error handling, it seems not to even
97 # survive its 2**16th message exchange.
99 is_multicast = False
100 is_multicast_locally = False
101 hostinfo = None # stored at initualization time
102 uri_base = property(lambda self: "coaps://" + self.hostinfo)
103 # Not necessarily very usable given we don't implement responding to server
104 # connection, but valid anyway
105 uri_base_local = property(lambda self: "coaps://" + self.hostinfo_local)
106 scheme = "coaps"
108 @property
109 def hostinfo_local(self):
110 # See TCP's.hostinfo_local
111 host, port, *_ = self._transport.get_extra_info("socket").getsockname()
112 if port == COAPS_PORT:
113 port = None
114 return hostportjoin(host, port)
116 @property
117 def blockwise_key(self):
118 return (self._host, self._port, self._pskId, self._psk)
120 def __init__(self, host, port, pskId, psk, coaptransport):
121 self._ready = False
122 self._queue = [] # stores sent packages while connection is being built
124 self._host = host
125 self._port = port
126 self._pskId = pskId
127 self._psk = psk
128 self.coaptransport = coaptransport
129 self.hostinfo = hostportjoin(host, None if port == COAPS_PORT else port)
131 self._startup = asyncio.ensure_future(self._start())
133 def _remove_from_pool(self):
134 """Remove self from the MessageInterfaceTinyDTLS's pool, so that it
135 will not be used in new requests.
137 This is idempotent (to allow quick removal and still remove it in a
138 finally clause) and not thread safe.
139 """
140 poolkey = (self._host, self._port, self._pskId)
141 if self.coaptransport._pool.get(poolkey) is self:
142 del self.coaptransport._pool[poolkey]
144 def send(self, message):
145 if self._queue is not None:
146 self._queue.append(message)
147 else:
148 # most of the time that will have returned long ago
149 self._retransmission_task.cancel()
151 self._dtls_socket.write(self._connection, message)
153 self._retransmission_task = asyncio.create_task(
154 self._run_retransmissions(),
155 name="DTLS handshake retransmissions",
156 )
158 log = property(lambda self: self.coaptransport.log)
160 def _build_accessor(self, method, deadvalue):
161 """Think self._build_accessor('_write')() == self._write(), just that
162 it's returning a weak wrapper that allows refcounting-based GC to
163 happen when the remote falls out of use"""
164 weakself = weakref.ref(self)
166 def wrapper(*args, __weakself=weakself, __method=method, __deadvalue=deadvalue):
167 self = __weakself()
168 if self is None:
169 warnings.warn(
170 "DTLS module did not shut down the DTLSSocket "
171 "perfectly; it still tried to call %s in vain" % __method
172 )
173 return __deadvalue
174 return getattr(self, __method)(*args)
176 wrapper.__name__ = "_build_accessor(%s)" % method
177 return wrapper
179 async def _start(self):
180 from DTLSSocket import dtls
182 self._dtls_socket = None
184 self._connection = None
186 try:
187 self._transport, _ = await self.coaptransport.loop.create_datagram_endpoint(
188 self.SingleConnection.factory(self),
189 remote_addr=(self._host, self._port),
190 )
192 self._dtls_socket = dtls.DTLS(
193 read=self._build_accessor("_read", 0),
194 write=self._build_accessor("_write", 0),
195 event=self._build_accessor("_event", 0),
196 pskId=self._pskId,
197 pskStore={self._pskId: self._psk},
198 )
199 self._connection = self._dtls_socket.connect(
200 _SENTINEL_ADDRESS, _SENTINEL_PORT
201 )
203 self._retransmission_task = asyncio.create_task(
204 self._run_retransmissions(),
205 name="DTLS handshake retransmissions",
206 )
208 self._connecting = asyncio.get_running_loop().create_future()
209 await self._connecting
211 queue = self._queue
212 self._queue = None
214 for message in queue:
215 # could be a tad more efficient by stopping the retransmissions
216 # in a go, then doing just the punch line and then starting it,
217 # but practically this will be a single thing most of the time
218 # anyway
219 self.send(message)
221 return
223 except Exception as e:
224 self.coaptransport.ctx.dispatch_error(e, self)
225 finally:
226 if self._queue is None:
227 # all worked, we're done here
228 return
230 self.shutdown()
232 async def _run_retransmissions(self):
233 while True:
234 when = self._dtls_socket.checkRetransmit() / DTLS_TICKS_PER_SECOND
235 if when == 0:
236 return
237 now = time.time() - DTLS_CLOCK_OFFSET
238 await asyncio.sleep(when - now)
240 def shutdown(self):
241 self._remove_from_pool()
243 self._startup.cancel()
244 self._retransmission_task.cancel()
246 if self._connection is not None:
247 # This also does what `.close()` does -- that would happen at
248 # __del__, but let's wait for that.
249 self._dtls_socket.resetPeer(self._connection)
250 # At least on Python 3.12 with websockets present in tests, this
251 # needs to be dropped now or segfaults happen.
252 self._connection = None
253 # doing this here allows the dtls socket to send a final word, but
254 # by closing this, we protect the nascent next connection from any
255 # delayed ICMP errors that might still wind up in the old socket
256 self._transport.close()
258 def __del__(self):
259 # Breaking the loops between the DTLS object and this here to allow for
260 # an orderly Alet (fatal, close notify) to go out -- and also because
261 # DTLSSocket throws `TypeError: 'NoneType' object is not subscriptable`
262 # from its destructor while the cyclical dependency is taken down.
263 self.shutdown()
265 def _inject_error(self, e):
266 """Put an error to all pending operations on this remote, just as if it
267 were raised inside the main loop."""
269 self.coaptransport.ctx.dispatch_error(e, self)
271 self.shutdown()
273 # dtls callbacks
275 def _read(self, sender, data):
276 # ignoring sender: it's only _SENTINEL_*
278 try:
279 message = Message.decode(data, self)
280 except error.UnparsableMessage:
281 self.log.warning("Ignoring unparsable message from %s", sender)
282 return len(data)
284 self.coaptransport.ctx.dispatch_message(message)
286 return len(data)
288 def _write(self, recipient, data):
289 # ignoring recipient: it's only _SENTINEL_*
290 try:
291 t = self._transport
292 except Exception:
293 # tinydtls sends callbacks very very late during shutdown (ie.
294 # `hasattr` and `AttributeError` are all not available any more,
295 # and even if the DTLSClientConnection class had a ._transport, it
296 # would already be gone), and it seems even a __del__ doesn't help
297 # break things up into the proper sequence.
298 return 0
299 t.sendto(data)
300 return len(data)
302 def _event(self, level, code):
303 if (level, code) == (LEVEL_NOALERT, DTLS_EVENT_CONNECT):
304 return
305 elif (level, code) == (LEVEL_NOALERT, DTLS_EVENT_CONNECTED):
306 self._connecting.set_result(True)
307 elif (level, code) == (LEVEL_FATAL, CODE_CLOSE_NOTIFY):
308 self._inject_error(CloseNotifyReceived())
309 elif level == LEVEL_FATAL:
310 self._inject_error(FatalDTLSError(code))
311 else:
312 self.log.warning("Unhandled alert level %d code %d", level, code)
314 # transport protocol
316 class SingleConnection:
317 @classmethod
318 def factory(cls, parent):
319 return functools.partial(cls, weakref.ref(parent))
321 def __init__(self, parent):
322 self.parent = parent #: DTLSClientConnection
324 def connection_made(self, transport):
325 # only for for shutdown
326 self.transport = transport
328 def connection_lost(self, exc):
329 pass
331 def error_received(self, exc):
332 parent = self.parent()
333 if parent is None:
334 self.transport.close()
335 return
336 parent._inject_error(exc)
338 def datagram_received(self, data, addr):
339 parent = self.parent()
340 if parent is None:
341 self.transport.close()
342 return
343 parent._dtls_socket.handleMessage(parent._connection, data)
346class MessageInterfaceTinyDTLS(interfaces.MessageInterface):
347 def __init__(self, ctx: interfaces.MessageManager, log, loop):
348 self._pool: weakref.WeakValueDictionary = weakref.WeakValueDictionary(
349 {}
350 ) # see _connection_for_address
352 self.ctx = ctx
354 self.log = log
355 self.loop = loop
357 self._shutting_down = False
359 def _connection_for_address(self, host, port, pskId, psk):
360 """Return a DTLSConnection to a given address. This will always give
361 the same result for the same host/port combination, at least for as
362 long as that result is kept alive (eg. by messages referring to it in
363 their .remote) and while the connection has not failed."""
365 try:
366 return self._pool[(host, port, pskId)]
367 except KeyError:
368 self.log.info(
369 "No DTLS connection active to (%s, %s, %s), creating one",
370 host,
371 port,
372 pskId,
373 )
374 connection = DTLSClientConnection(host, port, pskId, psk, self)
375 self._pool[(host, port, pskId)] = connection
376 return connection
378 @classmethod
379 async def create_client_transport_endpoint(
380 cls, ctx: interfaces.MessageManager, log, loop
381 ):
382 return cls(ctx, log, loop)
384 async def recognize_remote(self, remote):
385 return (
386 isinstance(remote, DTLSClientConnection) and remote in self._pool.values()
387 )
389 async def determine_remote(self, request):
390 if request.requested_scheme != "coaps":
391 return None
393 if self._shutting_down:
394 raise error.LibraryShutdown
396 if request.unresolved_remote:
397 host, port = hostportsplit(request.unresolved_remote)
398 port = port or COAPS_PORT
399 elif request.opt.uri_host:
400 host = request.opt.uri_host
401 port = request.opt.uri_port or COAPS_PORT
402 else:
403 raise ValueError(
404 "No location found to send message to (neither in .opt.uri_host nor in .remote)"
405 )
407 dtlsparams = self.ctx.client_credentials.credentials_from_request(request)
408 try:
409 pskId, psk = dtlsparams.as_dtls_psk()
410 except AttributeError:
411 raise CredentialsMissingError(
412 "Credentials for requested URI are not compatible with DTLS-PSK"
413 )
414 result = self._connection_for_address(host, port, pskId, psk)
415 return result
417 def send(self, message):
418 message.remote.send(message.encode())
420 async def shutdown(self):
421 self._shutting_down = True
423 remaining_connections = list(self._pool.values())
424 for c in remaining_connections:
425 c.shutdown()