Coverage for aiocoap/transports/tinydtls.py: 82%

206 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-16 16:09 +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 handles coaps:// using a 

6wrapped tinydtls library. 

7 

8This currently only implements the client side. To have a test server, run:: 

9 

10 $ git clone https://github.com/obgm/libcoap.git --recursive 

11 $ cd libcoap 

12 $ ./autogen.sh 

13 $ ./configure --with-tinydtls --disable-shared --disable-documentation 

14 $ make 

15 $ ./examples/coap-server -k secretPSK 

16 

17(Using TinyDTLS in libcoap is important; with the default OpenSSL build, I've 

18seen DTLS1.0 responses to DTLS1.3 requests, which are hard to debug.) 

19 

20The test server with its built-in credentials can then be accessed using:: 

21 

22 $ echo '{"coaps://localhost/*": {"dtls": {"psk": {"ascii": "secretPSK"}, "client-identity": {"ascii": "client_Identity"}}}}' > testserver.json 

23 $ ./aiocoap-client coaps://localhost --credentials testserver.json 

24 

25While it is planned to allow more programmatical construction of the 

26credentials store, the currently recommended way of storing DTLS credentials is 

27to load a structured data object into the client_credentials store of the context: 

28 

29>>> c = await aiocoap.Context.create_client_context() # doctest: +SKIP 

30>>> c.client_credentials.load_from_dict( 

31... {'coaps://localhost/*': {'dtls': { 

32... 'psk': b'secretPSK', 

33... 'client-identity': b'client_Identity', 

34... }}}) # doctest: +SKIP 

35 

36where, compared to the JSON example above, byte strings can be used directly 

37rather than expressing them as 'ascii'/'hex' (`{'hex': '30383135'}` style works 

38as well) to work around JSON's limitation of not having raw binary strings. 

39 

40Bear in mind that the aiocoap CoAPS support is highly experimental; for 

41example, while requests to this server do complete, error messages are still 

42shown during client shutdown. 

43""" 

44 

45import asyncio 

46import weakref 

47import functools 

48import time 

49import warnings 

50 

51from ..util import hostportjoin, hostportsplit 

52from ..util.asyncio import py38args 

53from ..message import Message 

54from .. import interfaces, error 

55from ..numbers import COAPS_PORT 

56from ..credentials import CredentialsMissingError 

57 

58# tinyDTLS passes address information around in its session data, but the way 

59# it's used here that will be ignored; this is the data that is sent to / read 

60# from the tinyDTLS functions 

61_SENTINEL_ADDRESS = "::1" 

62_SENTINEL_PORT = 1234 

63 

64DTLS_EVENT_CONNECT = 0x01DC 

65DTLS_EVENT_CONNECTED = 0x01DE 

66DTLS_EVENT_RENEGOTIATE = 0x01DF 

67 

68LEVEL_NOALERT = 0 # seems only to be issued by tinydtls-internal events 

69 

70# from RFC 5246 

71LEVEL_WARNING = 1 

72LEVEL_FATAL = 2 

73CODE_CLOSE_NOTIFY = 0 

74 

75# tinydtls can not be debugged in the Python way; if you need to get more 

76# information out of it, use the following line: 

77#dtls.setLogLevel(dtls.DTLS_LOG_DEBUG) 

78 

79# FIXME this should be exposed by the dtls wrapper 

80DTLS_TICKS_PER_SECOND = 1000 

81DTLS_CLOCK_OFFSET = time.time() 

82 

83# Currently kept a bit private by not inheriting from NetworkError -- thus 

84# they'll be wrapped in a NetworkError when they fly out of a request. 

85class CloseNotifyReceived(Exception): 

86 """The DTLS connection a request was sent on raised was closed by the 

87 server while the request was being processed""" 

88 

89class FatalDTLSError(Exception): 

90 """The DTLS connection a request was sent on raised a fatal error while the 

91 request was being processed""" 

92 

93class DTLSClientConnection(interfaces.EndpointAddress): 

94 # FIXME not only does this not do error handling, it seems not to even 

95 # survive its 2**16th message exchange. 

96 

97 is_multicast = False 

98 is_multicast_locally = False 

99 hostinfo = None # stored at initualization time 

100 uri_base = property(lambda self: 'coaps://' + self.hostinfo) 

101 # Not necessarily very usable given we don't implement responding to server 

102 # connection, but valid anyway 

103 uri_base_local = property(lambda self: 'coaps://' + self.hostinfo_local) 

104 scheme = 'coaps' 

105 

106 @property 

107 def hostinfo_local(self): 

108 # See TCP's.hostinfo_local 

109 host, port, *_ = self._transport.get_extra_info('socket').getsockname() 

110 if port == COAPS_PORT: 

111 port = None 

112 return hostportjoin(host, port) 

113 

114 @property 

115 def blockwise_key(self): 

116 return (self._host, self._port, self._pskId, self._psk) 

117 

118 def __init__(self, host, port, pskId, psk, coaptransport): 

119 self._ready = False 

120 self._queue = [] # stores sent packages while connection is being built 

121 

122 self._host = host 

123 self._port = port 

124 self._pskId = pskId 

125 self._psk = psk 

126 self.coaptransport = coaptransport 

127 self.hostinfo = hostportjoin(host, None if port == COAPS_PORT else port) 

128 

129 self._startup = asyncio.ensure_future(self._start()) 

130 

131 def _remove_from_pool(self): 

132 """Remove self from the MessageInterfaceTinyDTLS's pool, so that it 

