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

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

2# 

3# SPDX-License-Identifier: MIT 

4 

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""" 

10 

11import logging 

12from typing import Optional 

13import uuid 

14import io 

15 

16import cbor2 

17import lakers 

18 

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 

27 

28from aiocoap.transports.oscore import OSCOREAddress 

29 

30 

31class OscoreSiteWrapper(interfaces.Resource): 

32 def __init__(self, inner_site, server_credentials): 

33 self.log = logging.getLogger("coap-server.oscore-site") 

34 

35 self._inner_site = inner_site 

36 self.server_credentials = server_credentials 

37 

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() 

44 

45 async def render(self, request): 

46 raise RuntimeError( 

47 "OscoreSiteWrapper can only be used through the render_to_pipe interface" 

48 ) 

49 

50 async def needs_blockwise_assembly(self, request): 

51 raise RuntimeError( 

52 "OscoreSiteWrapper can only be used through the render_to_pipe interface" 

53 ) 

54 

55 async def render_to_pipe(self, pipe): 

56 request = pipe.request 

57 

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 

64 

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 

71 

72 if request.code not in (FETCH, POST): 

73 raise error.MethodNotAllowed 

74 

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 

82 

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 

118 

119 unprotected.remote = OSCOREAddress(sc, request.remote) 

120 

121 self.log.debug("Request %r was unprotected into %r", request, unprotected) 

122 

123 sc = sc.context_for_response() 

124 

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 ) 

134 

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 

152 

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 

159 

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 ) 

173 

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 

179 

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 

183 

184 request = pipe.request 

185 

186 if request.code is not POST: 

187 raise error.MethodNotAllowed 

188 

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 

199 

200 if len(request.payload) == 0: 

201 raise error.BadRequest 

202 

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) 

209 

210 def _process_edhoc_msg12(self, pipe): 

211 request = pipe.request 

212 

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 

221 

222 grease_settings = edhoc.GreaseSettings() 

223 

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:]) 

236 

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 

248 

249 if any(e.is_critical() for e in ead_1): 

250 self.log.error("Aborting EDHOC: Critical EAD1 present") 

251 raise error.BadRequest 

252 

253 grease_settings.update_from_ead(ead_1) 

254 

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 ) 

274 

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 

285 

286 pipe.add_response( 

287 aiocoap.Message(code=aiocoap.CHANGED, payload=message_2), is_last=True 

288 ) 

289 

290 def _process_edhoc_msg34(self, pipe): 

291 request = pipe.request 

292 

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() 

300 

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 

306 

307 # Our lookup is modelled expecting OSCORE header objects, so we rebuild one 

308 unprotected = {oscore.COSE_KID: c_r} 

309 

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 

317 

318 if not isinstance(sc, edhoc.EdhocResponderContext): 

319 raise error.BadRequest 

320 

321 message_4 = sc._offer_message_3(message_3) 

322 

323 pipe.add_response( 

324 aiocoap.Message(code=aiocoap.CHANGED, payload=message_4), is_last=True 

325 ) 

326 

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 """ 

332 

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