Coverage for aiocoap/oscore_sitewrapper.py: 81%

148 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-09-30 11:17 +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 . import edhoc 

26 

27from aiocoap.transports.oscore import OSCOREAddress 

28 

29 

30class OscoreSiteWrapper(interfaces.Resource): 

31 def __init__(self, inner_site, server_credentials): 

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

33 

34 self._inner_site = inner_site 

35 self.server_credentials = server_credentials 

36 

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

43 

44 async def render(self, request): 

45 raise RuntimeError( 

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

47 ) 

48 

49 async def needs_blockwise_assembly(self, request): 

50 raise RuntimeError( 

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

52 ) 

53 

54 async def render_to_pipe(self, pipe): 

55 request = pipe.request 

56 

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 

63 

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 

70 

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

72 raise error.MethodNotAllowed 

73 

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 

81 

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 

117 

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

119 

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

121 

122 sc = sc.context_for_response() 

123 

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 ) 

133 

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 

151 

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 

158 

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 ) 

172 

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 

178 

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 

182 

183 request = pipe.request 

184 

185 if request.code is not POST: 

186 raise error.MethodNotAllowed 

187 

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 

198 

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

200 raise error.BadRequest 

201 

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) 

208 

209 def _process_edhoc_msg12(self, pipe): 

210 request = pipe.request 

211 

212 origin = request.get_request_uri().removesuffix("/.well-known/edhoc") 

213 own_credential_object = self._get_edhoc_identity(origin) 

214 if own_credential_object is None: 

215 self.log.error( 

216 "Peer attempted EDHOC even though no EDHOC credentials are configured for %s", 

217 origin, 

218 ) 

219 raise error.NotFound 

220 

221 # FIXME lakers: Shouldn't have to commit this early, might still look at EAD1 

222 assert isinstance(own_credential_object.own_cred, dict) and list( 

223 own_credential_object.own_cred.keys() 

224 ) == [14], ( 

225 "So far can only process CCS style own credentials a la {14: ...}, own_cred = %r" 

226 % own_credential_object.own_cred 

227 ) 

228 responder = lakers.EdhocResponder( 

229 r=own_credential_object.own_key.d, 

230 cred_r=cbor2.dumps(own_credential_object.own_cred[14], canonical=True), 

231 ) 

232 c_i, ead_1 = responder.process_message_1(request.payload[1:]) 

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

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

235 raise error.BadRequest 

236 

237 used_own_identifiers = ( 

238 self.server_credentials.find_all_used_contextless_oscore_kid() 

239 ) 

240 # can't have c_r==c_i 

241 used_own_identifiers.add(c_i) 

242 # FIXME try larger ones too, but currently they wouldn't work in Lakers 

243 candidates = [cbor2.dumps(i) for i in range(-24, 24)] 

244 candidates = [c for c in candidates if c not in used_own_identifiers] 

245 if not candidates: 

246 # FIXME: LRU or timeout the contexts 

247 raise error.InternalServerError("Too many contexts") 

248 c_r = candidates[0] 

249 message_2 = responder.prepare_message_2( 

250 own_credential_object.own_cred_style.as_lakers(), c_r, None 

251 ) 

252 

253 credentials_entry = edhoc.EdhocResponderContext( 

254 responder, 

255 c_i, 

256 c_r, 

257 self.server_credentials, 

258 self.log, 

259 ) 

260 # FIXME we shouldn't need arbitrary keys 

261 self.server_credentials[":" + uuid.uuid4().hex] = credentials_entry 

262 

263 pipe.add_response( 

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

265 ) 

266 

267 def _process_edhoc_msg34(self, pipe): 

268 request = pipe.request 

269 

270 payload = io.BytesIO(request.payload) 

271 try: 

272 c_r = cbor2.load(payload) 

273 except cbor2.CBORDecodeError: 

274 self.log.error("Message 3 received without valid CBOR start") 

275 raise error.BadRequest 

276 message_3 = payload.read() 

277 

278 if isinstance(c_r, int) and not isinstance(c_r, bool) and -24 <= c_r < 23: 

279 c_r = cbor2.dumps(c_r) 

280 if not isinstance(c_r, bytes): 

281 self.log.error(f"Message 3 received with invalid C_R {c_r:r}") 

282 raise error.BadRequest 

283 

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

285 unprotected = {oscore.COSE_KID: c_r} 

286 

287 try: 

288 sc = self.server_credentials.find_oscore(unprotected) 

289 except KeyError: 

290 self.log.error( 

291 f"No OSCORE context found with recipient_id / c_r matching {c_r!r}" 

292 ) 

293 raise error.BadRequest 

294 

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

296 raise error.BadRequest 

297 

298 message_4 = sc._offer_message_3(message_3) 

299 

300 pipe.add_response( 

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

302 ) 

303 

304 def _get_edhoc_identity(self, origin: str) -> Optional[edhoc.EdhocCredentials]: 

305 """With lakers-python 0.3.1, we can effectively only have one identity 

306 per host; expect this to change once we gain access to EAD1 (plus more 

307 when there are more methods or cipher suites) 

308 """ 

309 

310 # That this works is a flaw of the credentials format by itself 

311 candidate = self.server_credentials.get(origin + "/*") 

312 if not isinstance(candidate, edhoc.EdhocCredentials): 

313 # FIXME not really a pair needed is it? 

314 return None 

315 return candidate