Coverage for aiocoap/transports/oscore.py: 94%

135 statements  

« prev     ^ index     » next       coverage.py v7.6.3, created at 2024-10-15 22:10 +0000

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

2# 

3# SPDX-License-Identifier: MIT 

4 

5# WORK IN PROGRESS: TransportEndpoint has been renamed to MessageInterface 

6# here, but actually we'll be providing a RequestInterface -- that's one of the 

7# reasons why RequestInterface, TokenInterface and MessageInterface were split 

8# in the first place. 

9 

10"""This module implements a RequestProvider for OSCORE. As such, it takes 

11routing ownership of requests that it has a security context available for, and 

12sends off the protected messages via another transport. 

13 

14This transport is a bit different from the others because it doesn't have its 

15dedicated URI scheme, but purely relies on preconfigured contexts. 

16 

17So far, this transport only deals with outgoing requests, and does not help in 

18building an OSCORE server. (Some code that could be used here in future resides 

19in `contrib/oscore-plugtest/plugtest-server` as the `ProtectedSite` class. 

20 

21In outgoing request, this transport automatically handles Echo options that 

22appear to come from RFC8613 Appendix B.1.2 style servers. They indicate that 

23the server could not process the request initially, but could do so if the 

24client retransmits it with an appropriate Echo value. 

25 

26Unlike other transports that could (at least in theory) be present multiple 

27times in :attr:`aiocoap.protocol.Context.request_interfaces` (eg. because there 

28are several bound sockets), this is only useful once in there, as it has no own 

29state, picks the OSCORE security context from the CoAP 

30:attr:`aiocoap.protocol.Context.client_credentials` when populating the remote 

31field, and handles any populated request based ono its remote.security_context 

32property alone. 

33""" 

34 

35from collections import namedtuple 

36from functools import wraps 

37 

38from .. import interfaces, credentials, edhoc, oscore 

39from ..numbers import UNAUTHORIZED, MAX_REGULAR_BLOCK_SIZE_EXP 

40 

41 

42def _requires_ua(f): 

43 @wraps(f) 

44 def wrapper(self): 

45 if self.underlying_address is None: 

46 raise ValueError( 

47 "No underlying address populated that could be used to derive a hostinfo" 

48 ) 

49 return f(self) 

50 

51 return wrapper 

52 

53 

54class OSCOREAddress( 

55 namedtuple("_OSCOREAddress", ["security_context", "underlying_address"]), 

56 interfaces.EndpointAddress, 

57): 

58 """Remote address type for :class:`TransportOSCORE`.""" 

59 

60 def __repr__(self): 

61 return "<%s in context %r to %r>" % ( 

62 type(self).__name__, 

63 self.security_context, 

64 self.underlying_address, 

65 ) 

66 

67 @property 

68 @_requires_ua 

69 def hostinfo(self): 

70 return self.underlying_address.hostinfo 

71 

72 @property 

73 @_requires_ua 

74 def hostinfo_local(self): 

75 return self.underlying_address.hostinfo_local 

76 

77 @property 

78 @_requires_ua 

79 def uri_base(self): 

80 return self.underlying_address.uri_base 

81 

82 @property 

83 @_requires_ua 

84 def uri_base_local(self): 

85 return self.underlying_address.uri_base_local 

86 

87 @property 

88 @_requires_ua 

89 def scheme(self): 

90 return self.underlying_address.scheme 

91 

92 @property 

93 def authenticated_claims(self): 

94 return self.security_context.authenticated_claims 

95 

96 is_multicast = False 

97 is_multicast_locally = False 

98 

99 maximum_payload_size = 1024 

100 maximum_block_size_exp = MAX_REGULAR_BLOCK_SIZE_EXP 

101 

102 @property 

103 def blockwise_key(self): 

104 if hasattr(self.security_context, "groupcontext"): 

105 # it's an aspect, and all aspects work compatibly as long as data 

106 # comes from the same recipient ID -- taking the group recipient 

107 # key for that one which is stable across switches between pairwise 

108 # and group mode 

