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

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

54 

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 

60 

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 

64 

65 

66class _AddressDTLS(interfaces.EndpointAddress): 

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

68 

69 def __init__(self, protocol, underlying_address): 

70 from DTLSSocket import dtls 

71 

72 self._protocol = protocol 

73 self._underlying_address = simplesocketserver._Address( 

74 protocol, underlying_address 

75 ) 

76 

77 self._dtls_socket = None 

78 

79 self._psk_store = SecurityStore(protocol._server_credentials) 

80 

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) 

90 

91 self._retransmission_task = asyncio.create_task( 

92 self._run_retransmissions(), 

93 name="DTLS server handshake retransmissions", 

94 ) 

95 

96 self.log = protocol.log 

97 

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) 

104 

105 scheme = "coaps" 

106 

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

108 

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

110 maximum_block_size_exp = constants.MAX_REGULAR_BLOCK_SIZE_EXP 

111 

112 @property 

113 def blockwise_key(self): 

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

115 

116 # implementing GenericUdp addresses 

117 

118 def send(self, message): 

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

120 

121 # dtls callbacks 

122 

123 def _read(self, sender, data): 

124 # ignoring sender: it's only _SENTINEL_* 

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

126 

127 return len(data) 

128 

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) 

139 

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 ) 

157 

158 # own helpers copied and adjusted from tinydtls 

159 

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) 

165 

166 self._retransmission_task.cancel() 

167 

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

169 

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 ) 

187 

188 

189class _DatagramServerSocketSimpleDTLS(_DatagramServerSocketSimple): 

190 _Address = _AddressDTLS # type: ignore 

191 max_sockets = 64 

192 

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

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

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

196 

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

202 

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 

210 

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) 

220 

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 ) 

228 

229 

230class GoingThroughMessageDecryption: 

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

232 the DTLS context stored with the address""" 

233 

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

235 self._plaintext_interface = plaintext_interface 

236 

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 ) 

245 

246 def _received_exception(self, address, exception): 

247 self._plaintext_interface._received_exception(address, exception) 

248 

249 def _received_plaintext(self, address, data): 

250 self._plaintext_interface._received_datagram(address, data) 

251 

252 

253class SecurityStore: 

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

255 dict-like object DTLSSocket expects. 

256 

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. 

260 

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

262 security store. 

263 """ 

264 

265 def __init__(self, server_credentials): 

266 self._server_credentials = server_credentials 

267 

268 self._claims = None 

269 

270 def keys(self): 

271 return self 

272 

273 def __contains__(self, key): 

274 try: 

275 self._server_credentials.find_dtls_psk(key) 

276 return True 

277 except KeyError: 

278 return False 

279 

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 

288 

289 

290class MessageInterfaceTinyDTLSServer(simplesocketserver.MessageInterfaceSimpleServer): 

291 _default_port = COAPS_PORT 

292 _serversocket = _DatagramServerSocketSimpleDTLS 

293 

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) 

299 

300 self._pool._server_credentials = server_credentials 

301 

302 return self 

303 

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