133 will not be used in new requests. 

134 

135 This is idempotent (to allow quick removal and still remove it in a 

136 finally clause) and not thread safe. 

137 """ 

138 poolkey = (self._host, self._port, self._pskId) 

139 if self.coaptransport._pool.get(poolkey) is self: 

140 del self.coaptransport._pool[poolkey] 

141 

142 def send(self, message): 

143 if self._queue is not None: 

144 self._queue.append(message) 

145 else: 

146 # most of the time that will have returned long ago 

147 self._retransmission_task.cancel() 

148 

149 self._dtls_socket.write(self._connection, message) 

150 

151 self._retransmission_task = asyncio.create_task( 

152 self._run_retransmissions(), 

153 **py38args(name="DTLS handshake retransmissions") 

154 ) 

155 

156 log = property(lambda self: self.coaptransport.log) 

157 

158 def _build_accessor(self, method, deadvalue): 

159 """Think self._build_accessor('_write')() == self._write(), just that 

160 it's returning a weak wrapper that allows refcounting-based GC to 

161 happen when the remote falls out of use""" 

162 weakself = weakref.ref(self) 

163 def wrapper(*args, __weakself=weakself, __method=method, __deadvalue=deadvalue): 

164 self = __weakself() 

165 if self is None: 

166 warnings.warn("DTLS module did not shut down the DTLSSocket " 

167 "perfectly; it still tried to call %s in vain" % 

168 __method) 

169 return __deadvalue 

170 return getattr(self, __method)(*args) 

171 wrapper.__name__ = "_build_accessor(%s)" % method 

172 return wrapper 

173 

174 async def _start(self): 

175 from DTLSSocket import dtls 

176 

177 self._dtls_socket = None 

178 

179 self._connection = None 

180 

181 try: 

182 self._transport, _ = await self.coaptransport.loop.create_datagram_endpoint( 

183 self.SingleConnection.factory(self), 

184 remote_addr=(self._host, self._port), 

185 ) 

186 

187 self._dtls_socket = dtls.DTLS( 

188 read=self._build_accessor("_read", 0), 

189 write=self._build_accessor("_write", 0), 

190 event=self._build_accessor("_event", 0), 

191 pskId=self._pskId, 

192 pskStore={self._pskId: self._psk}, 

193 ) 

194 self._connection = self._dtls_socket.connect(_SENTINEL_ADDRESS, _SENTINEL_PORT) 

195 

196 self._retransmission_task = asyncio.create_task( 

197 self._run_retransmissions(), 

198 **py38args(name="DTLS handshake retransmissions") 

199 ) 

200 

201 self._connecting = asyncio.get_running_loop().create_future() 

202 await self._connecting 

203 

204 queue = self._queue 

205 self._queue = None 

206 

207 for message in queue: 

208 # could be a tad more efficient by stopping the retransmissions 

209 # in a go, then doing just the punch line and then starting it, 

210 # but practically this will be a single thing most of the time 

211 # anyway 

212 self.send(message) 

213 

214 return 

215 

216 except asyncio.CancelledError: 

217 # Can be removed starting with Python 3.8 as it's a workaround for 

218 # https://bugs.python.org/issue32528 

219 raise 

220 except Exception as e: 

221 self.coaptransport.ctx.dispatch_error(e, self) 

222 finally: 

223 if self._queue is None: 

224 # all worked, we're done here 

225 return 

226 

227 self.shutdown() 

228 

229 async def _run_retransmissions(self): 

230 while True: 

231 when = self._dtls_socket.checkRetransmit() / DTLS_TICKS_PER_SECOND 

232 if when == 0: 

233 return 

234 now = time.time() - DTLS_CLOCK_OFFSET 

235 await asyncio.sleep(when - now) 

236 

237 

238 def shutdown(self): 

239 self._remove_from_pool() 

240 

241 self._startup.cancel() 

242 self._retransmission_task.cancel() 

243 

244 if self._connection is not None: 

245 try: 

246 self._dtls_socket.close(self._connection) 

247 except Exception: 

248 pass # _dtls_socket actually does raise an empty Exception() here 

249 # doing this here allows the dtls socket to send a final word, but 

250 # by closing this, we protect the nascent next connection from any 

251 # delayed ICMP errors that might still wind up in the old socket 

252 self._transport.close() 

253 

254 def __del__(self): 

255 # Breaking the loops between the DTLS object and this here to allow for 

256 # an orderly Alet (fatal, close notify) to go out -- and also because 

257 # DTLSSocket throws `TypeError: 'NoneType' object is not subscriptable` 

258 # from its destructor while the cyclical dependency is taken down. 

259 self.shutdown() 

260 

261 def _inject_error(self, e): 

262 """Put an error to all pending operations on this remote, just as if it 