109 detail = self.security_context.groupcontext.recipient_keys[ 

110 self.security_context.recipient_id 

111 ] 

112 else: 

113 detail = self.security_context.recipient_key 

114 return (self.underlying_address.blockwise_key, detail) 

115 

116 

117class TransportOSCORE(interfaces.RequestProvider): 

118 def __init__(self, context, forward_context): 

119 self._context = context 

120 self._wire = forward_context 

121 

122 if self._context.loop is not self._wire.loop: 

123 # TransportOSCORE is not designed to bridge loops -- would probably 

124 # be possible, but incur confusion that is most likely well avoidable 

125 raise ValueError("Wire and context need to share an asyncio loop") 

126 

127 self.loop = self._context.loop 

128 self.log = self._context.log 

129 

130 # Keep current requests. This is not needed for shutdown purposes (see 

131 # .shutdown), but because Python 3.6.4 (but not 3.6.5, and not at least 

132 # some 3.5) would otherwise cancel OSCORE tasks mid-observation. This 

133 # manifested itself as <https://github.com/chrysn/aiocoap/issues/111>. 

134 self._tasks = set() 

135 

136 # 

137 # implement RequestInterface 

138 # 

139 

140 async def fill_or_recognize_remote(self, message): 

141 if isinstance(message.remote, OSCOREAddress): 

142 return True 

143 if message.opt.oscore is not None: 

144 # double oscore is not specified; using this fact to make `._wire 

145 # is ._context` an option 

146 return False 

147 if message.opt.uri_path == (".well-known", "edhoc"): 

148 # FIXME better criteria based on next-hop? 

149 return False 

150 

151 try: 

152 secctx = self._context.client_credentials.credentials_from_request(message) 

153 except credentials.CredentialsMissingError: 

154 return False 

155 

156 # FIXME: it'd be better to have a "get me credentials *of this type* if they exist" 

157 if isinstance(secctx, oscore.CanProtect) or isinstance( 

158 secctx, edhoc.EdhocCredentials 

159 ): 

160 message.remote = OSCOREAddress(secctx, message.remote) 

161 self.log.debug( 

162 "Selecting OSCORE transport based on context %r for new request %r", 

163 secctx, 

164 message, 

165 ) 

166 return True 

167 else: 

168 return False 

169 

170 def request(self, request): 

171 t = self.loop.create_task( 

172 self._request(request), 

173 name="OSCORE request %r" % request, 

174 ) 

175 self._tasks.add(t) 

176 

177 def done(t, _tasks=self._tasks, _request=request): 

178 _tasks.remove(t) 

179 try: 

180 t.result() 

181 except Exception as e: 

182 _request.add_exception(e) 

183 

184 t.add_done_callback(done) 

185 

186 async def _request(self, request) -> None: 

187 """Process a request including any pre-flights or retries 

188 

189 Retries by this coroutine are limited to actionable authenticated 

190 errors, i.e. those where it is ensured that even though the request is 

191 encrypted twice, it is still only processed once. 

192 

193 This coroutine sets the result of request.request on completion; 

194 otherwise it raises and relies on its done callback to propagate the 

195 error. 

196 """ 

197 msg = request.request 

198 

199 secctx = msg.remote.security_context 

200 

201 if isinstance(secctx, edhoc.EdhocCredentials): 

202 if secctx._established_context is None: 

203 self._established_context = ( 

204 msg.remote.security_context.establish_context( 

205 wire=self._wire, 

206 underlying_address=msg.remote.underlying_address, 

207 underlying_proxy_scheme=msg.opt.proxy_scheme, 

208 underlying_uri_host=msg.opt.uri_host, 

209 logger=self.log.getChild("edhoc"), 

210 ) 

211 ) 

212 # FIXME: Who should drive retries here? We probably don't retry if 

213 # it fails immediately, but what happens with the request that 

214 # finds this broken, will it recurse? 

215 secctx = await self._established_context 

216 

217 def protect(echo): 

218 if echo is None: 

219 msg_to_protect = msg 

220 else: 

221 if msg.opt.echo: 

