Coverage for aiocoap/transports/tinydtls.py: 82%
206 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-16 16:09 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-16 16:09 +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 ..util.asyncio import py38args
53from ..message import Message
54from .. import interfaces, error
55from ..numbers import COAPS_PORT
56from ..credentials import CredentialsMissingError
58# tinyDTLS passes address information around in its session data, but the way
59# it's used here that will be ignored; this is the data that is sent to / read
60# from the tinyDTLS functions
61_SENTINEL_ADDRESS = "::1"
62_SENTINEL_PORT = 1234
64DTLS_EVENT_CONNECT = 0x01DC
65DTLS_EVENT_CONNECTED = 0x01DE
66DTLS_EVENT_RENEGOTIATE = 0x01DF
68LEVEL_NOALERT = 0 # seems only to be issued by tinydtls-internal events
70# from RFC 5246
71LEVEL_WARNING = 1
72LEVEL_FATAL = 2
73CODE_CLOSE_NOTIFY = 0
75# tinydtls can not be debugged in the Python way; if you need to get more
76# information out of it, use the following line:
77#dtls.setLogLevel(dtls.DTLS_LOG_DEBUG)
79# FIXME this should be exposed by the dtls wrapper
80DTLS_TICKS_PER_SECOND = 1000
81DTLS_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"""
89class FatalDTLSError(Exception):
90 """The DTLS connection a request was sent on raised a fatal error while the
91 request was being processed"""
93class DTLSClientConnection(interfaces.EndpointAddress):
94 # FIXME not only does this not do error handling, it seems not to even
95 # survive its 2**16th message exchange.
97 is_multicast = False
98 is_multicast_locally = False
99 hostinfo = None # stored at initualization time
100 uri_base = property(lambda self: 'coaps://' + self.hostinfo)
101 # Not necessarily very usable given we don't implement responding to server
102 # connection, but valid anyway
103 uri_base_local = property(lambda self: 'coaps://' + self.hostinfo_local)
104 scheme = 'coaps'
106 @property
107 def hostinfo_local(self):
108 # See TCP's.hostinfo_local
109 host, port, *_ = self._transport.get_extra_info('socket').getsockname()
110 if port == COAPS_PORT:
111 port = None
112 return hostportjoin(host, port)
114 @property
115 def blockwise_key(self):
116 return (self._host, self._port, self._pskId, self._psk)
118 def __init__(self, host, port, pskId, psk, coaptransport):
119 self._ready = False
120 self._queue = [] # stores sent packages while connection is being built
122 self._host = host
123 self._port = port
124 self._pskId = pskId
125 self._psk = psk
126 self.coaptransport = coaptransport
127 self.hostinfo = hostportjoin(host, None if port == COAPS_PORT else port)
129 self._startup = asyncio.ensure_future(self._start())
131 def _remove_from_pool(self):
132 """Remove self from the MessageInterfaceTinyDTLS's pool, so that it
133 will not be used in new requests.
135 This is idempotent (to allow quick removal and still remove it in a
136 finally clause) and not thread safe.
137 """
138 poolkey = (self._host, self._port, self._pskId)
139 if self.coaptransport._pool.get(poolkey) is self:
140 del self.coaptransport._pool[poolkey]
142 def send(self, message):
143 if self._queue is not None:
144 self._queue.append(message)
145 else:
146 # most of the time that will have returned long ago
147 self._retransmission_task.cancel()
149 self._dtls_socket.write(self._connection, message)
151 self._retransmission_task = asyncio.create_task(
152 self._run_retransmissions(),
153 **py38args(name="DTLS handshake retransmissions")
154 )
156 log = property(lambda self: self.coaptransport.log)
158 def _build_accessor(self, method, deadvalue):
159 """Think self._build_accessor('_write')() == self._write(), just that
160 it's returning a weak wrapper that allows refcounting-based GC to
161 happen when the remote falls out of use"""
162 weakself = weakref.ref(self)
163 def wrapper(*args, __weakself=weakself, __method=method, __deadvalue=deadvalue):
164 self = __weakself()
165 if self is None:
166 warnings.warn("DTLS module did not shut down the DTLSSocket "
167 "perfectly; it still tried to call %s in vain" %
168 __method)
169 return __deadvalue
170 return getattr(self, __method)(*args)
171 wrapper.__name__ = "_build_accessor(%s)" % method
172 return wrapper
174 async def _start(self):
175 from DTLSSocket import dtls
177 self._dtls_socket = None
179 self._connection = None
181 try:
182 self._transport, _ = await self.coaptransport.loop.create_datagram_endpoint(
183 self.SingleConnection.factory(self),
184 remote_addr=(self._host, self._port),
185 )
187 self._dtls_socket = dtls.DTLS(
188 read=self._build_accessor("_read", 0),
189 write=self._build_accessor("_write", 0),
190 event=self._build_accessor("_event", 0),
191 pskId=self._pskId,
192 pskStore={self._pskId: self._psk},
193 )
194 self._connection = self._dtls_socket.connect(_SENTINEL_ADDRESS, _SENTINEL_PORT)
196 self._retransmission_task = asyncio.create_task(
197 self._run_retransmissions(),
198 **py38args(name="DTLS handshake retransmissions")
199 )
201 self._connecting = asyncio.get_running_loop().create_future()
202 await self._connecting
204 queue = self._queue
205 self._queue = None
207 for message in queue:
208 # could be a tad more efficient by stopping the retransmissions
209 # in a go, then doing just the punch line and then starting it,
210 # but practically this will be a single thing most of the time
211 # anyway
212 self.send(message)
214 return
216 except asyncio.CancelledError:
217 # Can be removed starting with Python 3.8 as it's a workaround for
218 # https://bugs.python.org/issue32528
219 raise
220 except Exception as e:
221 self.coaptransport.ctx.dispatch_error(e, self)
222 finally:
223 if self._queue is None:
224 # all worked, we're done here
225 return
227 self.shutdown()
229 async def _run_retransmissions(self):
230 while True:
231 when = self._dtls_socket.checkRetransmit() / DTLS_TICKS_PER_SECOND
232 if when == 0:
233 return
234 now = time.time() - DTLS_CLOCK_OFFSET
235 await asyncio.sleep(when - now)
238 def shutdown(self):
239 self._remove_from_pool()
241 self._startup.cancel()
242 self._retransmission_task.cancel()
244 if self._connection is not None:
245 try:
246 self._dtls_socket.close(self._connection)
247 except Exception:
248 pass # _dtls_socket actually does raise an empty Exception() here
249 # doing this here allows the dtls socket to send a final word, but
250 # by closing this, we protect the nascent next connection from any
251 # delayed ICMP errors that might still wind up in the old socket
252 self._transport.close()
254 def __del__(self):
255 # Breaking the loops between the DTLS object and this here to allow for
256 # an orderly Alet (fatal, close notify) to go out -- and also because
257 # DTLSSocket throws `TypeError: 'NoneType' object is not subscriptable`
258 # from its destructor while the cyclical dependency is taken down.
259 self.shutdown()
261 def _inject_error(self, e):
262 """Put an error to all pending operations on this remote, just as if it
263 were raised inside the main loop."""
265 self.coaptransport.ctx.dispatch_error(e, self)
267 self.shutdown()
269 # dtls callbacks
271 def _read(self, sender, data):
272 # ignoring sender: it's only _SENTINEL_*
274 try:
275 message = Message.decode(data, self)
276 except error.UnparsableMessage:
277 self.log.warning("Ignoring unparsable message from %s", sender)
278 return len(data)
280 self.coaptransport.ctx.dispatch_message(message)
282 return len(data)
284 def _write(self, recipient, data):
285 # ignoring recipient: it's only _SENTINEL_*
286 try:
287 t = self._transport
288 except Exception:
289 # tinydtls sends callbacks very very late during shutdown (ie.
290 # `hasattr` and `AttributeError` are all not available any more,
291 # and even if the DTLSClientConnection class had a ._transport, it
292 # would already be gone), and it seems even a __del__ doesn't help
293 # break things up into the proper sequence.
294 return 0
295 t.sendto(data)
296 return len(data)
298 def _event(self, level, code):
299 if (level, code) == (LEVEL_NOALERT, DTLS_EVENT_CONNECT):
300 return
301 elif (level, code) == (LEVEL_NOALERT, DTLS_EVENT_CONNECTED):
302 self._connecting.set_result(True)
303 elif (level, code) == (LEVEL_FATAL, CODE_CLOSE_NOTIFY):
304 self._inject_error(CloseNotifyReceived())
305 elif level == LEVEL_FATAL:
306 self._inject_error(FatalDTLSError(code))
307 else:
308 self.log.warning("Unhandled alert level %d code %d", level, code)
310 # transport protocol
312 class SingleConnection:
313 @classmethod
314 def factory(cls, parent):
315 return functools.partial(cls, weakref.ref(parent))
317 def __init__(self, parent):
318 self.parent = parent #: DTLSClientConnection
320 def connection_made(self, transport):
321 # only for for shutdown
322 self.transport = transport
324 def connection_lost(self, exc):
325 pass
327 def error_received(self, exc):
328 parent = self.parent()
329 if parent is None:
330 self.transport.close()
331 return
332 parent._inject_error(exc)
334 def datagram_received(self, data, addr):
335 parent = self.parent()
336 if parent is None:
337 self.transport.close()
338 return
339 parent._dtls_socket.handleMessage(parent._connection, data)
341class MessageInterfaceTinyDTLS(interfaces.MessageInterface):
342 def __init__(self, ctx: interfaces.MessageManager, log, loop):
343 self._pool = weakref.WeakValueDictionary({}) # see _connection_for_address
345 self.ctx = ctx
347 self.log = log
348 self.loop = loop
350 def _connection_for_address(self, host, port, pskId, psk):
351 """Return a DTLSConnection to a given address. This will always give
352 the same result for the same host/port combination, at least for as
353 long as that result is kept alive (eg. by messages referring to it in
354 their .remote) and while the connection has not failed."""
356 try:
357 return self._pool[(host, port, pskId)]
358 except KeyError:
359 self.log.info("No DTLS connection active to (%s, %s, %s), creating one", host, port, pskId)
360 connection = DTLSClientConnection(host, port, pskId, psk, self)
361 self._pool[(host, port, pskId)] = connection
362 return connection
364 @classmethod
365 async def create_client_transport_endpoint(cls, ctx: interfaces.MessageManager, log, loop):
366 return cls(ctx, log, loop)
368 async def recognize_remote(self, remote):
369 return isinstance(remote, DTLSClientConnection) and remote in self._pool.values()
371 async def determine_remote(self, request):
372 if request.requested_scheme != 'coaps':
373 return None
375 if request.unresolved_remote:
376 host, port = hostportsplit(request.unresolved_remote)
377 port = port or COAPS_PORT
378 elif request.opt.uri_host:
379 host = request.opt.uri_host
380 port = request.opt.uri_port or COAPS_PORT
381 else:
382 raise ValueError("No location found to send message to (neither in .opt.uri_host nor in .remote)")
384 dtlsparams = self.ctx.client_credentials.credentials_from_request(request)
385 try:
386 pskId, psk = dtlsparams.as_dtls_psk()
387 except AttributeError:
388 raise CredentialsMissingError("Credentials for requested URI are not compatible with DTLS-PSK")
389 result = self._connection_for_address(host, port, pskId, psk)
390 return result
392 def send(self, message):
393 message.remote.send(message.encode())
395 async def shutdown(self):
396 remaining_connections = list(self._pool.values())
397 for c in remaining_connections:
398 c.shutdown()