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

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

202 statements  

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 <c.amsuess@energyharvesting.at> 

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 handles coaps:// using a 

10wrapped tinydtls library. 

11 

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

13 

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

15 $ cd libcoap 

16 $ ./autogen.sh 

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

18 $ make 

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

20 

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

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

23 

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

25 

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

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

28 

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

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

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

32 

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

34>>> c.client_credentials.load_from_dict( 

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

36... 'psk': b'secretPSK', 

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

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

39 

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

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

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

43 

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

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

46shown during client shutdown. 

47""" 

48 

49import asyncio 

50import weakref 

51import functools 

52import time 

53import warnings 

54 

55from ..util import hostportjoin, hostportsplit 

56from ..util.asyncio import py38args 

57from ..message import Message 

58from .. import interfaces, error 

59from ..numbers import COAPS_PORT 

60from ..credentials import CredentialsMissingError 

61 

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

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

64# from the tinyDTLS functions 

65_SENTINEL_ADDRESS = "::1" 

66_SENTINEL_PORT = 1234 

67 

68DTLS_EVENT_CONNECT = 0x01DC 

69DTLS_EVENT_CONNECTED = 0x01DE 

70DTLS_EVENT_RENEGOTIATE = 0x01DF 

71 

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

73 

74# from RFC 5246 

75LEVEL_WARNING = 1 

76LEVEL_FATAL = 2 

77CODE_CLOSE_NOTIFY = 0 

78 

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

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

81#dtls.setLogLevel(dtls.DTLS_LOG_DEBUG) 

82 

83# FIXME this should be exposed by the dtls wrapper 

84DTLS_TICKS_PER_SECOND = 1000 

85DTLS_CLOCK_OFFSET = time.time() 

86 

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

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

89class CloseNotifyReceived(Exception): 

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

91 server while the request was being processed""" 

92 

93class FatalDTLSError(Exception): 

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

95 request was being processed""" 

96 

97class DTLSClientConnection(interfaces.EndpointAddress): 

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

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

100 

101 is_multicast = False 

102 is_multicast_locally = False 

103 hostinfo = None # stored at initualization time 

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

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

106 # connection, but valid anyway 

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

108 scheme = 'coaps' 

109 

110 @property 

111 def hostinfo_local(self): 

112 # See TCP's.hostinfo_local 

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

114 if port == COAPS_PORT: 

115 port = None 

116 return hostportjoin(host, port) 

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