Coverage for aiocoap/transports/tinydtls_server.py: 89%

132 statements  

« prev     ^ index     » next       coverage.py v7.5.4, created at 2024-07-10 11:47 +0000

1# SPDX-FileCopyrightText: Christian Amsüss and the aiocoap contributors 

2# 

3# SPDX-License-Identifier: MIT 

4 

5"""This module implements a MessageInterface that serves coaps:// using a 

6wrapped tinydtls library. 

7 

8Bear in mind that the aiocoap CoAPS support is highly experimental and 

9incomplete. 

10 

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""" 

20 

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. 

30 

31import asyncio 

32from collections import OrderedDict 

33 

34import time 

35 

36from ..numbers import COAPS_PORT, constants 

37from .generic_udp import GenericMessageInterface 

38from .. import error, interfaces 

39from . import simplesocketserver 

40from .simplesocketserver import _DatagramServerSocketSimple 

41 

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) 

53 

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 

59 

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 

63 

64 

65class _AddressDTLS(interfaces.EndpointAddress): 

66 # no slots here, thus no equality other than identity, which is good 

67 

68 def __init__(self, protocol, underlying_address): 

69 from DTLSSocket import dtls 

70 

71 self._protocol = protocol 

72 self._underlying_address = simplesocketserver._Address( 

73 protocol, underlying_address 

74 ) 

75 

76 self._dtls_socket = None 

77 

78 self._psk_store = SecurityStore(protocol._server_credentials) 

79 

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) 

89 

90 self._retransmission_task = asyncio.create_task( 

91 self._run_retransmissions(), 

92 name="DTLS server handshake retransmissions", 

93 ) 

94 

95 self.log = protocol.log 

96 

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) 

103 

104 scheme = "coaps" 

105 

106 authenticated_claims = property(lambda self: [self._psk_store._claims]) 

107 

108 # Unlike for other remotes, this is settable per instance. 

109 maximum_block_size_exp = constants.MAX_REGULAR_BLOCK_SIZE_EXP 

110 

111 @property 

112 def blockwise_key(self): 

113 return (self._underlying_address.blockwise_key, self._psk_store._claims) 

114 

115 # implementing GenericUdp addresses 

116 

117 def send(self, message): 

118 self._dtls_socket.write(self._dtls_session, message) 

119 

120 # dtls callbacks 

121 

122 def _read(self, sender, data): 

123 # ignoring sender: it's only _SENTINEL_* 

124 self._protocol._message_interface._received_plaintext(self, data) 

125 

126 return len(data) 

127 

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) 

138 

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) 

151 

152 # own helpers copied and adjusted from tinydtls 

153 

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) 

159 

160 self._retransmission_task.cancel() 

161 

162 self._protocol._connections.pop(self._underlying_address.address) 

163 

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 ) 

181 

182 

183class _DatagramServerSocketSimpleDTLS(_DatagramServerSocketSimple): 

184 _Address = _AddressDTLS # type: ignore 

185 max_sockets = 64 

186 

187 def __init__(self, *args, **kwargs): 

188 self._connections = OrderedDict() # analogous to simple6's _sockets 

189 return super().__init__(*args, **kwargs) 

190 

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") 

196 

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 

204 

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) 

214 

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 ) 

222 

223 

224class GoingThroughMessageDecryption: 

225 """Warapper around GenericMessageInterface that puts incoming data through 

226 the DTLS context stored with the address""" 

227 

228 def __init__(self, plaintext_interface: "GenericMessageInterface"): 

229 self._plaintext_interface = plaintext_interface 

230 

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 ) 

239 

240 def _received_exception(self, address, exception): 

241 self._plaintext_interface._received_exception(address, exception) 

242 

243 def _received_plaintext(self, address, data): 

244 self._plaintext_interface._received_datagram(address, data) 

245 

246 

247class SecurityStore: 

248 """Wrapper around a CredentialsMap that makes it accessible to the 

249 dict-like object DTLSSocket expects. 

250 

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. 

254 

255 Therefore, SecurityStore objects are created per connection and not per 

256 security store. 

257 """ 

258 

259 def __init__(self, server_credentials): 

260 self._server_credentials = server_credentials 

261 

262 self._claims = None 

263 

264 def keys(self): 

265 return self 

266 

267 def __contains__(self, key): 

268 try: 

269 self._server_credentials.find_dtls_psk(key) 

270 return True 

271 except KeyError: 

272 return False 

273 

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 

282 

283 

284class MessageInterfaceTinyDTLSServer(simplesocketserver.MessageInterfaceSimpleServer): 

285 _default_port = COAPS_PORT 

286 _serversocket = _DatagramServerSocketSimpleDTLS 

287 

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) 

293 

294 self._pool._server_credentials = server_credentials 

295 

296 return self 

297 

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()