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