Skip to content

Commit 7b7d436

Browse files
committed
Add experimental DTLS server functionality
2 parents c150989 + 9fddd87 commit 7b7d436

File tree

7 files changed

+338
-5
lines changed

7 files changed

+338
-5
lines changed

aiocoap/cli/common.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,5 +126,7 @@ async def server_context_from_arguments(site, namespace, **kwargs):
126126
# actual identities present?
127127
from aiocoap.oscore_sitewrapper import OscoreSiteWrapper
128128
site = OscoreSiteWrapper(site, server_credentials)
129+
else:
130+
server_credentials = None
129131

130-
return await Context.create_server_context(site, namespace.bind, _ssl_context=ssl_context, **kwargs)
132+
return await Context.create_server_context(site, namespace.bind, _ssl_context=ssl_context, server_credentials=server_credentials, **kwargs)

aiocoap/credentials.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,3 +325,19 @@ def find_oscore(self, unprotected):
325325
return ctx
326326

327327
raise KeyError()
328+
329+
def find_dtls_psk(self, identity):
330+
# FIXME similar to find_oscore
331+
for (entry, item) in self.items():
332+
if not hasattr(item, "as_dtls_psk"):
333+
continue
334+
335+
psk_id, psk = item.as_dtls_psk()
336+
if psk_id != identity:
337+
continue
338+
339+
# FIXME is returning the entry name a sane value to later put in to
340+
# authenticated_claims? OSCORE does something different.
341+
return (psk, entry)
342+
343+
raise KeyError()

aiocoap/defaults.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,9 @@ def get_default_servertransports(*, loop=None, use_env=True):
8787
if not oscore_missing_modules():
8888
yield 'oscore'
8989

90-
# no server support yet, but doesn't hurt either
9190
if not dtls_missing_modules():
91+
if 'AIOCOAP_DTLSSERVER_ENABLED' in os.environ:
92+
yield 'tinydtls_server'
9293
yield 'tinydtls'
9394

9495
yield 'tcpserver'

aiocoap/protocol.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ class Context(interfaces.RequestProvider):
119119
everything not already mentioned).
120120
121121
"""
122-
def __init__(self, loop=None, serversite=None, loggername="coap", client_credentials=None):
122+
def __init__(self, loop=None, serversite=None, loggername="coap", client_credentials=None, server_credentials=None):
123123
self.log = logging.getLogger(loggername)
124124

125125
self.loop = loop or asyncio.get_event_loop()
@@ -131,6 +131,7 @@ def __init__(self, loop=None, serversite=None, loggername="coap", client_credent
131131
self._running_renderings = set()
132132

133133
self.client_credentials = client_credentials or CredentialsMap()
134+
self.server_credentials = server_credentials or CredentialsMap()
134135

135136
# FIXME: consider introducing a TimeoutDict
136137
self._block1_assemblies = {} # mapping block-key to (partial request, timeout handle)
@@ -209,7 +210,7 @@ async def create_client_context(cls, *, loggername="coap", loop=None):
209210
return self
210211

211212
@classmethod
212-
async def create_server_context(cls, site, bind=None, *, loggername="coap-server", loop=None, _ssl_context=None, multicast=[]):
213+
async def create_server_context(cls, site, bind=None, *, loggername="coap-server", loop=None, _ssl_context=None, multicast=[], server_credentials=None):
213214
"""Create a context, bound to all addresses on the CoAP port (unless
214215
otherwise specified in the ``bind`` argument).
215216
@@ -239,7 +240,7 @@ async def create_server_context(cls, site, bind=None, *, loggername="coap-server
239240
if loop is None:
240241
loop = asyncio.get_event_loop()
241242

242-
self = cls(loop=loop, serversite=site, loggername=loggername)
243+
self = cls(loop=loop, serversite=site, loggername=loggername, server_credentials=server_credentials)
243244

244245
multicast_done = not multicast
245246

@@ -261,6 +262,11 @@ async def create_server_context(cls, site, bind=None, *, loggername="coap-server
261262
await self._append_tokenmanaged_messagemanaged_transport(
262263
lambda mman: MessageInterfaceTinyDTLS.create_client_transport_endpoint(mman, log=self.log, loop=loop))
263264
# FIXME end duplication
265+
elif transportname == 'tinydtls_server':
266+
from .transports.tinydtls_server import MessageInterfaceTinyDTLSServer
267+
268+
await self._append_tokenmanaged_messagemanaged_transport(
269+
lambda mman: MessageInterfaceTinyDTLSServer.create_server(bind, mman, log=self.log, loop=loop, server_credentials=self.server_credentials))
264270
elif transportname == 'simplesocketserver':
265271
from .transports.simplesocketserver import MessageInterfaceSimpleServer
266272
await self._append_tokenmanaged_messagemanaged_transport(
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
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()

tests/common.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
import gbulb
2222
gbulb.install()
2323

24+
# All test servers are bound to loopback; if for any reason one'd want to run
25+
# with particular transports, just set them explicitly.
26+
os.environ['AIOCOAP_DTLSSERVER_ENABLED'] = '1'
27+
2428
if 'coverage' in sys.modules:
2529
PYTHON_PREFIX = [sys.executable, '-m', 'coverage', 'run', '--parallel-mode']
2630
else:
@@ -87,6 +91,7 @@ def _find_loopbacknames():
8791

8892
tcp_disabled = 'tcp' not in os.environ.get('AIOCOAP_SERVER_TRANSPORT', 'tcp is default')
8993
ws_disabled = 'ws' not in os.environ.get('AIOCOAP_SERVER_TRANSPORT', 'ws is default')
94+
dtls_disabled = 'dtls' not in os.environ.get('AIOCOAP_SERVER_TRANSPORT', 'dtls is default')
9095

9196
if __name__ == "__main__":
9297
print("Python prefix:", PYTHON_PREFIX)

0 commit comments

Comments
 (0)