Coverage for src/aiocoap/oscore_sitewrapper.py: 0%

146 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-02-12 11:18 +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 async def render(self, request): 

38 raise RuntimeError( 

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

40 ) 

41 

42 async def needs_blockwise_assembly(self, request): 

43 raise RuntimeError( 

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

45 ) 

46 

47 # FIXME: should there be a get_resources_as_linkheader that just forwards 

48 # all the others and indicates ;osc everywhere? 

49 

50 async def render_to_pipe(self, pipe): 

51 request = pipe.request 

52 

53 if request.opt.uri_path == (".well-known", "edhoc"): 

54 # We'll have to take that explicitly, otherwise we'd need to rely 

55 # on a resource to be prepared by the user in the site with a 

56 # cyclical reference closed after site construction 

57 await self._render_edhoc_to_pipe(pipe) 

58 return 

59 

60 try: 

61 unprotected = oscore.verify_start(request) 

62 except oscore.NotAProtectedMessage: 

63 # ie. if no object_seccurity present 

64 await self._inner_site.render_to_pipe(pipe) 

65 return 

66 

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

68 raise error.MethodNotAllowed 

69 

70 try: 

71 sc = self.server_credentials.find_oscore(unprotected) 

72 except KeyError: 

73 if request.mtype == aiocoap.CON: 

74 raise error.Unauthorized("Security context not found") 

75 else: 

76 return 

77 

78 try: 

79 unprotected, seqno = sc.unprotect(request) 

80 except error.RenderableError as e: 

81 # Note that this is flying out of the unprotection (ie. the 

82 # security context), which is trusted to not leak unintended 

83 # information in unencrypted responses. (By comparison, a 

84 # renderable exception flying out of a user 

85 # render_to_pipe could only be be rendered to a 

86 # protected message, and we'd need to be weary of rendering errors 

87 # during to_message as well). 

88 # 

89 # Note that this clause is not a no-op: it protects the 4.01 Echo 

90 # recovery exception (which is also a ReplayError) from being 

91 # treated as such. 

92 raise e 

93 # The other errors could be ported thee but would need some better NoResponse handling. 

94 except oscore.ReplayError: 

95 if request.mtype == aiocoap.CON: 

96 pipe.add_response( 

97 aiocoap.Message( 

98 code=aiocoap.UNAUTHORIZED, max_age=0, payload=b"Replay detected" 

99 ), 

100 is_last=True, 

101 ) 

102 return 

103 except oscore.DecodeError: 

104 if request.mtype == aiocoap.CON: 

105 raise error.BadOption("Failed to decode COSE") 

106 else: 

107 return 

108 except oscore.ProtectionInvalid: 

109 if request.mtype == aiocoap.CON: 

110 raise error.BadRequest("Decryption failed") 

111 else: 

112 return 

113 

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

115 

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

117 

118 sc = sc.context_for_response() 

119 

120 inner_pipe = aiocoap.pipe.IterablePipe(unprotected) 

121 pr_that_can_take_errors = aiocoap.pipe.error_to_message(inner_pipe, self.log) 

122 # FIXME: do not create a task but run this in here (can this become a 

123 # feature of the aiterable PR?) 

124 aiocoap.pipe.run_driving_pipe( 

125 pr_that_can_take_errors, 

126 self._inner_site.render_to_pipe(inner_pipe), 

127 name="OSCORE response rendering for %r" % unprotected, 

128 ) 

129 

130 async for event in inner_pipe: 

131 if event.exception is not None: 

132 # These are expected to be rare in handlers 

133 # 

134 # FIXME should we try to render them? (See also 

135 # run_driving_pipe). Just raising them 

136 # would definitely be bad, as they might be renderable and 

137 # then would hit the outer message. 

138 self.log.warn( 

139 "Turning error raised from renderer into nondescript protected error %r", 

140 event.exception, 

141 ) 

142 message = aiocoap.Message(code=aiocoap.INTERNAL_SERVER_ERROR) 

143 is_last = True 

144 else: 

145 message = event.message 

146 is_last = event.is_last 

147 

148 # FIXME: Around several places in the use of pipe (and 

149 # now even here), non-final events are hard-coded as observations. 

150 # This should shift toward the source telling, or the stream being 

151 # annotated as "eventually consistent resource states". 

152 if not is_last: 

153 message.opt.observe = 0 

154 

155 protected_response, _ = sc.protect(message, seqno) 

156 if message.opt.observe is not None: 

157 # FIXME: should be done in protect, or by something else that 

158 # generally handles obs numbers better (sending the 

