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

205 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 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 ..message import Message 

53from .. import interfaces, error 

54from ..numbers import COAPS_PORT 

55from ..credentials import CredentialsMissingError 

56 

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

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

59# from the tinyDTLS functions 

60_SENTINEL_ADDRESS = "::1" 

61_SENTINEL_PORT = 1234 

62 

63DTLS_EVENT_CONNECT = 0x01DC 

64DTLS_EVENT_CONNECTED = 0x01DE 

65DTLS_EVENT_RENEGOTIATE = 0x01DF 

66 

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

68 

69# from RFC 5246 

70LEVEL_WARNING = 1 

71LEVEL_FATAL = 2 

72CODE_CLOSE_NOTIFY = 0 

73 

74level_names = { 

75 LEVEL_NOALERT: "no alert", 

76 LEVEL_WARNING: "warning", 

77 LEVEL_FATAL: "fatal", 

78} 

79 

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

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

82# dtls.setLogLevel(dtls.DTLS_LOG_DEBUG) 

83 

84# FIXME this should be exposed by the dtls wrapper 

85DTLS_TICKS_PER_SECOND = 1000 

86DTLS_CLOCK_OFFSET = time.time() 

87 

88 

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

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

91class CloseNotifyReceived(Exception): 

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

93 server while the request was being processed""" 

94 

95 

96class FatalDTLSError(Exception): 

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

98 request was being processed""" 

99 

100 

101class DTLSClientConnection(interfaces.EndpointAddress): 

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

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

104 

105 is_multicast = False 

106 is_multicast_locally = False 

107 hostinfo = None # stored at initualization time 

108 uri_base = property(lambda self: "coaps://" + self.hostinfo) 

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

110 # connection, but valid anyway 

111 uri_base_local = property(lambda self: "coaps://" + self.hostinfo_local) 

112 scheme = "coaps" 

113 

114 @property 

115 def hostinfo_local(self): 

116 # See TCP's.hostinfo_local 

117 host, port, *_ = self._transport.get_extra_info("socket").getsockname() 

118 if port == COAPS_PORT: 

119 port = None 

120 return hostportjoin(host, port) 

121 

122 @property 

123 def blockwise_key(self): 

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

125 

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

127 self._ready = False 

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

129 

130 self._host = host 

131 self._port = port 

132 self._pskId = pskId 

133 self._psk = psk 

134 self.coaptransport = coaptransport 

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

136 

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

138 

139 def _remove_from_pool(self): 

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

141 will not be used in new requests. 

142 

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

144 finally clause) and not thread safe. 

145 """ 

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

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

148 del self.coaptransport._pool[poolkey] 

149 

150 def send(self, message): 

151 if self._queue is not None: 

152 self._queue.append(message) 

153 else: 

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

155 self._retransmission_task.cancel() 

156 

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

158 

159 self._retransmission_task = asyncio.create_task( 

160 self._run_retransmissions(), 

161 name="DTLS handshake retransmissions", 

162 ) 

163 

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

165 

166 def _build_accessor(self, method, deadvalue): 

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

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

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

170 weakself = weakref.ref(self) 

171 

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

173 self = __weakself() 

174 if self is None: 

175 warnings.warn( 

176 "DTLS module did not shut down the DTLSSocket " 

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

178 ) 

179 return __deadvalue 

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

181 

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

183 return wrapper 

184 

185 async def _start(self): 

186 from DTLSSocket import dtls 

187 

188 self._dtls_socket = None 

189 

190 self._connection = None 

191 

192 try: 

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

194 self.SingleConnection.factory(self), 

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

196 ) 

197 

198 self._dtls_socket = dtls.DTLS( 

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

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

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

202 pskId=self._pskId, 

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

204 ) 

205 self._connection = self._dtls_socket.connect( 

206 _SENTINEL_ADDRESS, _SENTINEL_PORT 

207 ) 

208 

209 self._retransmission_task = asyncio.create_task( 

210 self._run_retransmissions(), 

211 name="DTLS handshake retransmissions", 

212 ) 

213 

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

215 await self._connecting 

216 

217 queue = self._queue 

218 self._queue = None 

219 

220 for message in queue: 

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

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

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

224 # anyway 

225 self.send(message) 

226 

227 return 

228 

229 except Exception as e: 

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

231 finally: 

232 if self._queue is None: 

233 # all worked, we're done here 

234 return 

235 

236 self.shutdown() 

237 

238 async def _run_retransmissions(self): 

239 while True: 

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

241 if when == 0: 

242 return 

243 now = time.time() - DTLS_CLOCK_OFFSET 

244 await asyncio.sleep(when - now) 

245 

246 def shutdown(self): 

247 self._remove_from_pool() 

248 

249 self._startup.cancel() 

250 self._retransmission_task.cancel() 

251 

252 if self._connection is not None: 

253 # This also does what `.close()` does -- that would happen at 

254 # __del__, but let's wait for that. 

255 self._dtls_socket.resetPeer(self._connection) 

256 # At least on Python 3.12 with websockets present in tests, this 

257 # needs to be dropped now or segfaults happen. 

258 self._connection = None 

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

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

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

262 self._transport.close() 

263 

264 def __del__(self): 

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

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

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

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

269 self.shutdown() 

270 

271 def _inject_error(self, e): 

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

273 were raised inside the main loop.""" 

