Coverage for aiocoap/transports/tinydtls.py: 82%
199 statements
« prev ^ index » next coverage.py v7.6.8, created at 2024-11-28 12:34 +0000
« prev ^ index » next coverage.py v7.6.8, created at 2024-11-28 12:34 +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 # doing this here allows the dtls socket to send a final word, but
251 # by closing this, we protect the nascent next connection from any
252 # delayed ICMP errors that might still wind up in the old socket
253 self._transport.close()
255 def __del__(self):
256 # Breaking the loops between the DTLS object and this here to allow for
257 # an orderly Alet (fatal, close notify) to go out -- and also because
258 # DTLSSocket throws `TypeError: 'NoneType' object is not subscriptable`
259 # from its destructor while the cyclical dependency is taken down.
260 self.shutdown()
262 def _inject_error(self, e):
263 """Put an error to all pending operations on this remote, just as if it
264 were raised inside the main loop."""
266 self.coaptransport.ctx.dispatch_error(e, self)
268 self.shutdown()
270 # dtls callbacks
272 def _read(self, sender, data):
273 # ignoring sender: it's only _SENTINEL_*
275 try:
276 message = Message.decode(data, self)
277 except error.UnparsableMessage:
278 self.log.warning("Ignoring unparsable message from %s", sender)
279 return len(data)
281 self.coaptransport.ctx.dispatch_message(message)
283 return len(data)
285 def _write(self, recipient, data):
286 # ignoring recipient: it's only _SENTINEL_*
287 try:
288 t = self._transport
289 except Exception:
290 # tinydtls sends callbacks very very late during shutdown (ie.
291 # `hasattr` and `AttributeError` are all not available any more,
292 # and even if the DTLSClientConnection class had a ._transport, it
293 # would already be gone), and it seems even a __del__ doesn't help
294 # break things up into the proper sequence.
295 return 0
296 t.sendto(data)
297 return len(data)
299 def _event(self, level, code):
300 if (level, code) == (LEVEL_NOALERT, DTLS_EVENT_CONNECT):
301 return
302 elif (level, code) == (LEVEL_NOALERT, DTLS_EVENT_CONNECTED):
303 self._connecting.set_result(True)
304 elif (level, code) == (LEVEL_FATAL, CODE_CLOSE_NOTIFY):
305 self._inject_error(CloseNotifyReceived())
306 elif level == LEVEL_FATAL:
307 self._inject_error(FatalDTLSError(code))
308 else:
309 self.log.warning("Unhandled alert level %d code %d", level, code)
311 # transport protocol
313 class SingleConnection:
314 @classmethod
315 def factory(cls, parent):
316 return functools.partial(cls, weakref.ref(parent))
318 def __init__(self, parent):
319 self.parent = parent #: DTLSClientConnection
321 def connection_made(self, transport):
322 # only for for shutdown
323 self.transport = transport
325 def connection_lost(self, exc):
326 pass
328 def error_received(self, exc):
329 parent = self.parent()
330 if parent is None:
331 self.transport.close()
332 return
333 parent._inject_error(exc)
335 def datagram_received(self, data, addr):
336 parent = self.parent()
337 if parent is None:
338 self.transport.close()
339 return
340 parent._dtls_socket.handleMessage(parent._connection, data)
343class MessageInterfaceTinyDTLS(interfaces.MessageInterface):
344 def __init__(self, ctx: interfaces.MessageManager, log, loop):
345 self._pool: weakref.WeakValueDictionary = weakref.WeakValueDictionary(
346 {}
347 ) # see _connection_for_address
349 self.ctx = ctx
351 self.log = log
352 self.loop = loop
354 def _connection_for_address(self, host, port, pskId, psk):
355 """Return a DTLSConnection to a given address. This will always give
356 the same result for the same host/port combination, at least for as
357 long as that result is kept alive (eg. by messages referring to it in
358 their .remote) and while the connection has not failed."""
360 try:
361 return self._pool[(host, port, pskId)]
362 except KeyError:
363 self.log.info(
364 "No DTLS connection active to (%s, %s, %s), creating one",
365 host,
366 port,
367 pskId,
368 )
369 connection = DTLSClientConnection(host, port, pskId, psk, self)
370 self._pool[(host, port, pskId)] = connection
371 return connection
373 @classmethod
374 async def create_client_transport_endpoint(
375 cls, ctx: interfaces.MessageManager, log, loop
376 ):
377 return cls(ctx, log, loop)
379 async def recognize_remote(self, remote):
380 return (
381 isinstance(remote, DTLSClientConnection) and remote in self._pool.values()
382 )
384 async def determine_remote(self, request):
385 if request.requested_scheme != "coaps":
386 return None
388 if request.unresolved_remote:
389 host, port = hostportsplit(request.unresolved_remote)
390 port = port or COAPS_PORT
391 elif request.opt.uri_host:
392 host = request.opt.uri_host
393 port = request.opt.uri_port or COAPS_PORT
394 else:
395 raise ValueError(
396 "No location found to send message to (neither in .opt.uri_host nor in .remote)"
397 )
399 dtlsparams = self.ctx.client_credentials.credentials_from_request(request)
400 try:
401 pskId, psk = dtlsparams.as_dtls_psk()
402 except AttributeError:
403 raise CredentialsMissingError(
404 "Credentials for requested URI are not compatible with DTLS-PSK"
405 )
406 result = self._connection_for_address(host, port, pskId, psk)
407 return result
409 def send(self, message):
410 message.remote.send(message.encode())
412 async def shutdown(self):
413 remaining_connections = list(self._pool.values())
414 for c in remaining_connections:
415 c.shutdown()