159 # oscore-reconstructed number is nice because it's consistent 

160 # with a proxy that doesn't want to keep a counter when it 

161 # knows it's OSCORE already), but starting this per obs with 

162 # zero (unless it was done on that token recently) would be 

163 # most efficient 

164 protected_response.opt.observe = sc.sender_sequence_number & 0xFFFFFFFF 

165 self.log.debug( 

166 "Response %r was encrypted into %r", message, protected_response 

167 ) 

168 

169 pipe.add_response(protected_response, is_last=is_last) 

170 if event.is_last: 

171 break 

172 # The created task gets cancelled here because the __aiter__ result is 

173 # dropped and thus all interest in the inner_pipe goes away 

174 

175 async def _render_edhoc_to_pipe(self, pipe): 

176 self.log.debug("Processing request as EDHOC message 1 or 3") 

177 # Conveniently we don't have to care for observation, and thus can treat the rendering to a pipeline as just a rendering 

178 

179 request = pipe.request 

180 

181 if request.code is not POST: 

182 raise error.MethodNotAllowed 

183 

184 if any( 

185 o.number.is_critical() 

186 for o in request.opt.option_list() 

187 if o.number not in (OptionNumber.URI_PATH, OptionNumber.URI_HOST) 

188 ): 

189 # FIXME: This should be done by every resource handler (see 

190 # https://github.com/chrysn/aiocoap/issues/268) -- this is crude 

191 # but better than doing nothing (and because we're rendering to a 

192 # pipe, chances are upcoming mitigation might not catch this) 

193 raise error.BadOption 

194 

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

196 raise error.BadRequest 

197 

198 if request.payload[0:1] == cbor2.dumps(True): 

199 self.log.debug("Processing request as EDHOC message 1") 

200 self._process_edhoc_msg12(pipe) 

201 else: 

202 self.log.debug("Processing request as EDHOC message 3") 

203 self._process_edhoc_msg34(pipe) 

204 

205 def _process_edhoc_msg12(self, pipe): 

206 request = pipe.request 

207 

208 origin = request.get_request_uri(local_is_server=True).removesuffix( 

209 "/.well-known/edhoc" 

210 ) 

211 own_credential_object = self._get_edhoc_identity(origin) 

212 if own_credential_object is None: 

213 self.log.error( 

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

215 origin, 

216 ) 

217 raise error.NotFound 

218 

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

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

221 own_credential_object.own_cred.keys() 

222 ) == [14], ( 

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

224 % own_credential_object.own_cred 

225 ) 

226 responder = lakers.EdhocResponder( 

227 r=own_credential_object.own_key.d, 

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

229 ) 

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

231 if ead_1 is not None: 

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

233 raise error.BadRequest 

234 

235 used_own_identifiers = ( 

236 self.server_credentials.find_all_used_contextless_oscore_kid() 

237 ) 

238 # can't have c_r==c_i 

239 used_own_identifiers.add(c_i) 

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

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

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

243 if not candidates: 

244 # FIXME: LRU or timeout the contexts 

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

246 c_r = candidates[0] 

247 message_2 = responder.prepare_message_2( 

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

249 ) 

250 

251 credentials_entry = edhoc.EdhocResponderContext( 

252 responder, 

253 c_i, 

254 c_r, 

255 self.server_credentials, 

256 self.log, 

257 ) 

258 # FIXME we shouldn't need arbitrary keys 

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

260 

261 pipe.add_response( 

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

263 ) 

264 

265 def _process_edhoc_msg34(self, pipe): 

266 request = pipe.request 

267 

268 payload = io.BytesIO(request.payload) 

269 try: 

270 c_r = cbor2.load(payload) 

271 except cbor2.CBORDecodeError: 

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

273 raise error.BadRequest 

274 message_3 = payload.read() 

275 

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

277 c_r = cbor2.dumps(c_r) 

278 if not isinstance(c_r, bytes): 

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

280 raise error.BadRequest 

281 

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

283 unprotected = {oscore.COSE_KID: c_r} 

284 

285 try: 

286 sc = self.server_credentials.find_oscore(unprotected) 

287 except KeyError: 

288 self.log.error( 

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

290 ) 

291 raise error.BadRequest 

292 

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

294 raise error.BadRequest 

295 

296 message_4 = sc._offer_message_3(message_3) 

297 

298 pipe.add_response( 

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

300 ) 

301 

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

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

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

305 when there are more methods or cipher suites) 

306 """ 

307 

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

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

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

311 # FIXME not really a pair needed is it? 

312 return None 

313 return candidate