Coverage for aiocoap/transports/tinydtls_server.py: 89%
132 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 serves coaps:// using a
6wrapped tinydtls library.
8Bear in mind that the aiocoap CoAPS support is highly experimental and
9incomplete.
11Unlike other transports this is *not* enabled automatically in general, as it
12is limited to servers bound to a single address for implementation reasons.
13(Basically, because it is built on the simplesocketserver rather than the udp6
14server -- that can change in future, though). Until either the implementation
15is changed or binding arguments are (allowing different transports to bind to
16per-transport addresses or ports), a DTLS server will only be enabled if the
17AIOCOAP_DTLSSERVER_ENABLED environment variable is set, or tinydtls_server is
18listed explicitly in AIOCOAP_SERVER_TRANSPORT.
19"""
21# Comparing this to the tinydtls transport, things are a bit easier as we don't
22# expect to send the first DTLS payload (thus don't need the queue), and don't
23# need that clean a cleanup (at least if we assume that the clients all shut
24# down on their own anyway).
25#
26# Then again, keeping connections live for as long as someone holds their
27# address (eg. by some "pool with N strong references, and the rest are weak"
28# and just go away on overflow unless someone keeps the address alive) would be
29# more convenient here.
31import asyncio
32from collections import OrderedDict
34import time
36from ..numbers import COAPS_PORT, constants
37from .generic_udp import GenericMessageInterface
38from .. import error, interfaces
39from . import simplesocketserver
40from .simplesocketserver import _DatagramServerSocketSimple
42from .tinydtls import (
43 LEVEL_NOALERT,
44 LEVEL_FATAL,
45 DTLS_EVENT_CONNECT,
46 DTLS_EVENT_CONNECTED,
47 CODE_CLOSE_NOTIFY,
48 CloseNotifyReceived,
49 DTLS_TICKS_PER_SECOND,
50 DTLS_CLOCK_OFFSET,
51 FatalDTLSError,
52)
54# tinyDTLS passes address information around in its session data, but the way
55# it's used here that will be ignored; this is the data that is sent to / read
56# from the tinyDTLS functions
57_SENTINEL_ADDRESS = "::1"
58_SENTINEL_PORT = 1234
60# While we don't have retransmissions set up, this helps work issues of dropped
61# packets from sending in rapid succession
62_SEND_SLEEP_WORKAROUND = 0
65class _AddressDTLS(interfaces.EndpointAddress):
66 # no slots here, thus no equality other than identity, which is good
68 def __init__(self, protocol, underlying_address):
69 from DTLSSocket import dtls
71 self._protocol = protocol
72 self._underlying_address = simplesocketserver._Address(
73 protocol, underlying_address
74 )
76 self._dtls_socket = None
78 self._psk_store = SecurityStore(protocol._server_credentials)
80 self._dtls_socket = dtls.DTLS(
81 # FIXME: Use accessors like tinydtls (but are they needed? maybe shutdown sequence is just already better here...)
82 read=self._read,
83 write=self._write,
84 event=self._event,
85 pskId=b"The socket needs something there but we'll never use it",
86 pskStore=self._psk_store,
87 )
88 self._dtls_session = dtls.Session(_SENTINEL_ADDRESS, _SENTINEL_PORT)
90 self._retransmission_task = asyncio.create_task(
91 self._run_retransmissions(),
92 name="DTLS server handshake retransmissions",
93 )
95 self.log = protocol.log
97 is_multicast = False
98 is_multicast_locally = False
99 hostinfo = property(lambda self: self._underlying_address.hostinfo)
100 uri_base = property(lambda self: "coaps://" + self.hostinfo)
101 hostinfo_local = property(lambda self: self._underlying_address.hostinfo_local)
102 uri_base_local = property(lambda self: "coaps://" + self.hostinfo_local)
104 scheme = "coaps"
106 authenticated_claims = property(lambda self: [self._psk_store._claims])
108 # Unlike for other remotes, this is settable per instance.
109 maximum_block_size_exp = constants.MAX_REGULAR_BLOCK_SIZE_EXP
111 @property
112 def blockwise_key(self):
113 return (self._underlying_address.blockwise_key, self._psk_store._claims)
115 # implementing GenericUdp addresses
117 def send(self, message):
118 self._dtls_socket.write(self._dtls_session, message)
120 # dtls callbacks
122 def _read(self, sender, data):
123 # ignoring sender: it's only _SENTINEL_*
124 self._protocol._message_interface._received_plaintext(self, data)
126 return len(data)
128 def _write(self, recipient, data):
129 if (
130 _SEND_SLEEP_WORKAROUND
131 and len(data) > 13
132 and data[0] == 22
133 and data[13] == 14
134 ):
135 time.sleep(_SEND_SLEEP_WORKAROUND)
136 self._underlying_address.send(data)
137 return len(data)
139 def _event(self, level, code):
140 if (level, code) == (LEVEL_NOALERT, DTLS_EVENT_CONNECT):
141 return
142 elif (level, code) == (LEVEL_NOALERT, DTLS_EVENT_CONNECTED):
143 # No need to react to "connected": We're not the ones sending the first message
144 return
145 elif (level, code) == (LEVEL_FATAL, CODE_CLOSE_NOTIFY):
146 self._inject_error(CloseNotifyReceived())
147 elif level == LEVEL_FATAL:
148 self._inject_error(FatalDTLSError(code))
149 else:
150 self.log.warning("Unhandled alert level %d code %d", level, code)
152 # own helpers copied and adjusted from tinydtls
154 def _inject_error(self, e):
155 # this includes "was shut down" with a CloseNotifyReceived e
156 """Put an error to all pending operations on this remote, just as if it
157 were raised inside the main loop."""
158 self._protocol._message_interface._received_exception(self, e)
160 self._retransmission_task.cancel()
162 self._protocol._connections.pop(self._underlying_address.address)
164 # This is a bit more defensive than the one in tinydtls as it starts out in
165 # waiting, and RFC6347 indicates on a brief glance that the state machine
166 # could go from waiting to some other state later on, so we (re)trigger it
167 # whenever something comes in
168 async def _run_retransmissions(self):
169 when = self._dtls_socket.checkRetransmit() / DTLS_TICKS_PER_SECOND
170 if when == 0:
171 return
172 # FIXME: Find out whether the DTLS server is ever supposed to send
173 # retransmissions in the first place (this part was missing an import
174 # and it never showed).
175 now = time.time() - DTLS_CLOCK_OFFSET
176 await asyncio.sleep(when - now)
177 self._retransmission_task = asyncio.create_task(
178 self._run_retransmissions(),
179 name="DTLS server handshake retransmissions",
180 )
183class _DatagramServerSocketSimpleDTLS(_DatagramServerSocketSimple):
184 _Address = _AddressDTLS # type: ignore
185 max_sockets = 64
187 def __init__(self, *args, **kwargs):
188 self._connections = OrderedDict() # analogous to simple6's _sockets
189 return super().__init__(*args, **kwargs)
191 async def connect(self, sockaddr):
192 # Even if we opened a connection, it wouldn't have the same security
193 # properties as the incoming one that it's probably supposed to replace
194 # would have had
195 raise RuntimeError("Sending initial messages via a DTLSServer is not supported")
197 # Overriding to use GoingThroughMessageDecryption adapter
198 @classmethod
199 async def create(cls, bind, log, loop, message_interface):
200 wrapped_interface = GoingThroughMessageDecryption(message_interface)
201 self = await super().create(bind, log, loop, wrapped_interface)
202 # self._security_store left uninitialized to ease subclassing from SimpleSocketServer; should be set before using this any further
203 return self
205 # Overriding as now we do need to manage the pol
206 def datagram_received(self, data, sockaddr):
207 if sockaddr in self._connections:
208 address = self._connections[sockaddr]
209 self._connections.move_to_end(sockaddr)
210 else:
211 address = self._Address(self, sockaddr)
212 self._connections[sockaddr] = address
213 self._message_interface._received_datagram(address, data)
215 def _maybe_purge_sockets(self):
216 while len(self._connections) >= self.max_sockets: # more of an if
217 oldaddr, oldest = next(iter(self._connections.items()))
218 # FIXME custom error?
219 oldest._inject_error(
220 error.LibraryShutdown("Connection is being closed for lack of activity")
221 )
224class GoingThroughMessageDecryption:
225 """Warapper around GenericMessageInterface that puts incoming data through
226 the DTLS context stored with the address"""
228 def __init__(self, plaintext_interface: "GenericMessageInterface"):
229 self._plaintext_interface = plaintext_interface
231 def _received_datagram(self, address, data):
232 # Put it into the DTLS processor; that'll forward any actually contained decrypted datagrams on to _received_plaintext
233 address._retransmission_task.cancel()
234 address._dtls_socket.handleMessage(address._dtls_session, data)
235 address._retransmission_task = asyncio.create_task(
236 address._run_retransmissions(),
237 name="DTLS server handshake retransmissions",
238 )
240 def _received_exception(self, address, exception):
241 self._plaintext_interface._received_exception(address, exception)
243 def _received_plaintext(self, address, data):
244 self._plaintext_interface._received_datagram(address, data)
247class SecurityStore:
248 """Wrapper around a CredentialsMap that makes it accessible to the
249 dict-like object DTLSSocket expects.
251 Not only does this convert interfaces, it also adds a back channel: As
252 DTLSSocket wouldn't otherwise report who authenticated, this is tracking
253 access and storing the claims associated with the used key for later use.
255 Therefore, SecurityStore objects are created per connection and not per
256 security store.
257 """
259 def __init__(self, server_credentials):
260 self._server_credentials = server_credentials
262 self._claims = None
264 def keys(self):
265 return self
267 def __contains__(self, key):
268 try:
269 self._server_credentials.find_dtls_psk(key)
270 return True
271 except KeyError:
272 return False
274 def __getitem__(self, key):
275 (psk, claims) = self._server_credentials.find_dtls_psk(key)
276 if self._claims not in (None, claims):
277 # I didn't know it could do that -- how would we know which is the
278 # one it eventually picked?
279 raise RuntimeError("DTLS stack tried accessing different keys")
280 self._claims = claims
281 return psk
284class MessageInterfaceTinyDTLSServer(simplesocketserver.MessageInterfaceSimpleServer):
285 _default_port = COAPS_PORT
286 _serversocket = _DatagramServerSocketSimpleDTLS
288 @classmethod
289 async def create_server(
290 cls, bind, ctx: interfaces.MessageManager, log, loop, server_credentials
291 ):
292 self = await super().create_server(bind, ctx, log, loop)
294 self._pool._server_credentials = server_credentials
296 return self
298 async def shutdown(self):
299 remaining_connections = list(self._pool._connections.values())
300 for c in remaining_connections:
301 c._inject_error(error.LibraryShutdown("Shutting down"))
302 await super().shutdown()