Coverage for aiocoap/transports/oscore.py: 94%
135 statements
« prev ^ index » next coverage.py v7.6.8, created at 2024-11-28 12:34 +0000
« prev ^ index » next coverage.py v7.6.8, created at 2024-11-28 12:34 +0000
1# SPDX-FileCopyrightText: Christian Amsüss and the aiocoap contributors
2#
3# SPDX-License-Identifier: MIT
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.
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.
14This transport is a bit different from the others because it doesn't have its
15dedicated URI scheme, but purely relies on preconfigured contexts.
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.
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.
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"""
35from collections import namedtuple
36from functools import wraps
38from .. import interfaces, credentials, edhoc, oscore
39from ..numbers import UNAUTHORIZED, MAX_REGULAR_BLOCK_SIZE_EXP
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)
51 return wrapper
54class OSCOREAddress(
55 namedtuple("_OSCOREAddress", ["security_context", "underlying_address"]),
56 interfaces.EndpointAddress,
57):
58 """Remote address type for :class:`TransportOSCORE`."""
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 )
67 @property
68 @_requires_ua
69 def hostinfo(self):
70 return self.underlying_address.hostinfo
72 @property
73 @_requires_ua
74 def hostinfo_local(self):
75 return self.underlying_address.hostinfo_local
77 @property
78 @_requires_ua
79 def uri_base(self):
80 return self.underlying_address.uri_base
82 @property
83 @_requires_ua
84 def uri_base_local(self):
85 return self.underlying_address.uri_base_local
87 @property
88 @_requires_ua
89 def scheme(self):
90 return self.underlying_address.scheme
92 @property
93 def authenticated_claims(self):
94 return self.security_context.authenticated_claims
96 is_multicast = False
97 is_multicast_locally = False
99 maximum_payload_size = 1024
100 maximum_block_size_exp = MAX_REGULAR_BLOCK_SIZE_EXP
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)
117class TransportOSCORE(interfaces.RequestProvider):
118 def __init__(self, context, forward_context):
119 self._context = context
120 self._wire = forward_context
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")
127 self.loop = self._context.loop
128 self.log = self._context.log
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()
136 #
137 # implement RequestInterface
138 #
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
151 try:
152 secctx = self._context.client_credentials.credentials_from_request(message)
153 except credentials.CredentialsMissingError:
154 return False
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
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)
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)
184 t.add_done_callback(done)
186 async def _request(self, request) -> None:
187 """Process a request including any pre-flights or retries
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.
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
199 secctx = msg.remote.security_context
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
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
229 wire_request = self._wire.request(protected)
231 return (wire_request, original_request_seqno)
233 wire_request, original_request_seqno = protect(None)
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).
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
249 try:
250 protected_response = await wire_request.response
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)
258 unprotected_response, _ = secctx.unprotect(
259 protected_response, original_request_seqno
260 )
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 )
271 wire_request, original_request_seqno = protect(
272 unprotected_response.opt.echo
273 )
275 protected_response = await wire_request.response
276 unprotected_response, _ = secctx.unprotect(
277 protected_response, original_request_seqno
278 )
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)
295 if not more:
296 return
298 async for protected_response in wire_request.observation:
299 unprotected_response, _ = secctx.unprotect(
300 protected_response, original_request_seqno
301 )
303 more = protected_response.opt.observe is not None
304 more = _check(more, unprotected_response)
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)
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()
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