Coverage for aiocoap/oscore_sitewrapper.py: 81%

117 statements  

« prev     ^ index     » next       coverage.py v7.6.3, created at 2024-10-15 22:10 +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 

14 

15import cbor2 

16import lakers 

17 

18import aiocoap 

19from aiocoap import interfaces 

20from aiocoap import oscore, error 

21import aiocoap.pipe 

22from .numbers.codes import FETCH, POST 

23from .numbers.optionnumbers import OptionNumber 

24from . import edhoc 

25 

26from aiocoap.transports.oscore import OSCOREAddress 

27 

28 

29class OscoreSiteWrapper(interfaces.Resource): 

30 def __init__(self, inner_site, server_credentials): 

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

32 

33 self._inner_site = inner_site 

34 self.server_credentials = server_credentials 

35 

36 async def render(self, request): 

37 raise RuntimeError( 

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

39 ) 

40 

41 async def needs_blockwise_assembly(self, request): 

42 raise RuntimeError( 

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

44 ) 

45 

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

47 # all the others and indicates ;osc everywhere? 

48 

49 async def render_to_pipe(self, pipe): 

50 request = pipe.request 

51 

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

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

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

55 # cyclical reference closed after site construction 

56 await self._render_edhoc_to_pipe(pipe) 

57 return 

58 

59 try: 

60 unprotected = oscore.verify_start(request) 

61 except oscore.NotAProtectedMessage: 

62 # ie. if no object_seccurity present 

63 await self._inner_site.render_to_pipe(pipe) 

64 return 

65 

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

67 raise error.MethodNotAllowed 

68 

69 try: 

70 sc = self.server_credentials.find_oscore(unprotected) 

71 except KeyError: 

72 if request.mtype == aiocoap.CON: 

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

74 else: 

75 return 

76 

77 try: 

78 unprotected, seqno = sc.unprotect(request) 

79 except error.RenderableError as e: 

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

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

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

83 # renderable exception flying out of a user 

84 # render_to_pipe could only be be rendered to a 

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

86 # during to_message as well). 

87 # 

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

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

90 # treated as such. 

91 raise e 

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

93 except oscore.ReplayError: 

94 if request.mtype == aiocoap.CON: 

95 pipe.add_response( 

96 aiocoap.Message( 

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

98 ), 

99 is_last=True, 

100 ) 

101 return 

102 except oscore.DecodeError: 

103 if request.mtype == aiocoap.CON: 

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

105 else: 

106 return 

107 except oscore.ProtectionInvalid: 

108 if request.mtype == aiocoap.CON: 

109 raise error.BadRequest("Decryption failed") 

110 else: 

111 return 

112 

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

114 

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

116 

117 sc = sc.context_for_response() 

118 

119 inner_pipe = aiocoap.pipe.IterablePipe(unprotected) 

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

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

122 # feature of the aiterable PR?) 

123 aiocoap.pipe.run_driving_pipe( 

124 pr_that_can_take_errors, 

125 self._inner_site.render_to_pipe(inner_pipe), 

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

127 ) 

128 

129 async for event in inner_pipe: 

130 if event.exception is not None: 

131 # These are expected to be rare in handlers 

132 # 

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

134 # run_driving_pipe). Just raising them 

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

136 # then would hit the outer message. 

137 self.log.warn( 

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

139 event.exception, 

140 ) 

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

142 is_last = True 

143 else: 

144 message = event.message 

145 is_last = event.is_last 

146 

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

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

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

150 # annotated as "eventually consistent resource states". 

151 if not is_last: 

152 message.opt.observe = 0 

153 

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

155 if message.opt.observe is not None: 

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

157 # generally handles obs numbers better (sending the 

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

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

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

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

162 # most efficient 

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

164 self.log.debug( 

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

166 ) 

167 

168 pipe.add_response(protected_response, is_last=is_last) 

169 if event.is_last: 

170 break 

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

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

173 

174 async def _render_edhoc_to_pipe(self, pipe): 

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

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

177 

178 request = pipe.request 

179 

180 if request.code is not POST: 

181 raise error.MethodNotAllowed 

182 

183 if any( 

184 o.number.is_critical() 

185 for o in request.opt.option_list() 

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

187 ): 

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

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

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

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

192 raise error.BadOption 

193 

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

195 raise error.BadRequest 

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

197 self.log.error( 

198 "Receivign message 3 as a standalone message is not supported yet" 

199 ) 

200 # FIXME: Add support for it 

201 raise error.BadRequest 

202 

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

204 "/.well-known/edhoc" 

205 ) 

206 own_credential_object = self._get_edhoc_identity(origin) 

207 if own_credential_object is None: 

208 self.log.error( 

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

210 origin, 

211 ) 

212 raise error.NotFound 

213 

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

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

216 own_credential_object.own_cred.keys() 

217 ) == [14], ( 

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

219 % own_credential_object.own_cred 

220 ) 

221 responder = lakers.EdhocResponder( 

222 r=own_credential_object.own_key.d, 

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

224 ) 

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

226 if ead_1 is not None: 

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

228 raise error.BadRequest 

229 

230 used_own_identifiers = ( 

231 self.server_credentials.find_all_used_contextless_oscore_kid() 

232 ) 

233 # can't have c_r==c_i 

234 used_own_identifiers.add(c_i) 

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

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

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

238 if not candidates: 

239 # FIXME: LRU or timeout the contexts 

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

241 c_r = candidates[0] 

242 message_2 = responder.prepare_message_2( 

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

244 ) 

245 

246 credentials_entry = edhoc.EdhocResponderContext( 

247 responder, 

248 c_i, 

249 c_r, 

250 self.server_credentials, 

251 self.log, 

252 ) 

253 # FIXME we shouldn't need arbitrary keys 

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

255 

256 pipe.add_response( 

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

258 ) 

259 

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

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

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

263 when there are more methods or cipher suites) 

264 """ 

265 

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

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

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

269 # FIXME not really a pair needed is it? 

270 return None 

271 return candidate