263 were raised inside the main loop.""" 

264 

265 self.coaptransport.ctx.dispatch_error(e, self) 

266 

267 self.shutdown() 

268 

269 # dtls callbacks 

270 

271 def _read(self, sender, data): 

272 # ignoring sender: it's only _SENTINEL_* 

273 

274 try: 

275 message = Message.decode(data, self) 

276 except error.UnparsableMessage: 

277 self.log.warning("Ignoring unparsable message from %s", sender) 

278 return len(data) 

279 

280 self.coaptransport.ctx.dispatch_message(message) 

281 

282 return len(data) 

283 

284 def _write(self, recipient, data): 

285 # ignoring recipient: it's only _SENTINEL_* 

286 try: 

287 t = self._transport 

288 except Exception: 

289 # tinydtls sends callbacks very very late during shutdown (ie. 

290 # `hasattr` and `AttributeError` are all not available any more, 

291 # and even if the DTLSClientConnection class had a ._transport, it 

292 # would already be gone), and it seems even a __del__ doesn't help 

293 # break things up into the proper sequence. 

294 return 0 

295 t.sendto(data) 

296 return len(data) 

297 

298 def _event(self, level, code): 

299 if (level, code) == (LEVEL_NOALERT, DTLS_EVENT_CONNECT): 

300 return 

301 elif (level, code) == (LEVEL_NOALERT, DTLS_EVENT_CONNECTED): 

302 self._connecting.set_result(True) 

303 elif (level, code) == (LEVEL_FATAL, CODE_CLOSE_NOTIFY): 

304 self._inject_error(CloseNotifyReceived()) 

305 elif level == LEVEL_FATAL: 

306 self._inject_error(FatalDTLSError(code)) 

307 else: 

308 self.log.warning("Unhandled alert level %d code %d", level, code) 

309 

310 # transport protocol 

311 

312 class SingleConnection: 

313 @classmethod 

314 def factory(cls, parent): 

315 return functools.partial(cls, weakref.ref(parent)) 

316 

317 def __init__(self, parent): 

318 self.parent = parent #: DTLSClientConnection 

319 

320 def connection_made(self, transport): 

321 # only for for shutdown 

322 self.transport = transport 

323 

324 def connection_lost(self, exc): 

325 pass 

326 

327 def error_received(self, exc): 

328 parent = self.parent() 

329 if parent is None: 

330 self.transport.close() 

331 return 

332 parent._inject_error(exc) 

333 

334 def datagram_received(self, data, addr): 

335 parent = self.parent() 

336 if parent is None: 

337 self.transport.close() 

338 return 

339 parent._dtls_socket.handleMessage(parent._connection, data) 

340 

341class MessageInterfaceTinyDTLS(interfaces.MessageInterface): 

342 def __init__(self, ctx: interfaces.MessageManager, log, loop): 

343 self._pool = weakref.WeakValueDictionary({}) # see _connection_for_address 

344 

345 self.ctx = ctx 

346 

347 self.log = log 

348 self.loop = loop 

349 

350 def _connection_for_address(self, host, port, pskId, psk): 

351 """Return a DTLSConnection to a given address. This will always give 

352 the same result for the same host/port combination, at least for as 

353 long as that result is kept alive (eg. by messages referring to it in 

354 their .remote) and while the connection has not failed.""" 

355 

356 try: 

357 return self._pool[(host, port, pskId)] 

358 except KeyError: 

359 self.log.info("No DTLS connection active to (%s, %s, %s), creating one", host, port, pskId) 

360 connection = DTLSClientConnection(host, port, pskId, psk, self) 

361 self._pool[(host, port, pskId)] = connection 

362 return connection 

363 

364 @classmethod 

365 async def create_client_transport_endpoint(cls, ctx: interfaces.MessageManager, log, loop): 

366 return cls(ctx, log, loop) 

367 

368 async def recognize_remote(self, remote): 

369 return isinstance(remote, DTLSClientConnection) and remote in self._pool.values() 

370 

371 async def determine_remote(self, request): 

372 if request.requested_scheme != 'coaps': 

373 return None 

374 

375 if request.unresolved_remote: 

376 host, port = hostportsplit(request.unresolved_remote) 

377 port = port or COAPS_PORT 

378 elif request.opt.uri_host: 

379 host = request.opt.uri_host 

380 port = request.opt.uri_port or COAPS_PORT 

381 else: 

382 raise ValueError("No location found to send message to (neither in .opt.uri_host nor in .remote)") 

383 

384 dtlsparams = self.ctx.client_credentials.credentials_from_request(request) 

385 try: 

386 pskId, psk = dtlsparams.as_dtls_psk() 

387 except AttributeError: 

388 raise CredentialsMissingError("Credentials for requested URI are not compatible with DTLS-PSK") 

389 result = self._connection_for_address(host, port, pskId, psk) 

390 return result 

391 

392 def send(self, message): 

393 message.remote.send(message.encode()) 

394 

395 async def shutdown(self): 

396 remaining_connections = list(self._pool.values()) 

397 for c in remaining_connections: 

398 c.shutdown()