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

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

120 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# WORK IN PROGRESS: TransportEndpoint has been renamed to MessageInterface 

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

11# reasons why RequestInterface, TokenInterface and MessageInterface were split 

12# in the first place. 

13 

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

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

16sends off the protected messages via another transport. 

17 

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

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

20 

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

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

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

24 

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

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

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

28client retransmits it with an appropriate Echo value. 

29 

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

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

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

33state, picks the OSCORE security context from the CoAP 

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

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

36property alone. 

37""" 

38 

39from collections import namedtuple 

40from functools import wraps 

41 

42from .. import interfaces, credentials, oscore 

43from ..numbers import UNAUTHORIZED 

44from ..util.asyncio import py38args 

45 

46class OSCOREAddress( 

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

48 interfaces.EndpointAddress 

49 ): 

50 """Remote address type for :cls:`TransportOSCORE`.""" 

51 

52 def __repr__(self): 

53 return "<%s in context %r to %r>"%(type(self).__name__, self.security_context, self.underlying_address) 

54 

55 def _requires_ua(f): 

56 @wraps(f) 

57 def wrapper(self): 

58 if self.underlying_address is None: 

59 raise ValueError("No underlying address populated that could be used to derive a hostinfo") 

60 return f(self) 

61 return wrapper 

62 

63 @property 

64 @_requires_ua 

65 def hostinfo(self): 

66 return self.underlying_address.hostinfo 

67 

68 @property 

69 @_requires_ua 

70 def hostinfo_local(self): 

71 return self.underlying_address.hostinfo_local 

72 

73 @property 

74 @_requires_ua 

75 def uri_base(self): 

76 return self.underlying_address.uri_base 

77 

78 @property 

79 @_requires_ua 

80 def uri_base_local(self): 

81 return self.underlying_address.uri_base_local 

82 

83 @property 

84 @_requires_ua 

85 def scheme(self): 

86 return self.underlying_address.scheme 

87 

88 @property 

89 def authenticated_claims(self): 

90 return self.security_context.authenticated_claims 

91 

92 is_multicast = False 

93 

94 maximum_payload_size = 1024 

95 maximum_block_size_exp = 6 

96 

97class TransportOSCORE(interfaces.RequestProvider): 

98 def __init__(self, context, forward_context): 

99 self._context = context 

100 self._wire = forward_context 

101 

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

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

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

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

106 

107 self.loop = self._context.loop 

108 self.log = self._context.log 

109 

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

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

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

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

114 self._tasks = set() 

115 

116 # 

117 # implement RequestInterface 

118 # 

119 

120 async def fill_or_recognize_remote(self, message): 

121 if isinstance(message.remote, OSCOREAddress): 

122 return True 

123 if message.opt.object_security is not None: 

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

125 # is ._context` an option 

126 return False 

127 

128 try: 

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

130 except credentials.CredentialsMissingError: 

131 return False 

132 

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

134 if isinstance(secctx, oscore.CanProtect): 

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

136 self.log.debug("Selecting OSCORE transport based on context %r for new request %r", secctx, message) 

137 return True 

138 else: 

139 return False 

140 

141 def request(self, request): 

142 t = self.loop.create_task( 

143 self._request(request), 

144 **py38args(name="OSCORE request %r" % request) 

145 ) 

146 self._tasks.add(t) 

147 t.add_done_callback(lambda _, _tasks=self._tasks, _t=t: _tasks.remove(_t)) 

148 

149 async def _request(self, request): 

150 msg = request.request 

151 

152 secctx = msg.remote.security_context 

153 

154 def protect(echo): 

155 if echo is None: 

156 msg_to_protect = msg 

157 else: 

158 if msg.opt.echo: 

159 self.log.warning("Overwriting the requested Echo value with the one to answer a 4.01 Unauthorized") 

160 msg_to_protect = msg.copy(echo=echo) 

161 protected, original_request_seqno = secctx.protect(msg_to_protect) 

162 protected.remote = msg.remote.underlying_address 

163 

164 wire_request = self._wire.request(protected) 

165 

166 return (wire_request, original_request_seqno) 

167 

168 wire_request, original_request_seqno = protect(None) 

169 

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

171 # PlumbingRequest here, because it is a BlockwiseRequest to handle 

172 # outer blockwise. 

173 # (Might be a good idea to model those after PlumbingRequest too, 

174 # though). 

175 

176 def _check(more, unprotected_response): 

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

178 self.log.warning("OSCORE protected message contained observe, but unprotected code is unsuccessful. Ignoring the observation.") 

179 return False 

180 return more 

181 

182 try: 

183 protected_response = await wire_request.response 

184 

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

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

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

188 unprotected = oscore.verify_start(protected_response) 

189 secctx = secctx.context_from_response(unprotected) 

190 

191 unprotected_response, _ = secctx.unprotect(protected_response, original_request_seqno) 

192 

193 if unprotected_response.code == UNAUTHORIZED and unprotected_response.opt.echo is not None: 

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

195 self.log.info("Answering the server's 4.01 Unauthorized / Echo as part of OSCORE B.1.2 recovery") 

196 

197 wire_request, original_request_seqno = protect(unprotected_response.opt.echo) 

198 

199 protected_response = await wire_request.response 

200 unprotected_response, _ = secctx.unprotect(protected_response, original_request_seqno) 

201 

202 unprotected_response.remote = OSCOREAddress(secctx, protected_response.remote) 

203 self.log.debug("Successfully unprotected %r into %r", protected_response, unprotected_response) 

204 # FIXME: if i could tap into the underlying PlumbingRequest, that'd 

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

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

207 more = protected_response.opt.observe is not None 

208 more = _check(more, unprotected_response) 

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

210 

211 if not more: 

212 return 

213 

214 async for protected_response in wire_request.observation: 

215 unprotected_response, _ = secctx.unprotect(protected_response, original_request_seqno) 

216 

217 more = protected_response.opt.observe is not None 

218 more = _check(more, unprotected_response) 

219 

220 unprotected_response.remote = OSCOREAddress(secctx, protected_response.remote) 

221 self.log.debug("Successfully unprotected %r into %r", protected_response, unprotected_response) 

222 # FIXME: discover is_last from the underlying response 

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

224 

225 if not more: 

226 return 

227 request.add_exception(NotImplementedError("End of observation" 

228 " should have been indicated in is_last, see above lines")) 

229 except Exception as e: 

230 request.add_exception(e) 

231 finally: 

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

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

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

235 pass 

236 #if wire_request.observation is not None: 

237 # wire_request.observation.cancel() 

238 

239 async def shutdown(self): 

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

241 # their underlying transports 

242 pass