222 self.log.warning( 

223 "Overwriting the requested Echo value with the one to answer a 4.01 Unauthorized" 

224 ) 

225 msg_to_protect = msg.copy(echo=echo) 

226 protected, original_request_seqno = secctx.protect(msg_to_protect) 

227 protected.remote = msg.remote.underlying_address 

228 

229 wire_request = self._wire.request(protected) 

230 

231 return (wire_request, original_request_seqno) 

232 

233 wire_request, original_request_seqno = protect(None) 

234 

235 # tempting as it would be, we can't access the request as a 

236 # Pipe here, because it is a BlockwiseRequest to handle 

237 # outer blockwise. 

238 # (Might be a good idea to model those after Pipe too, 

239 # though). 

240 

241 def _check(more, unprotected_response): 

242 if more and not unprotected_response.code.is_successful(): 

243 self.log.warning( 

244 "OSCORE protected message contained observe, but unprotected code is unsuccessful. Ignoring the observation." 

245 ) 

246 return False 

247 return more 

248 

249 try: 

250 protected_response = await wire_request.response 

251 

252 # Offer secctx to switch over for reception based on the header 

253 # data (similar to how the server address switches over when 

254 # receiving a response to a request sent over multicast) 

255 unprotected = oscore.verify_start(protected_response) 

256 secctx = secctx.context_from_response(unprotected) 

257 

258 unprotected_response, _ = secctx.unprotect( 

259 protected_response, original_request_seqno 

260 ) 

261 

262 if ( 

263 unprotected_response.code == UNAUTHORIZED 

264 and unprotected_response.opt.echo is not None 

265 ): 

266 # Assist the server in B.1.2 Echo receive window recovery 

267 self.log.info( 

268 "Answering the server's 4.01 Unauthorized / Echo as part of OSCORE B.1.2 recovery" 

269 ) 

270 

271 wire_request, original_request_seqno = protect( 

272 unprotected_response.opt.echo 

273 ) 

274 

275 protected_response = await wire_request.response 

276 unprotected_response, _ = secctx.unprotect( 

277 protected_response, original_request_seqno 

278 ) 

279 

280 unprotected_response.remote = OSCOREAddress( 

281 secctx, protected_response.remote 

282 ) 

283 self.log.debug( 

284 "Successfully unprotected %r into %r", 

285 protected_response, 

286 unprotected_response, 

287 ) 

288 # FIXME: if i could tap into the underlying Pipe, that'd 

289 # be a lot easier -- and also get rid of the awkward _check 

290 # code moved into its own function just to avoid duplication. 

291 more = protected_response.opt.observe is not None 

292 more = _check(more, unprotected_response) 

293 request.add_response(unprotected_response, is_last=not more) 

294 

295 if not more: 

296 return 

297 

298 async for protected_response in wire_request.observation: 

299 unprotected_response, _ = secctx.unprotect( 

300 protected_response, original_request_seqno 

301 ) 

302 

303 more = protected_response.opt.observe is not None 

304 more = _check(more, unprotected_response) 

305 

306 unprotected_response.remote = OSCOREAddress( 

307 secctx, protected_response.remote 

308 ) 

309 self.log.debug( 

310 "Successfully unprotected %r into %r", 

311 protected_response, 

312 unprotected_response, 

313 ) 

314 # FIXME: discover is_last from the underlying response 

315 request.add_response(unprotected_response, is_last=not more) 

316 

317 if not more: 

318 return 

319 request.add_exception( 

320 NotImplementedError( 

321 "End of observation" 

322 " should have been indicated in is_last, see above lines" 

323 ) 

324 ) 

325 finally: 

326 # FIXME: no way yet to cancel observations exists yet, let alone 

327 # one that can be used in a finally clause (ie. won't raise 

328 # something else if the observation terminated server-side) 

329 pass 

330 # if wire_request.observation is not None: 

331 # wire_request.observation.cancel() 

332 

333 async def shutdown(self): 

334 # Nothing to do here yet; the individual requests will be shut down by 

335 # their underlying transports 

336 pass