Coverage for aiocoap/transports/tinydtls_server.py: 92%
132 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 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 level_names,
46 DTLS_EVENT_CONNECT,
47 DTLS_EVENT_CONNECTED,
48 CODE_CLOSE_NOTIFY,
49 CloseNotifyReceived,
50 DTLS_TICKS_PER_SECOND,
51 DTLS_CLOCK_OFFSET,
52 FatalDTLSError,
53)
55# tinyDTLS passes address information around in its session data, but the way
56# it's used here that will be ignored; this is the data that is sent to / read
57# from the tinyDTLS functions
58_SENTINEL_ADDRESS = "::1"
59_SENTINEL_PORT = 1234
61# While we don't have retransmissions set up, this helps work issues of dropped
62# packets from sending in rapid succession
63_SEND_SLEEP_WORKAROUND = 0
66class _AddressDTLS(interfaces.EndpointAddress):
67 # no slots here, thus no equality other than identity, which is good
69 def __init__(self, protocol, underlying_address):
70 from DTLSSocket import dtls
72 self._protocol = protocol
73 self._underlying_address = simplesocketserver._Address(
74 protocol, underlying_address
75 )
77 self._dtls_socket = None
79 self._psk_store = SecurityStore(protocol._server_credentials)
81 self._dtls_socket = dtls.DTLS(
82 # FIXME: Use accessors like tinydtls (but are they needed? maybe shutdown sequence is just already better here...)
83 read=self._read,
84 write=self._write,
85 event=self._event,
86 pskId=b"The socket needs something there but we'll never use it",
87 pskStore=self._psk_store,
88 )
89 self._dtls_session = dtls.Session(_SENTINEL_ADDRESS, _SENTINEL_PORT)
91 self._retransmission_task = asyncio.create_task(
92 self._run_retransmissions(),
93 name="DTLS server handshake retransmissions",
94 )
96 self.log = protocol.log
98 is_multicast = False
99 is_multicast_locally = False
100 hostinfo = property(lambda self: self._underlying_address.hostinfo)
101 uri_base = property(lambda self: "coaps://" + self.hostinfo)
102 hostinfo_local = property(lambda self: self._underlying_address.hostinfo_local)
103 uri_base_local = property(lambda self: "coaps://" + self.hostinfo_local)
105 scheme = "coaps"
107 authenticated_claims = property(lambda self: [self._psk_store._claims])
109 # Unlike for other remotes, this is settable per instance.
110 maximum_block_size_exp = constants.MAX_REGULAR_BLOCK_SIZE_EXP
112 @property
113 def blockwise_key(self):
114 return (self._underlying_address.blockwise_key, self._psk_store._claims)
116 # implementing GenericUdp addresses
118 def send(self, message):
119 self._dtls_socket.write(self._dtls_session, message)
121 # dtls callbacks
123 def _read(self, sender, data):
124 # ignoring sender: it's only _SENTINEL_*
125 self._protocol._message_interface._received_plaintext(self, data)
127 return len(data)
129 def _write(self, recipient, data):
130 if (
131 _SEND_SLEEP_WORKAROUND
132 and len(data) > 13
133 and data[0] == 22
134 and data[13] == 14
135 ):
136 time.sleep(_SEND_SLEEP_WORKAROUND)
137 self._underlying_address.send(data)
138 return len(data)
140 def _event(self, level, code):
141 if (level, code) == (LEVEL_NOALERT, DTLS_EVENT_CONNECT):
142 return
143 elif (level, code) == (LEVEL_NOALERT, DTLS_EVENT_CONNECTED):
144 # No need to react to "connected": We're not the ones sending the first message
145 return
146 elif (level, code) == (LEVEL_FATAL, CODE_CLOSE_NOTIFY):
147 self._inject_error(CloseNotifyReceived())
148 elif level == LEVEL_FATAL:
149 self._inject_error(FatalDTLSError(code))
150 else:
151 self.log.warning(
152 "Unhandled alert level %d (%s) code %d",
153 level,
154 level_names.get(level, "unknown"),
155 code,
156 )
158 # own helpers copied and adjusted from tinydtls
160 def _inject_error(self, e):
161 # this includes "was shut down" with a CloseNotifyReceived e
162 """Put an error to all pending operations on this remote, just as if it
163 were raised inside the main loop."""
164 self._protocol._message_interface._received_exception(self, e)
166 self._retransmission_task.cancel()
168 self._protocol._connections.pop(self._underlying_address.address)
170 # This is a bit more defensive than the one in tinydtls as it starts out in
171 # waiting, and RFC6347 indicates on a brief glance that the state machine
172 # could go from waiting to some other state later on, so we (re)trigger it
173 # whenever something comes in
174 async def _run_retransmissions(self):
175 when = self._dtls_socket.checkRetransmit() / DTLS_TICKS_PER_SECOND
176 if when == 0:
177 return
178 # FIXME: Find out whether the DTLS server is ever supposed to send
179 # retransmissions in the first place (this part was missing an import
180 # and it never showed).
181 now = time.time() - DTLS_CLOCK_OFFSET
182 await asyncio.sleep(when - now)
183 self._retransmission_task = asyncio.create_task(
184 self._run_retransmissions(),
185 name="DTLS server handshake retransmissions",
186 )
189class _DatagramServerSocketSimpleDTLS(_DatagramServerSocketSimple):
190 _Address = _AddressDTLS # type: ignore
191 max_sockets = 64
193 def __init__(self, *args, **kwargs):
194 self._connections = OrderedDict() # analogous to simple6's _sockets
195 return super().__init__(*args, **kwargs)
197 async def connect(self, sockaddr):
198 # Even if we opened a connection, it wouldn't have the same security
199 # properties as the incoming one that it's probably supposed to replace
200 # would have had
201 raise RuntimeError("Sending initial messages via a DTLSServer is not supported")
203 # Overriding to use GoingThroughMessageDecryption adapter
204 @classmethod
205 async def create(cls, bind, log, loop, message_interface):
206 wrapped_interface = GoingThroughMessageDecryption(message_interface)
207 self = await super().create(bind, log, loop, wrapped_interface)
208 # self._security_store left uninitialized to ease subclassing from SimpleSocketServer; should be set before using this any further
209 return self
211 # Overriding as now we do need to manage the pol
212 def datagram_received(self, data, sockaddr):
213 if sockaddr in self._connections:
214 address = self._connections[sockaddr]
215 self._connections.move_to_end(sockaddr)
216 else:
217 address = self._Address(self, sockaddr)
218 self._connections[sockaddr] = address
219 self._message_interface._received_datagram(address, data)
221 def _maybe_purge_sockets(self):
222 while len(self._connections) >= self.max_sockets: # more of an if
223 oldaddr, oldest = next(iter(self._connections.items()))
224 # FIXME custom error?
225 oldest._inject_error(
226 error.LibraryShutdown("Connection is being closed for lack of activity")
227 )
230class GoingThroughMessageDecryption:
231 """Warapper around GenericMessageInterface that puts incoming data through
232 the DTLS context stored with the address"""
234 def __init__(self, plaintext_interface: "GenericMessageInterface"):
235 self._plaintext_interface = plaintext_interface
237 def _received_datagram(self, address, data):
238 # Put it into the DTLS processor; that'll forward any actually contained decrypted datagrams on to _received_plaintext
239 address._retransmission_task.cancel()
240 address._dtls_socket.handleMessage(address._dtls_session, data)
241 address._retransmission_task = asyncio.create_task(
242 address._run_retransmissions(),
243 name="DTLS server handshake retransmissions",
244 )
246 def _received_exception(self, address, exception):
247 self._plaintext_interface._received_exception(address, exception)
249 def _received_plaintext(self, address, data):
250 self._plaintext_interface._received_datagram(address, data)
253class SecurityStore:
254 """Wrapper around a CredentialsMap that makes it accessible to the
255 dict-like object DTLSSocket expects.
257 Not only does this convert interfaces, it also adds a back channel: As
258 DTLSSocket wouldn't otherwise report who authenticated, this is tracking
259 access and storing the claims associated with the used key for later use.
261 Therefore, SecurityStore objects are created per connection and not per
262 security store.
263 """
265 def __init__(self, server_credentials):
266 self._server_credentials = server_credentials
268 self._claims = None
270 def keys(self):
271 return self
273 def __contains__(self, key):
274 try:
275 self._server_credentials.find_dtls_psk(key)
276 return True
277 except KeyError:
278 return False
280 def __getitem__(self, key):
281 (psk, claims) = self._server_credentials.find_dtls_psk(key)
282 if self._claims not in (None, claims):
283 # I didn't know it could do that -- how would we know which is the
284 # one it eventually picked?
285 raise RuntimeError("DTLS stack tried accessing different keys")
286 self._claims = claims
287 return psk
290class MessageInterfaceTinyDTLSServer(simplesocketserver.MessageInterfaceSimpleServer):
291 _default_port = COAPS_PORT
292 _serversocket = _DatagramServerSocketSimpleDTLS
294 @classmethod
295 async def create_server(
296 cls, bind, ctx: interfaces.MessageManager, log, loop, server_credentials
297 ):
298 self = await super().create_server(bind, ctx, log, loop)
300 self._pool._server_credentials = server_credentials
302 return self
304 async def shutdown(self):
305 remaining_connections = list(self._pool._connections.values())
306 for c in remaining_connections:
307 c._inject_error(error.LibraryShutdown("Shutting down"))
308 await super().shutdown()