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