274 

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

276 

277 self.shutdown() 

278 

279 # dtls callbacks 

280 

281 def _read(self, sender, data): 

282 # ignoring sender: it's only _SENTINEL_* 

283 

284 try: 

285 message = Message.decode(data, self) 

286 except error.UnparsableMessage: 

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

288 return len(data) 

289 

290 self.coaptransport.ctx.dispatch_message(message) 

291 

292 return len(data) 

293 

294 def _write(self, recipient, data): 

295 # ignoring recipient: it's only _SENTINEL_* 

296 try: 

297 t = self._transport 

298 except Exception: 

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

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

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

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

303 # break things up into the proper sequence. 

304 return 0 

305 t.sendto(data) 

306 return len(data) 

307 

308 def _event(self, level, code): 

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

310 return 

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

312 self._connecting.set_result(True) 

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

314 self._inject_error(CloseNotifyReceived()) 

315 elif level == LEVEL_FATAL: 

316 self._inject_error(FatalDTLSError(code)) 

317 else: 

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

319 

320 # transport protocol 

321 

322 class SingleConnection: 

323 @classmethod 

324 def factory(cls, parent): 

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

326 

327 def __init__(self, parent): 

328 self.parent = parent #: DTLSClientConnection 

329 

330 def connection_made(self, transport): 

331 # only for for shutdown 

332 self.transport = transport 

333 

334 def connection_lost(self, exc): 

335 pass 

336 

337 def error_received(self, exc): 

338 parent = self.parent() 

339 if parent is None: 

340 self.transport.close() 

341 return 

342 parent._inject_error(exc) 

343 

344 def datagram_received(self, data, addr): 

345 parent = self.parent() 

346 if parent is None: 

347 self.transport.close() 

348 return 

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

350 

351 

352class MessageInterfaceTinyDTLS(interfaces.MessageInterface): 

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

354 self._pool: weakref.WeakValueDictionary = weakref.WeakValueDictionary( 

355 {} 

356 ) # see _connection_for_address 

357 

358 self.ctx = ctx 

359 

360 self.log = log 

361 self.loop = loop 

362 

363 self._shutting_down = False 

364 

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

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

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

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

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

370 

371 try: 

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

373 except KeyError: 

374 self.log.info( 

375 "No DTLS connection active to (%s, %s, %s), creating one", 

376 host, 

377 port, 

378 pskId, 

379 ) 

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

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

382 return connection 

383 

384 @classmethod 

385 async def create_client_transport_endpoint( 

386 cls, ctx: interfaces.MessageManager, log, loop 

387 ): 

388 return cls(ctx, log, loop) 

389 

390 async def recognize_remote(self, remote): 

391 return ( 

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

393 ) 

394 

395 async def determine_remote(self, request): 

396 if request.requested_scheme != "coaps": 

397 return None 

398 

399 if self._shutting_down: 

400 raise error.LibraryShutdown 

401 

402 if request.unresolved_remote: 

403 host, port = hostportsplit(request.unresolved_remote) 

404 port = port or COAPS_PORT 

405 elif request.opt.uri_host: 

406 host = request.opt.uri_host 

407 port = request.opt.uri_port or COAPS_PORT 

408 else: 

409 raise ValueError( 

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

411 ) 

412 

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

414 try: 

415 pskId, psk = dtlsparams.as_dtls_psk() 

416 except AttributeError: 

417 raise CredentialsMissingError( 

418 "Credentials for requested URI are not compatible with DTLS-PSK" 

419 ) 

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

421 return result 

422 

423 def send(self, message): 

424 message.remote.send(message.encode()) 

425 

426 async def shutdown(self): 

427 self._shutting_down = True 

428 

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

430 for c in remaining_connections: 

431 c.shutdown()