Coverage for src/aiocoap/oscore_sitewrapper.py: 0%
148 statements
« prev ^ index » next coverage.py v7.7.0, created at 2025-03-20 17:26 +0000
« prev ^ index » next coverage.py v7.7.0, created at 2025-03-20 17:26 +0000
1# SPDX-FileCopyrightText: Christian Amsüss and the aiocoap contributors
2#
3# SPDX-License-Identifier: MIT
5"""This module assists in creating OSCORE servers by proving a wrapper around a
6:class:aiocoap.resource.Site. It enforces no access control, but just indicates
7to the resources whether a client is authenticated by setting the request's
8remote property adaequately.
9"""
11import logging
12from typing import Optional
13import uuid
14import io
16import cbor2
17import lakers
19import aiocoap
20from aiocoap import interfaces
21from aiocoap import oscore, error
22import aiocoap.pipe
23from .numbers.codes import FETCH, POST
24from .numbers.optionnumbers import OptionNumber
25from . import edhoc
27from aiocoap.transports.oscore import OSCOREAddress
30class OscoreSiteWrapper(interfaces.Resource):
31 def __init__(self, inner_site, server_credentials):
32 self.log = logging.getLogger("coap-server.oscore-site")
34 self._inner_site = inner_site
35 self.server_credentials = server_credentials
37 def get_resources_as_linkheader(self):
38 # Not applying any limits while WKCResource does not either
39 #
40 # Not adding `;osc` everywhere as that is excessive (and not telling
41 # much, as one won't know *how* to get those credentials)
42 return self._inner_site.get_resources_as_linkheader()
44 async def render(self, request):
45 raise RuntimeError(
46 "OscoreSiteWrapper can only be used through the render_to_pipe interface"
47 )
49 async def needs_blockwise_assembly(self, request):
50 raise RuntimeError(
51 "OscoreSiteWrapper can only be used through the render_to_pipe interface"
52 )
54 async def render_to_pipe(self, pipe):
55 request = pipe.request
57 if request.opt.uri_path == (".well-known", "edhoc"):
58 # We'll have to take that explicitly, otherwise we'd need to rely
59 # on a resource to be prepared by the user in the site with a
60 # cyclical reference closed after site construction
61 await self._render_edhoc_to_pipe(pipe)
62 return
64 try:
65 unprotected = oscore.verify_start(request)
66 except oscore.NotAProtectedMessage:
67 # ie. if no object_seccurity present
68 await self._inner_site.render_to_pipe(pipe)
69 return
71 if request.code not in (FETCH, POST):
72 raise error.MethodNotAllowed
74 try:
75 sc = self.server_credentials.find_oscore(unprotected)
76 except KeyError:
77 if request.mtype == aiocoap.CON:
78 raise error.Unauthorized("Security context not found")
79 else:
80 return
82 try:
83 unprotected, seqno = sc.unprotect(request)
84 except error.RenderableError as e:
85 # Note that this is flying out of the unprotection (ie. the
86 # security context), which is trusted to not leak unintended
87 # information in unencrypted responses. (By comparison, a
88 # renderable exception flying out of a user
89 # render_to_pipe could only be be rendered to a
90 # protected message, and we'd need to be weary of rendering errors
91 # during to_message as well).
92 #
93 # Note that this clause is not a no-op: it protects the 4.01 Echo
94 # recovery exception (which is also a ReplayError) from being
95 # treated as such.
96 raise e
97 # The other errors could be ported thee but would need some better NoResponse handling.
98 except oscore.ReplayError:
99 if request.mtype == aiocoap.CON:
100 pipe.add_response(
101 aiocoap.Message(
102 code=aiocoap.UNAUTHORIZED, max_age=0, payload=b"Replay detected"
103 ),
104 is_last=True,
105 )
106 return
107 except oscore.DecodeError:
108 if request.mtype == aiocoap.CON:
109 raise error.BadOption("Failed to decode COSE")
110 else:
111 return
112 except oscore.ProtectionInvalid:
113 if request.mtype == aiocoap.CON:
114 raise error.BadRequest("Decryption failed")
115 else:
116 return
118 unprotected.remote = OSCOREAddress(sc, request.remote)
120 self.log.debug("Request %r was unprotected into %r", request, unprotected)
122 sc = sc.context_for_response()
124 inner_pipe = aiocoap.pipe.IterablePipe(unprotected)
125 pr_that_can_take_errors = aiocoap.pipe.error_to_message(inner_pipe, self.log)
126 # FIXME: do not create a task but run this in here (can this become a
127 # feature of the aiterable PR?)
128 aiocoap.pipe.run_driving_pipe(
129 pr_that_can_take_errors,
130 self._inner_site.render_to_pipe(inner_pipe),
131 name="OSCORE response rendering for %r" % unprotected,
132 )
134 async for event in inner_pipe:
135 if event.exception is not None:
136 # These are expected to be rare in handlers
137 #
138 # FIXME should we try to render them? (See also
139 # run_driving_pipe). Just raising them
140 # would definitely be bad, as they might be renderable and
141 # then would hit the outer message.
142 self.log.warn(
143 "Turning error raised from renderer into nondescript protected error %r",
144 event.exception,
145 )
146 message = aiocoap.Message(code=aiocoap.INTERNAL_SERVER_ERROR)
147 is_last = True
148 else:
149 message = event.message
150 is_last = event.is_last
152 # FIXME: Around several places in the use of pipe (and
153 # now even here), non-final events are hard-coded as observations.
154 # This should shift toward the source telling, or the stream being
155 # annotated as "eventually consistent resource states".
156 if not is_last:
157 message.opt.observe = 0
159 protected_response, _ = sc.protect(message, seqno)
160 if message.opt.observe is not None:
161 # FIXME: should be done in protect, or by something else that
162 # generally handles obs numbers better (sending the
163 # oscore-reconstructed number is nice because it's consistent
164 # with a proxy that doesn't want to keep a counter when it
165 # knows it's OSCORE already), but starting this per obs with
166 # zero (unless it was done on that token recently) would be
167 # most efficient
168 protected_response.opt.observe = sc.sender_sequence_number & 0xFFFFFFFF
169 self.log.debug(
170 "Response %r was encrypted into %r", message, protected_response
171 )
173 pipe.add_response(protected_response, is_last=is_last)
174 if event.is_last:
175 break
176 # The created task gets cancelled here because the __aiter__ result is
177 # dropped and thus all interest in the inner_pipe goes away
179 async def _render_edhoc_to_pipe(self, pipe):
180 self.log.debug("Processing request as EDHOC message 1 or 3")
181 # Conveniently we don't have to care for observation, and thus can treat the rendering to a pipeline as just a rendering
183 request = pipe.request
185 if request.code is not POST:
186 raise error.MethodNotAllowed
188 if any(
189 o.number.is_critical()
190 for o in request.opt.option_list()
191 if o.number not in (OptionNumber.URI_PATH, OptionNumber.URI_HOST)
192 ):
193 # FIXME: This should be done by every resource handler (see
194 # https://github.com/chrysn/aiocoap/issues/268) -- this is crude
195 # but better than doing nothing (and because we're rendering to a
196 # pipe, chances are upcoming mitigation might not catch this)
197 raise error.BadOption
199 if len(request.payload) == 0:
200 raise error.BadRequest
202 if request.payload[0:1] == cbor2.dumps(True):
203 self.log.debug("Processing request as EDHOC message 1")
204 self._process_edhoc_msg12(pipe)
205 else:
206 self.log.debug("Processing request as EDHOC message 3")
207 self._process_edhoc_msg34(pipe)
209 def _process_edhoc_msg12(self, pipe):
210 request = pipe.request
212 origin = request.get_request_uri(local_is_server=True).removesuffix(
213 "/.well-known/edhoc"
214 )
215 own_credential_object = self._get_edhoc_identity(origin)
216 if own_credential_object is None:
217 self.log.error(
218 "Peer attempted EDHOC even though no EDHOC credentials are configured for %s",
219 origin,
220 )
221 raise error.NotFound
223 # FIXME lakers: Shouldn't have to commit this early, might still look at EAD1
224 assert isinstance(own_credential_object.own_cred, dict) and list(
225 own_credential_object.own_cred.keys()
226 ) == [14], (
227 "So far can only process CCS style own credentials a la {14: ...}, own_cred = %r"
228 % own_credential_object.own_cred
229 )
230 responder = lakers.EdhocResponder(
231 r=own_credential_object.own_key.d,
232 cred_r=cbor2.dumps(own_credential_object.own_cred[14], canonical=True),
233 )
234 c_i, ead_1 = responder.process_message_1(request.payload[1:])
235 if ead_1 is not None:
236 self.log.error("Aborting EDHOC: EAD1 present")
237 raise error.BadRequest
239 used_own_identifiers = (
240 self.server_credentials.find_all_used_contextless_oscore_kid()
241 )
242 # can't have c_r==c_i
243 used_own_identifiers.add(c_i)
244 # FIXME try larger ones too, but currently they wouldn't work in Lakers
245 candidates = [cbor2.dumps(i) for i in range(-24, 24)]
246 candidates = [c for c in candidates if c not in used_own_identifiers]
247 if not candidates:
248 # FIXME: LRU or timeout the contexts
249 raise error.InternalServerError("Too many contexts")
250 c_r = candidates[0]
251 message_2 = responder.prepare_message_2(
252 own_credential_object.own_cred_style.as_lakers(), c_r, None
253 )
255 credentials_entry = edhoc.EdhocResponderContext(
256 responder,
257 c_i,
258 c_r,
259 self.server_credentials,
260 self.log,
261 )
262 # FIXME we shouldn't need arbitrary keys
263 self.server_credentials[":" + uuid.uuid4().hex] = credentials_entry
265 pipe.add_response(
266 aiocoap.Message(code=aiocoap.CHANGED, payload=message_2), is_last=True
267 )
269 def _process_edhoc_msg34(self, pipe):
270 request = pipe.request
272 payload = io.BytesIO(request.payload)
273 try:
274 c_r = cbor2.load(payload)
275 except cbor2.CBORDecodeError:
276 self.log.error("Message 3 received without valid CBOR start")
277 raise error.BadRequest
278 message_3 = payload.read()
280 if isinstance(c_r, int) and not isinstance(c_r, bool) and -24 <= c_r < 23:
281 c_r = cbor2.dumps(c_r)
282 if not isinstance(c_r, bytes):
283 self.log.error(f"Message 3 received with invalid C_R {c_r:r}")
284 raise error.BadRequest
286 # Our lookup is modelled expecting OSCORE header objects, so we rebuild one
287 unprotected = {oscore.COSE_KID: c_r}
289 try:
290 sc = self.server_credentials.find_oscore(unprotected)
291 except KeyError:
292 self.log.error(
293 f"No OSCORE context found with recipient_id / c_r matching {c_r!r}"
294 )
295 raise error.BadRequest
297 if not isinstance(sc, edhoc.EdhocResponderContext):
298 raise error.BadRequest
300 message_4 = sc._offer_message_3(message_3)
302 pipe.add_response(
303 aiocoap.Message(code=aiocoap.CHANGED, payload=message_4), is_last=True
304 )
306 def _get_edhoc_identity(self, origin: str) -> Optional[edhoc.EdhocCredentials]:
307 """With lakers-python 0.3.1, we can effectively only have one identity
308 per host; expect this to change once we gain access to EAD1 (plus more
309 when there are more methods or cipher suites)
310 """
312 # That this works is a flaw of the credentials format by itself
313 candidate = self.server_credentials.get(origin + "/*")
314 if not isinstance(candidate, edhoc.EdhocCredentials):
315 # FIXME not really a pair needed is it?
316 return None
317 return candidate