Coverage for aiocoap/oscore.py: 87%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

837 statements  

1# This file is part of the Python aiocoap library project. 

2# 

3# Copyright (c) 2012-2014 Maciej Wasilak <http://sixpinetrees.blogspot.com/>, 

4# 2013-2014 Christian Amsüss <c.amsuess@energyharvesting.at> 

5# 

6# aiocoap is free software, this file is published under the MIT license as 

7# described in the accompanying LICENSE file. 

8 

9"""This module contains the tools to send OSCORE secured messages. 

10 

11It only deals with the algorithmic parts, the security context and protection 

12and unprotection of messages. It does not touch on the integration of OSCORE in 

13the larger aiocoap stack of having a context or requests; that's what 

14:mod:`aiocoap.transports.osore` is for.`""" 

15 

16from __future__ import annotations 

17 

18import io 

19import json 

20import binascii 

21import os 

22import os.path 

23import tempfile 

24import abc 

25from typing import Optional 

26import secrets 

27 

28from aiocoap.message import Message 

29from aiocoap.util import cryptography_additions 

30from aiocoap.numbers import GET, POST, FETCH, CHANGED, UNAUTHORIZED 

31from aiocoap import error 

32 

33from cryptography.hazmat.primitives.ciphers import aead 

34from cryptography.hazmat.primitives.kdf.hkdf import HKDF 

35from cryptography.hazmat.primitives import hashes 

36import cryptography.hazmat.backends 

37import cryptography.exceptions 

38from cryptography.hazmat.primitives import asymmetric, serialization 

39from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature, encode_dss_signature 

40 

41import cbor2 as cbor 

42 

43import filelock 

44 

45MAX_SEQNO = 2**40 - 1 

46 

47# Relevant values from the IANA registry "CBOR Object Signing and Encryption (COSE)" 

48COSE_KID = 4 

49COSE_PIV = 6 

50COSE_KID_CONTEXT = 10 

51# from https://tools.ietf.org/html/draft-ietf-cose-countersign-01 

52COSE_COUNTERSINGATURE0 = 11 

53 

54COMPRESSION_BITS_N = 0b111 

55COMPRESSION_BIT_K = 0b1000 

56COMPRESSION_BIT_H = 0b10000 

57COMPRESSION_BIT_G = 0b100000 # Group Flag from draft-ietf-core-oscore-groupcomm-10 

58COMPRESSION_BITS_RESERVED = 0b11000000 

59 

60class DeterministicKey: 

61 """Singleton to indicate that for this key member no public or private key 

62 is available because it is the Deterministic Client (see 

63 <https://www.ietf.org/archive/id/draft-amsuess-core-cachable-oscore-01.html>) 

64 

65 This is highly experimental not only from an implementation but also from a 

66 specification point of view. The specification has not received adaequate 

67 review that would justify using it in any non-experimental scenario. 

68 """ 

69DETERMINISTIC_KEY = DeterministicKey() 

70del DeterministicKey 

71 

72class NotAProtectedMessage(error.Error, ValueError): 

73 """Raised when verification is attempted on a non-OSCORE message""" 

74 

75 def __init__(self, message, plain_message): 

76 super().__init__(message) 

77 self.plain_message = plain_message 

78 

79class ProtectionInvalid(error.Error, ValueError): 

80 """Raised when verification of an OSCORE message fails""" 

81 

82class DecodeError(ProtectionInvalid): 

83 """Raised when verification of an OSCORE message fails because CBOR or compressed data were erroneous""" 

84 

85class ReplayError(ProtectionInvalid): 

86 """Raised when verification of an OSCORE message fails because the sequence numbers was already used""" 

87 

88class ReplayErrorWithEcho(ProtectionInvalid, error.RenderableError): 

89 """Raised when verification of an OSCORE message fails because the 

90 recipient replay window is uninitialized, but a 4.01 Echo can be 

91 constructed with the data in the exception that can lead to the client 

92 assisting in replay window recovery""" 

93 def __init__(self, secctx, request_id, echo): 

94 self.secctx = secctx 

95 self.request_id = request_id 

96 self.echo = echo 

97 

98 def to_message(self): 

99 inner = Message( 

100 code=UNAUTHORIZED, 

101 echo=self.echo, 

102 ) 

103 outer, _ = self.secctx.protect(inner, request_id=self.request_id) 

104 return outer 

105 

106class ContextUnavailable(error.Error, ValueError): 

107 """Raised when a context is (currently or permanently) unavailable for 

108 protecting or unprotecting a message""" 

109 

110class RequestIdentifiers: 

111 """A container for details that need to be passed along from the 

112 (un)protection of a request to the (un)protection of the response; these 

113 data ensure that the request-response binding process works by passing 

114 around the request's partial IV. 

115 

116 Users of this module should never create or interact with instances, but 

117 just pass them around. 

118 """ 

119 def __init__(self, kid, partial_iv, nonce, can_reuse_nonce): 

120 self.kid = kid 

121 self.partial_iv = partial_iv 

122 self.nonce = nonce 

123 self.can_reuse_nonce = can_reuse_nonce 

124 

125 self.request_hash = None 

126 

127 def get_reusable_nonce(self): 

128 """Return the nonce if can_reuse_nonce is True, and set can_reuse_nonce 

129 to False.""" 

130 

131 if self.can_reuse_nonce: 

132 self.can_reuse_nonce = False 

133 return self.nonce 

134 else: 

135 return None 

136 

137def _xor_bytes(a, b): 

138 assert len(a) == len(b) 

139 # FIXME is this an efficient thing to do, or should we store everything 

140 # that possibly needs xor'ing as long integers with an associated length? 

141 return bytes(_a ^ _b for (_a, _b) in zip(a, b)) 

142 

143class Algorithm(metaclass=abc.ABCMeta): 

144 @abc.abstractmethod 

145 def encrypt(cls, plaintext, aad, key, iv): 

146 """Return ciphertext + tag for given input data""" 

147 

148 @abc.abstractmethod 

149 def decrypt(cls, ciphertext_and_tag, aad, key, iv): 

150 """Reverse encryption. Must raise ProtectionInvalid on any error 

151 stemming from untrusted data.""" 

152 

153 @staticmethod 

154 def _build_encrypt0_structure(protected, external_aad): 

155 assert protected == {} 

156 protected_serialized = b'' # were it into an empty dict, it'd be the cbor dump 

157 enc_structure = ['Encrypt0', protected_serialized, external_aad] 

158 

159 return cbor.dumps(enc_structure) 

160 

161class AES_CCM(Algorithm, metaclass=abc.ABCMeta): 

162 """AES-CCM implemented using the Python cryptography library""" 

163 

164 @classmethod 

165 def encrypt(cls, plaintext, aad, key, iv): 

166 return aead.AESCCM(key, cls.tag_bytes).encrypt(iv, plaintext, aad) 

167 

168 @classmethod 

169 def decrypt(cls, ciphertext_and_tag, aad, key, iv): 

170 try: 

171 return aead.AESCCM(key, cls.tag_bytes).decrypt(iv, ciphertext_and_tag, aad) 

172 except cryptography.exceptions.InvalidTag: 

173 raise ProtectionInvalid("Tag invalid") 

174 

175class AES_CCM_16_64_128(AES_CCM): 

176 # from RFC8152 and draft-ietf-core-object-security-0[012] 3.2.1 

177 value = 10 

178 key_bytes = 16 # 128-bit key 

179 tag_bytes = 8 # 64-bit tag 

180 iv_bytes = 13 # 13-byte nonce 

181 

182class AES_CCM_16_64_256(AES_CCM): 

183 # from RFC8152 

184 value = 11 

185 key_bytes = 32 # 256-bit key 

186 tag_bytes = 8 # 64-bit tag 

187 iv_bytes = 13 # 13-byte nonce 

188 

189class AES_CCM_64_64_128(AES_CCM): 

190 # from RFC8152 

191 value = 12 

192 key_bytes = 16 # 128-bit key 

193 tag_bytes = 8 # 64-bit tag 

194 iv_bytes = 7 # 7-byte nonce 

195 

196class AES_CCM_64_64_256(AES_CCM): 

197 # from RFC8152 

198 value = 13 

199 key_bytes = 32 # 256-bit key 

200 tag_bytes = 8 # 64-bit tag 

201 iv_bytes = 7 # 7-byte nonce 

202 

203class AES_CCM_16_128_128(AES_CCM): 

204 # from RFC8152 

205 value = 30 

206 key_bytes = 16 # 128-bit key 

207 tag_bytes = 16 # 128-bit tag 

208 iv_bytes = 13 # 13-byte nonce 

209 

210class AES_CCM_16_128_256(AES_CCM): 

211 # from RFC8152 

212 value = 31 

213 key_bytes = 32 # 256-bit key 

214 tag_bytes = 16 # 128-bit tag 

215 iv_bytes = 13 # 13-byte nonce 

216 

217class AES_CCM_64_128_128(AES_CCM): 

218 # from RFC8152 

219 value = 32 

220 key_bytes = 16 # 128-bit key 

221 tag_bytes = 16 # 128-bit tag 

222 iv_bytes = 7 # 7-byte nonce 

223 

224class AES_CCM_64_128_256(AES_CCM): 

225 # from RFC8152 

226 value = 33 

227 key_bytes = 32 # 256-bit key 

228 tag_bytes = 16 # 128-bit tag 

229 iv_bytes = 7 # 7-byte nonce 

230 

231 

232class AES_GCM(Algorithm, metaclass=abc.ABCMeta): 

233 """AES-GCM implemented using the Python cryptography library""" 

234 

235 iv_bytes = 12 # 96 bits fixed size of the nonce 

236 

237 @classmethod 

238 def encrypt(cls, plaintext, aad, key, iv): 

239 return aead.AESGCM(key).encrypt(iv, plaintext, aad) 

240 

241 @classmethod 

242 def decrypt(cls, ciphertext_and_tag, aad, key, iv): 

243 try: 

244 return aead.AESGCM(key).decrypt(iv, ciphertext_and_tag, aad) 

245 except cryptography.exceptions.InvalidTag: 

246 raise ProtectionInvalid("Tag invalid") 

247 

248class A128GCM(AES_GCM): 

249 # from RFC8152 

250 value = 1 

251 key_bytes = 16 # 128-bit key 

252 tag_bytes = 16 # 128-bit tag 

253 

254class A192GCM(AES_GCM): 

255 # from RFC8152 

256 value = 2 

257 key_bytes = 24 # 192-bit key 

258 tag_bytes = 16 # 128-bit tag 

259 

260class A256GCM(AES_GCM): 

261 # from RFC8152 

262 value = 3 

263 key_bytes = 32 # 256-bit key 

264 tag_bytes = 16 # 128-bit tag 

265 

266class ChaCha20Poly1305(Algorithm): 

267 # from RFC8152 

268 value = 24 

269 key_bytes = 32 # 256-bit key 

270 tag_bytes = 16 # 128-bit tag 

271 iv_bytes = 12 # 96-bit nonce 

272 

273 @classmethod 

274 def encrypt(cls, plaintext, aad, key, iv): 

275 return aead.ChaCha20Poly1305(key).encrypt(iv, plaintext, aad) 

276 

277 @classmethod 

278 def decrypt(cls, ciphertext_and_tag, aad, key, iv): 

279 try: 

280 return aead.ChaCha20Poly1305(key).decrypt(iv, ciphertext_and_tag, aad) 

281 except cryptography.exceptions.InvalidTag: 

282 raise ProtectionInvalid("Tag invalid") 

283 

284class AlgorithmCountersign(metaclass=abc.ABCMeta): 

285 """A fully parameterized COSE countersign algorithm 

286 

287 An instance is able to provide all the alg_countersign, par_countersign and 

288 par_countersign_key parameters taht go into the Group OSCORE algorithms 

289 field. 

290 """ 

291 @abc.abstractmethod 

292 def sign(self, body, external_aad, private_key): 

293 """Return the signature produced by the key when using 

294 CounterSignature0 as describe in draft-ietf-cose-countersign-01""" 

295 

296 @abc.abstractmethod 

297 def verify(self, signature, body, external_aad, public_key): 

298 """Verify a signature in analogy to sign""" 

299 

300 @abc.abstractmethod 

301 def generate(self): 

302 """Return a usable private key""" 

303 

304 @abc.abstractmethod 

305 def public_from_private(self, private_key): 

306 """Given a private key, derive the publishable key""" 

307 

308 @abc.abstractmethod 

309 def staticstatic(self, private_key, public_key): 

310 """Derive a shared static-static secret from a private and a public key""" 

311 

312 @staticmethod 

313 def _build_countersign_structure(body, external_aad): 

314 countersign_structure = [ 

315 "CounterSignature0", 

316 b"", 

317 b"", 

318 external_aad, 

319 body 

320 ] 

321 tobesigned = cbor.dumps(countersign_structure) 

322 return tobesigned 

323 

324 @property 

325 @abc.abstractproperty 

326 def signature_length(self): 

327 """The length of a signature using this algorithm""" 

328 

329class Ed25519(AlgorithmCountersign): 

330 def sign(self, body, aad, private_key): 

331 private_key = asymmetric.ed25519.Ed25519PrivateKey.from_private_bytes(private_key) 

332 return private_key.sign(self._build_countersign_structure(body, aad)) 

333 

334 def verify(self, signature, body, aad, public_key): 

335 public_key = asymmetric.ed25519.Ed25519PublicKey.from_public_bytes(public_key) 

336 try: 

337 public_key.verify(signature, self._build_countersign_structure(body, aad)) 

338 except cryptography.exceptions.InvalidSignature: 

339 raise ProtectionInvalid("Signature mismatch") 

340 

341 def generate(self): 

342 key = asymmetric.ed25519.Ed25519PrivateKey.generate() 

343 # FIXME: We could avoid handing the easy-to-misuse bytes around if the 

344 # current algorithm interfaces did not insist on passing the 

345 # exchangable representations -- and generally that should be more 

346 # efficient. 

347 return key.private_bytes( 

348 encoding=serialization.Encoding.Raw, 

349 format=serialization.PrivateFormat.Raw, 

350 encryption_algorithm=serialization.NoEncryption(), 

351 ) 

352 

353 def public_from_private(self, private_key): 

354 private_key = asymmetric.ed25519.Ed25519PrivateKey.from_private_bytes(private_key) 

355 public_key = private_key.public_key() 

356 return public_key.public_bytes( 

357 encoding=serialization.Encoding.Raw, 

358 format=serialization.PublicFormat.Raw, 

359 ) 

360 

361 def staticstatic(self, private_key, public_key): 

362 private_key = asymmetric.ed25519.Ed25519PrivateKey.from_private_bytes(private_key) 

363 private_key = cryptography_additions.sk_to_curve25519(private_key) 

364 

365 public_key = asymmetric.ed25519.Ed25519PublicKey.from_public_bytes(public_key) 

366 public_key = cryptography_additions.pk_to_curve25519(public_key) 

367 

368 return private_key.exchange(public_key) 

369 

370 # from https://tools.ietf.org/html/draft-ietf-core-oscore-groupcomm-10#appendix-G 

371 value_all_par = [-8, [[1], [1, 6]]] 

372 

373 signature_length = 64 

374 

375class ECDSA_SHA256_P256(AlgorithmCountersign): 

376 # Trying a new construction approach -- should work just as well given 

377 # we're just passing Python objects around 

378 def from_public_parts(self, x: bytes, y: bytes): 

379 """Create a public key from its COSE values""" 

380 return asymmetric.ec.EllipticCurvePublicNumbers( 

381 int.from_bytes(x, 'big'), 

382 int.from_bytes(y, 'big'), 

383 asymmetric.ec.SECP256R1() 

384 ).public_key() 

385 

386 def from_private_parts(self, x: bytes, y: bytes, d: bytes): 

387 public_numbers = self.from_public_parts(x, y).public_numbers() 

388 private_numbers = asymmetric.ec.EllipticCurvePrivateNumbers( 

389 int.from_bytes(d, 'big'), 

390 public_numbers) 

391 return private_numbers.private_key() 

392 

393 def sign(self, body, aad, private_key): 

394 der_signature = private_key.sign(self._build_countersign_structure(body, aad), asymmetric.ec.ECDSA(hashes.SHA256())) 

395 (r, s) = decode_dss_signature(der_signature) 

396 

397 return r.to_bytes(32, "big") + s.to_bytes(32, "big") 

398 

399 def verify(self, signature, body, aad, public_key): 

400 r = signature[:32] 

401 s = signature[32:] 

402 r = int.from_bytes(r, "big") 

403 s = int.from_bytes(s, "big") 

404 der_signature = encode_dss_signature(r, s) 

405 try: 

406 public_key.verify(der_signature, self._build_countersign_structure(body, aad), asymmetric.ec.ECDSA(hashes.SHA256())) 

407 except cryptography.exceptions.InvalidSignature: 

408 raise ProtectionInvalid("Signature mismatch") 

409 

410 def generate(self): 

411 return asymmetric.ec.generate_private_key(asymmetric.ec.SECP256R1()) 

412 

413 def public_from_private(self, private_key): 

414 return private_key.public_key() 

415 

416 def staticstatic(self, private_key, public_key): 

417 return private_key.exchange(asymmetric.ec.ECDH(), public_key) 

418 

419 # from https://tools.ietf.org/html/draft-ietf-core-oscore-groupcomm-10#appendix-G 

420 value_all_par = [-7, [[2], [2, 1]]] 

421 

422 signature_length = 64 

423 

424algorithms = { 

425 'AES-CCM-16-64-128': AES_CCM_16_64_128(), 

426 'AES-CCM-16-64-256': AES_CCM_16_64_256(), 

427 'AES-CCM-64-64-128': AES_CCM_64_64_128(), 

428 'AES-CCM-64-64-256': AES_CCM_64_64_256(), 

429 'AES-CCM-16-128-128': AES_CCM_16_128_128(), 

430 'AES-CCM-16-128-256': AES_CCM_16_128_256(), 

431 'AES-CCM-64-128-128': AES_CCM_64_128_128(), 

432 'AES-CCM-64-128-256': AES_CCM_64_128_256(), 

433 'ChaCha20/Poly1305': ChaCha20Poly1305(), 

434 'A128GCM': A128GCM(), 

435 'A192GCM': A192GCM(), 

436 'A256GCM': A256GCM(), 

437 } 

438 

439# algorithms with full parameter set 

440algorithms_countersign = { 

441 # maybe needs a different name... 

442 'EdDSA on Ed25519': Ed25519(), 

443 'ECDSA w/ SHA-256 on P-256': ECDSA_SHA256_P256(), 

444 } 

445 

446DEFAULT_ALGORITHM = 'AES-CCM-16-64-128' 

447 

448_hash_backend = cryptography.hazmat.backends.default_backend() 

449hashfunctions = { 

450 'sha256': hashes.SHA256(), 

451 } 

452 

453DEFAULT_HASHFUNCTION = 'sha256' 

454 

455DEFAULT_WINDOWSIZE = 32 

456 

457class BaseSecurityContext: 

458 # The protection and unprotection functions will use the Group OSCORE AADs 

459 # rather than the regular OSCORE AADs. (Ie. alg_countersign is added to 

460 # the algorithms, and the id_context is added at the end). 

461 # 

462 # This is not necessarily identical to is_signing (as pairwise contexts use 

463 # this but don't sign), and is distinct from the added OSCORE option in the 

464 # AAD (as that's only applicable for the external AAD as extracted for 

465 # signing and signature verification purposes). 

466 external_aad_is_group = False 

467 

468 # Authentication information carried with this security context; managed 

469 # externally by whatever creates the security context. 

470 authenticated_claims = [] 

471 

472 def _construct_nonce(self, partial_iv_short, piv_generator_id): 

473 pad_piv = b"\0" * (5 - len(partial_iv_short)) 

474 

475 s = bytes([len(piv_generator_id)]) 

476 pad_id = b'\0' * (self.algorithm.iv_bytes - 6 - len(piv_generator_id)) 

477 

478 components = s + \ 

479 pad_id + \ 

480 piv_generator_id + \ 

481 pad_piv + \ 

482 partial_iv_short 

483 

484 nonce = _xor_bytes(self.common_iv, components) 

485 

486 return nonce 

487 

488 def _extract_external_aad(self, message, request_id): 

489 # If any option were actually Class I, it would be something like 

490 # 

491 # the_options = pick some of(message) 

492 # class_i_options = Message(the_options).opt.encode() 

493 

494 oscore_version = 1 

495 class_i_options = b"" 

496 if request_id.request_hash is not None: 

497 class_i_options = Message(request_hash=request_id.request_hash).opt.encode() 

498 

499 algorithms = [self.algorithm.value] 

500 if self.external_aad_is_group: 

501 algorithms.extend(self.alg_countersign.value_all_par) 

502 

503 external_aad = [ 

504 oscore_version, 

505 algorithms, 

506 request_id.kid, 

507 request_id.partial_iv, 

508 class_i_options, 

509 ] 

510 

511 if self.external_aad_is_group: 

512 external_aad.append(self.id_context) 

513 

514 assert message.opt.object_security is not None 

515 external_aad.append(message.opt.object_security) 

516 

517 external_aad = cbor.dumps(external_aad) 

518 

519 return external_aad 

520 

521# FIXME pull interface components from SecurityContext up here 

522class CanProtect(BaseSecurityContext, metaclass=abc.ABCMeta): 

523 # The protection function will add a signature acccording to the context's 

524 # alg_countersign attribute if this is true 

525 is_signing = False 

526 

527 # Send the KID when protecting responses 

528 # 

529 # Once group pairwise mode is implemented, this will need to become a 

530 # parameter to protect(), which is stored at the point where the incoming 

531 # context is turned into an outgoing context. (Currently, such a mechanism 

532 # isn't there yet, and oscore_wrapper protects responses with the very same 

533 # context they came in on). 

534 responses_send_kid = False 

535 

536 @staticmethod 

537 def _compress(protected, unprotected, ciphertext): 

538 """Pack the untagged COSE_Encrypt0 object described by the *args 

539 into two bytestrings suitable for the Object-Security option and the 

540 message body""" 

541 

542 if protected: 

543 raise RuntimeError("Protection produced a message that has uncompressable fields.") 

544 

545 piv = unprotected.pop(COSE_PIV, b"") 

546 if len(piv) > COMPRESSION_BITS_N: 

547 raise ValueError("Can't encode overly long partial IV") 

548 

549 firstbyte = len(piv) 

550 if COSE_KID in unprotected: 

551 firstbyte |= COMPRESSION_BIT_K 

552 kid_data = unprotected.pop(COSE_KID) 

553 else: 

554 kid_data = b"" 

555 

556 if COSE_KID_CONTEXT in unprotected: 

557 firstbyte |= COMPRESSION_BIT_H 

558 kid_context = unprotected.pop(COSE_KID_CONTEXT) 

559 s = len(kid_context) 

560 if s > 255: 

561 raise ValueError("KID Context too long") 

562 s_kid_context = bytes((s,)) + kid_context 

563 else: 

564 s_kid_context = b"" 

565 

566 if COSE_COUNTERSINGATURE0 in unprotected: 

567 firstbyte |= COMPRESSION_BIT_G 

568 

569 # In theory at least. In practice, that's an empty value to later 

570 # be squished in when the compressed option value is available for 

571 # signing. 

572 ciphertext += unprotected.pop(COSE_COUNTERSINGATURE0) 

573 

574 if unprotected: 

575 raise RuntimeError("Protection produced a message that has uncompressable fields.") 

576 

577 if firstbyte: 

578 option = bytes([firstbyte]) + piv + s_kid_context + kid_data 

579 else: 

580 option = b"" 

581 

582 return (option, ciphertext) 

583 

584 def protect(self, message, request_id=None, *, kid_context=True): 

585 """Given a plain CoAP message, create a protected message that contains 

586 message's options in the inner or outer CoAP message as described in 

587 OSCOAP. 

588 

589 If the message is a response to a previous message, the additional data 

590 from unprotecting the request are passed in as request_id. When 

591 request data is present, its partial IV is reused if possible. The 

592 security context's ID context is encoded in the resulting message 

593 unless kid_context is explicitly set to a False; other values for the 

594 kid_context can be passed in as byte string in the same parameter. 

595 """ 

596 

597 assert (request_id is None) == message.code.is_request() 

598 

599 outer_message, plaintext = self._split_message(message) 

600 

601 protected = {} 

602 nonce = None 

603 unprotected = {} 

604 if request_id is not None: 

605 nonce = request_id.get_reusable_nonce() 

606 

607 if nonce is None: 

608 nonce, partial_iv_short = self._build_new_nonce() 

609 

610 unprotected[COSE_PIV] = partial_iv_short 

611 

612 if message.code.is_request(): 

613 unprotected[COSE_KID] = self.sender_id 

614 

615 request_id = RequestIdentifiers(self.sender_id, partial_iv_short, nonce, can_reuse_nonce=None) 

616 

617 if kid_context is True: 

618 if self.id_context is not None: 

619 unprotected[COSE_KID_CONTEXT] = self.id_context 

620 elif kid_context is not False: 

621 unprotected[COSE_KID_CONTEXT] = kid_context 

622 else: 

623 if self.responses_send_kid: 

624 unprotected[COSE_KID] = self.sender_id 

625 

626 # Putting in a dummy value as the signature calculation will already need some of the compression result 

627 if self.is_signing: 

628 unprotected[COSE_COUNTERSINGATURE0] = b"" 

629 # FIXME: Running this twice quite needlessly (just to get the object_security option for sending) 

630 option_data, _ = self._compress(protected, unprotected, b"") 

631 

632 outer_message.opt.object_security = option_data 

633 

634 external_aad = self._extract_external_aad(outer_message, request_id) 

635 

636 aad = self.algorithm._build_encrypt0_structure(protected, external_aad) 

637 

638 key = self._get_sender_key(outer_message, external_aad, plaintext, request_id) 

639 

640 ciphertext = self.algorithm.encrypt(plaintext, aad, key, nonce) 

641 

642 _, payload = self._compress(protected, unprotected, ciphertext) 

643 

644 if self.is_signing: 

645 payload += self.alg_countersign.sign(payload, external_aad, self.private_key) 

646 outer_message.payload = payload 

647 

648 # FIXME go through options section 

649 

650 # the request_id in the second argument should be discarded by the 

651 # caller when protecting a response -- is that reason enough for an 

652 # `if` and returning None? 

653 return outer_message, request_id 

654 

655 def _get_sender_key(self, outer_message, aad, plaintext, request_id): 

656 """Customization hook of the protect function 

657 

658 While most security contexts have a fixed sender key, deterministic 

659 requests need to shake up a few things. They need to modify the outer 

660 message, as well as the request_id as it will later be used to 

661 unprotect the response.""" 

662 return self.sender_key 

663 

664 def _split_message(self, message): 

665 """Given a protected message, return the outer message that contains 

666 all Class I and Class U options (but without payload or Object-Security 

667 option), and the encoded inner message that contains all Class E 

668 options and the payload. 

669 

670 This leaves the messages' remotes unset.""" 

671 

672 if message.code.is_request(): 

673 outer_host = message.opt.uri_host 

674 proxy_uri = message.opt.proxy_uri 

675 

676 inner_message = message.copy( 

677 uri_host=None, 

678 uri_port=None, 

679 proxy_uri=None, 

680 proxy_scheme=None, 

681 ) 

682 inner_message.remote = None 

683 

684 if proxy_uri is not None: 

685 # Use set_request_uri to split up the proxy URI into its 

686 # components; extract, preserve and clear them. 

687 inner_message.set_request_uri(proxy_uri, set_uri_host=False) 

688 if inner_message.opt.proxy_uri is not None: 

689 raise ValueError("Can not split Proxy-URI into options") 

690 outer_uri = inner_message.remote.uri_base 

691 inner_message.remote = None 

692 inner_message.opt.proxy_scheme = None 

693 

694 if message.opt.observe is None: 

695 outer_code = POST 

696 else: 

697 outer_code = FETCH 

698 else: 

699 outer_host = None 

700 proxy_uri = None 

701 

702 inner_message = message.copy() 

703 

704 # FIXME actually CHANGED or CONTENT, but that means the original code needs to be dragged along in RequestIdentifiers 

705 outer_code = CHANGED 

706 

707 # no max-age because these are always successsful responses 

708 outer_message = Message(code=outer_code, 

709 uri_host=outer_host, 

710 observe=None if message.code.is_response() else message.opt.observe, 

711 ) 

712 if proxy_uri is not None: 

713 outer_message.set_request_uri(outer_uri) 

714 

715 plaintext = bytes([inner_message.code]) + inner_message.opt.encode() 

716 if inner_message.payload: 

717 plaintext += bytes([0xFF]) 

718 plaintext += inner_message.payload 

719 

720 return outer_message, plaintext 

721 

722 def _build_new_nonce(self): 

723 """This implements generation of a new nonce, assembled as per Figure 5 

724 of draft-ietf-core-object-security-06. Returns the shortened partial IV 

725 as well.""" 

726 seqno = self.new_sequence_number() 

727 

728 partial_iv = seqno.to_bytes(5, 'big') 

729 

730 return (self._construct_nonce(partial_iv, self.sender_id), partial_iv.lstrip(b'\0') or b'\0') 

731 

732 # sequence number handling 

733 

734 def new_sequence_number(self): 

735 """Return a new sequence number; the implementation is responsible for 

736 never returning the same value twice in a given security context. 

737 

738 May raise ContextUnavailable.""" 

739 retval = self.sender_sequence_number 

740 if retval >= MAX_SEQNO: 

741 raise ContextUnavailable("Sequence number too large, context is exhausted.") 

742 self.sender_sequence_number += 1 

743 self.post_seqnoincrease() 

744 return retval 

745 

746 # implementation defined 

747 

748 @abc.abstractmethod 

749 def post_seqnoincrease(self): 

750 """Ensure that sender_sequence_number is stored""" 

751 raise 

752 

753 def context_from_response(self, unprotected_bag) -> CanUnprotect: 

754 """When receiving a response to a request protected with this security 

755 context, pick the security context with which to unprotect the response 

756 given the unprotected information from the Object-Security option. 

757 

758 This allow picking the right security context in a group response, and 

759 helps getting a new short-lived context for B.2 mode. The default 

760 behaivor is returning self. 

761 """ 

762 return self # FIXME justify by moving into a mixin for CanProtectAndUnprotect 

763 

764class CanUnprotect(BaseSecurityContext): 

765 def unprotect(self, protected_message, request_id=None): 

766 assert (request_id is not None) == protected_message.code.is_response() 

767 is_response = protected_message.code.is_response() 

768 

769 # Set to a raisable exception on replay check failures; it will be 

770 # raised, but the package may still be processed in the course of Echo handling. 

771 replay_error = None 

772 

773 protected_serialized, protected, unprotected, ciphertext = self._extract_encrypted0(protected_message) 

774 

775 if protected: 

776 raise ProtectionInvalid("The protected field is not empty") 

777 

778 # FIXME check for duplicate keys in protected 

779 

780 if unprotected.pop(COSE_KID_CONTEXT, self.id_context) != self.id_context: 

781 # FIXME is this necessary? 

782 raise ProtectionInvalid("Sender ID context does not match") 

783 

784 if unprotected.pop(COSE_KID, self.recipient_id) != self.recipient_id: 

785 # for most cases, this is caught by the session ID dispatch, but in 

786 # responses (where explicit sender IDs are atypical), this is a 

787 # valid check 

788 raise ProtectionInvalid("Sender ID does not match") 

789 

790 if COSE_PIV not in unprotected: 

791 if not is_response: 

792 raise ProtectionInvalid("No sequence number provided in request") 

793 

794 nonce = request_id.nonce 

795 seqno = None # sentinel for not striking out anyting 

796 else: 

797 partial_iv_short = unprotected.pop(COSE_PIV) 

798 

799 nonce = self._construct_nonce(partial_iv_short, self.recipient_id) 

800 

801 seqno = int.from_bytes(partial_iv_short, 'big') 

802 

803 if not is_response: 

804 if not self.recipient_replay_window.is_initialized(): 

805 replay_error = ReplayError("Sequence number check unavailable") 

806 elif not self.recipient_replay_window.is_valid(seqno): 

807 replay_error = ReplayError("Sequence number was re-used") 

808 

809 if replay_error is not None and self.echo_recovery is None: 

810 # Don't even try decoding if there is no reason to 

811 raise replay_error 

812 

813 request_id = RequestIdentifiers(self.recipient_id, partial_iv_short, nonce, can_reuse_nonce=replay_error is None) 

814 

815 if unprotected.pop(COSE_COUNTERSINGATURE0, None) is not None: 

816 try: 

817 alg_countersign = self.alg_countersign 

818 except NameError: 

819 raise DecodeError("Group messages can not be decoded with this non-group context") 

820 

821 siglen = alg_countersign.signature_length 

822 if len(ciphertext) < siglen: 

823 raise DecodeError("Message too short for signature") 

824 signature = ciphertext[-siglen:] 

825 ciphertext = ciphertext[:-siglen] 

826 else: 

827 signature = None 

828 

829 if unprotected: 

830 raise DecodeError("Unsupported unprotected option") 

831 

832 if len(ciphertext) < self.algorithm.tag_bytes + 1: # +1 assures access to plaintext[0] (the code) 

833 raise ProtectionInvalid("Ciphertext too short") 

834 

835 external_aad = self._extract_external_aad(protected_message, request_id) 

836 enc_structure = ['Encrypt0', protected_serialized, external_aad] 

837 aad = cbor.dumps(enc_structure) 

838 

839 key = self._get_recipient_key(protected_message) 

840 

841 plaintext = self.algorithm.decrypt(ciphertext, aad, key, nonce) 

842 

843 self._post_decrypt_checks(external_aad, plaintext, protected_message, request_id) 

844 

845 if not is_response and seqno is not None and replay_error is None: 

846 self.recipient_replay_window.strike_out(seqno) 

847 

848 if signature is not None: 

849 # Only doing the expensive signature validation once the cheaper decyrption passed 

850 alg_countersign.verify(signature, ciphertext, external_aad, self.recipient_public_key) 

851 

852 # FIXME add options from unprotected 

853 

854 unprotected_message = Message(code=plaintext[0]) 

855 unprotected_message.payload = unprotected_message.opt.decode(plaintext[1:]) 

856 

857 try_initialize = not self.recipient_replay_window.is_initialized() and \ 

858 self.echo_recovery is not None 

859 if try_initialize: 

860 if protected_message.code.is_request(): 

861 # Either accept into replay window and clear replay error, or raise 

862 # something that can turn into a 4.01,Echo response 

863 if unprotected_message.opt.echo == self.echo_recovery: 

864 self.recipient_replay_window.initialize_from_freshlyseen(seqno) 

865 replay_error = None 

866 else: 

867 raise ReplayErrorWithEcho(secctx=self, request_id=request_id, echo=self.echo_recovery) 

868 else: 

869 # We can initialize the replay window from a response as well. 

870 # The response is guaranteed fresh as it was AEAD-decoded to 

871 # match a request sent by this process. 

872 # 

873 # This is rare, as it only works when the server uses an own 

874 # sequence number, eg. when sending a notification or when 

875 # acting again on a retransmitted safe request whose response 

876 # it did not cache. 

877 # 

878 # Nothing bad happens if we can't make progress -- we just 

879 # don't initialize the replay window that wouldn't have been 

880 # checked for a response anyway. 

881 if seqno is not None: 

882 self.recipient_replay_window.initialize_from_freshlyseen(seqno) 

883 

884 if replay_error is not None: 

885 raise replay_error 

886 

887 if unprotected_message.code.is_request(): 

888 if protected_message.opt.observe != 0: 

889 unprotected_message.opt.observe = None 

890 else: 

891 if protected_message.opt.observe is not None: 

892 # -1 ensures that they sort correctly in later reordering 

893 # detection. Note that neither -1 nor high (>3 byte) sequence 

894 # numbers can be serialized in the Observe option, but they are 

895 # in this implementation accepted for passing around. 

896 unprotected_message.opt.observe = -1 if seqno is None else seqno 

897 

898 return unprotected_message, request_id 

899 

900 def _get_recipient_key(self, protected_message): 

901 """Customization hook of the unprotect function 

902 

903 While most security contexts have a fixed recipient key, deterministic 

904 requests build it on demand.""" 

905 return self.recipient_key 

906 

907 def _post_decrypt_checks(self, aad, plaintext, protected_message, request_id): 

908 """Customization hook of the unprotect function after decryption 

909 

910 While most security contexts are good with the default checks, 

911 deterministic requests need to perform additional checks while AAD and 

912 plaintext information is still available, and modify the request_id for 

913 the later protection step of the response.""" 

914 

915 @staticmethod 

916 def _uncompress(option_data, payload): 

917 if option_data == b"": 

918 firstbyte = 0 

919 else: 

920 firstbyte = option_data[0] 

921 tail = option_data[1:] 

922 

923 unprotected = {} 

924 

925 if firstbyte & COMPRESSION_BITS_RESERVED: 

926 raise DecodeError("Protected data uses reserved fields") 

927 

928 pivsz = firstbyte & COMPRESSION_BITS_N 

929 if pivsz: 

930 if len(tail) < pivsz: 

931 raise DecodeError("Partial IV announced but not present") 

932 unprotected[COSE_PIV] = tail[:pivsz] 

933 tail = tail[pivsz:] 

934 

935 if firstbyte & COMPRESSION_BIT_H: 

936 # kid context hint 

937 s = tail[0] 

938 if len(tail) - 1 < s: 

939 raise DecodeError("Context hint announced but not present") 

940 unprotected[COSE_KID_CONTEXT] = tail[1:s+1] 

941 tail = tail[s+1:] 

942 

943 if firstbyte & COMPRESSION_BIT_K: 

944 kid = tail 

945 unprotected[COSE_KID] = kid 

946 

947 if firstbyte & COMPRESSION_BIT_G: 

948 # Not really; As this is (also) used early on (before the KID 

949 # context is even known, because it's just getting extracted), this 

950 # is returning an incomplete value here and leaves it to the later 

951 # processing to strip the right number of bytes from the ciphertext 

952 unprotected[COSE_COUNTERSINGATURE0] = b"" 

953 

954 return b"", {}, unprotected, payload 

955 

956 @classmethod 

957 def _extract_encrypted0(cls, message): 

958 if message.opt.object_security is None: 

959 raise NotAProtectedMessage("No Object-Security option present", message) 

960 

961 protected_serialized, protected, unprotected, ciphertext = cls._uncompress(message.opt.object_security, message.payload) 

962 return protected_serialized, protected, unprotected, ciphertext 

963 

964 # implementation defined 

965 

966 def context_for_response(self) -> CanProtect: 

967 """After processing a request with this context, with which security 

968 context should an outgoing response be protected? By default, it's the 

969 same context.""" 

970 # FIXME: Is there any way in which the handler may want to influence 

971 # the decision taken here? Or would, then, the handler just call a more 

972 # elaborate but similar function when setting the response's remote 

973 # already? 

974 return self # FIXME justify by moving into a mixin for CanProtectAndUnprotect 

975 

976class SecurityContextUtils(BaseSecurityContext): 

977 def _kdf(self, master_salt, master_secret, role_id, out_type): 

978 out_bytes = {'Key': self.algorithm.key_bytes, 'IV': self.algorithm.iv_bytes}[out_type] 

979 

980 info = cbor.dumps([ 

981 role_id, 

982 self.id_context, 

983 self.algorithm.value, 

984 out_type, 

985 out_bytes 

986 ]) 

987 hkdf = HKDF( 

988 algorithm=self.hashfun, 

989 length=out_bytes, 

990 salt=master_salt, 

991 info=info, 

992 backend=_hash_backend, 

993 ) 

994 expanded = hkdf.derive(master_secret) 

995 return expanded 

996 

997 def derive_keys(self, master_salt, master_secret): 

998 """Populate sender_key, recipient_key and common_iv from the algorithm, 

999 hash function and id_context already configured beforehand, and from 

1000 the passed salt and secret.""" 

1001 

1002 self.sender_key = self._kdf(master_salt, master_secret, self.sender_id, 'Key') 

1003 self.recipient_key = self._kdf(master_salt, master_secret, self.recipient_id, 'Key') 

1004 

1005 self.common_iv = self._kdf(master_salt, master_secret, b"", 'IV') 

1006 

1007 # really more of the Credentials interface 

1008 

1009 def get_oscore_context_for(self, unprotected): 

1010 """Return a sutiable context (most easily self) for an incoming request 

1011 if its unprotected data (COSE_KID, COSE_KID_CONTEXT) fit its 

1012 description. If it doesn't match, it returns None. 

1013 

1014 The default implementation just strictly checks for whether kid and any 

1015 kid context match (not matching if a local KID context is set but none 

1016 is given in the request); modes like Group OSCORE can spin up aspect 

1017 objects here. 

1018 """ 

1019 if unprotected.get(COSE_KID, None) == self.recipient_id and unprotected.get(COSE_KID_CONTEXT, None) == self.id_context: 

1020 return self 

1021 

1022class ReplayWindow: 

1023 """A regular replay window of a fixed size. 

1024 

1025 It is implemented as an index and a bitfield (represented by an integer) 

1026 whose least significant bit represents the seqyence number of the index, 

1027 and a 1 indicates that a number was seen. No shenanigans around implicit 

1028 leading ones (think floating point normalization) happen. 

1029 

1030 >>> w = ReplayWindow(32, lambda: None) 

1031 >>> w.initialize_empty() 

1032 >>> w.strike_out(5) 

1033 >>> w.is_valid(3) 

1034 True 

1035 >>> w.is_valid(5) 

1036 False 

1037 >>> w.strike_out(0) 

1038 >>> w.strike_out(1) 

1039 >>> w.strike_out(2) 

1040 >>> w.is_valid(1) 

1041 False 

1042 

1043 Jumping ahead by the window size invalidates older numbers: 

1044 

1045 >>> w.is_valid(4) 

1046 True 

1047 >>> w.strike_out(35) 

1048 >>> w.is_valid(4) 

1049 True 

1050 >>> w.strike_out(36) 

1051 >>> w.is_valid(4) 

1052 False 

1053 

1054 Usage safety 

1055 ------------ 

1056 

1057 For every key, the replay window can only be initielized empty once. On 

1058 later uses, it needs to be persisted by storing the output of 

1059 self.persist() somewhere and loaded from that persisted data. 

1060 

1061 It is acceptable to store persistance data in the strike_out_callback, but 

1062 that must then ensure that the data is written (flushed to a file or 

1063 committed to a database), but that is usually inefficient. 

1064 

1065 Stability 

1066 --------- 

1067 

1068 This class is not considered for stabilization yet and an implementation 

1069 detail of the SecurityContext implementation(s). 

1070 """ 

1071 

1072 _index = None 

1073 """Sequence number represented by the least significant bit of _bitfield""" 

1074 _bitfield = None 

1075 """Integer interpreted as a bitfield, self._size wide. A digit 1 at any bit 

1076 indicates that the bit's index (its power of 2) plus self._index was 

1077 already seen.""" 

1078 

1079 def __init__(self, size, strike_out_callback): 

1080 self._size = size 

1081 self.strike_out_callback = strike_out_callback 

1082 

1083 def is_initialized(self): 

1084 return self._index is not None 

1085 

1086 def initialize_empty(self): 

1087 self._index = 0 

1088 self._bitfield = 0 

1089 

1090 def initialize_from_persisted(self, persisted): 

1091 self._index = persisted['index'] 

1092 self._bitfield = persisted['bitfield'] 

1093 

1094 def initialize_from_freshlyseen(self, seen): 

1095 """Initialize the replay window with a particular value that is just 

1096 being observed in a fresh (ie. generated by the peer later than any 

1097 messages processed before state was lost here) message. This marks the 

1098 seen sequence number and all preceding it as invalid, and and all later 

1099 ones as valid.""" 

1100 self._index = seen 

1101 self._bitfield = 1 

1102 

1103 def is_valid(self, number): 

1104 if number < self._index: 

1105 return False 

1106 if number >= self._index + self._size: 

1107 return True 

1108 return (self._bitfield >> (number - self._index)) & 1 == 0 

1109 

1110 def strike_out(self, number): 

1111 if not self.is_valid(number): 

1112 raise ValueError("Sequence number is not valid any more and " 

1113 "thus can't be removed from the window") 

1114 overshoot = number - (self._index + self._size - 1) 

1115 if overshoot > 0: 

1116 self._index += overshoot 

1117 self._bitfield >>= overshoot 

1118 assert self.is_valid(number) 

1119 self._bitfield |= 1 << (number - self._index) 

1120 

1121 self.strike_out_callback() 

1122 

1123 def persist(self): 

1124 """Return a dict containing internal state which can be passed to init 

1125 to recreated the replay window.""" 

1126 

1127 return {'index': self._index, 'bitfield': self._bitfield} 

1128 

1129class FilesystemSecurityContext(CanProtect, CanUnprotect, SecurityContextUtils): 

1130 """Security context stored in a directory as distinct files containing 

1131 containing 

1132 

1133 * Master secret, master salt, sender and recipient ID, 

1134 optionally algorithm, the KDF hash function, and replay window size 

1135 (settings.json and secrets.json, where the latter is typically readable 

1136 only for the user) 

1137 * sequence numbers and replay windows (sequence.json, the only file the 

1138 process needs write access to) 

1139 

1140 The static parameters can all either be placed in settings.json or 

1141 secrets.json, but must not be present in both; the presence of either file 

1142 is sufficient. 

1143 

1144 .. warning:: 

1145 

1146 Security contexts must never be copied around and used after another 

1147 copy was used. They should only ever be moved, and if they are copied 

1148 (eg. as a part of a system backup), restored contexts must not be used 

1149 again; they need to be replaced with freshly created ones. 

1150 

1151 An additional file named `lock` is created to prevent the accidental use of 

1152 a context by to concurrent programs. 

1153 

1154 Note that the sequence number file is updated in an atomic fashion which 

1155 requires file creation privileges in the directory. If privilege separation 

1156 between settings/key changes and sequence number changes is desired, one 

1157 way to achieve that on Linux is giving the aiocoap process's user group 

1158 write permissions on the directory and setting the sticky bit on the 

1159 directory, thus forbidding the user to remove the settings/secret files not 

1160 owned by him. 

1161 

1162 Writes due to sent sequence numbers are reduced by applying a variation on 

1163 the mechanism of RFC8613 Appendix B.1.1 (incrementing the persisted sender 

1164 seqence number in steps of `k`). That value is automatically grown from 

1165 sequence_number_chunksize_start up to sequence_number_chunksize_limit. 

1166 At runtime, the receive window is not stored but kept indeterminate. In 

1167 case of an abnormal shutdown, the server uses the mechanism described in 

1168 Appendix B.1.2 to recover. 

1169 """ 

1170 

1171 class LoadError(ValueError): 

1172 """Exception raised with a descriptive message when trying to load a 

1173 faulty security context""" 

1174 

1175 def __init__( 

1176 self, 

1177 basedir, 

1178 sequence_number_chunksize_start=10, 

1179 sequence_number_chunksize_limit=10000, 

1180 ): 

1181 self.basedir = basedir 

1182 

1183 self.lockfile = filelock.FileLock(os.path.join(basedir, 'lock')) 

1184 # 0.001: Just fail if it can't be acquired 

1185 # See https://github.com/benediktschmitt/py-filelock/issues/57 

1186 try: 

1187 self.lockfile.acquire(timeout=0.001) 

1188 # see https://github.com/PyCQA/pycodestyle/issues/703 

1189 except: # noqa: E722 

1190 # No lock, no loading, no need to fail in __del__ 

1191 self.lockfile = None 

1192 raise 

1193 

1194 # Always enabled as committing to a file for every received request 

1195 # would be a terrible burden. 

1196 self.echo_recovery = secrets.token_bytes(8) 

1197 

1198 try: 

1199 self._load() 

1200 except KeyError as k: 

1201 raise self.LoadError("Configuration key missing: %s"%(k.args[0],)) 

1202 

1203 self.sequence_number_chunksize_start = sequence_number_chunksize_start 

1204 self.sequence_number_chunksize_limit = sequence_number_chunksize_limit 

1205 self.sequence_number_chunksize = sequence_number_chunksize_start 

1206 

1207 self.sequence_number_persisted = self.sender_sequence_number 

1208 

1209 def _load(self): 

1210 # doesn't check for KeyError on every occasion, relies on __init__ to 

1211 # catch that 

1212 

1213 data = {} 

1214 for readfile in ("secret.json", "settings.json"): 

1215 try: 

1216 with open(os.path.join(self.basedir, readfile)) as f: 

1217 filedata = json.load(f) 

1218 except FileNotFoundError: 

1219 continue 

1220 

1221 for (key, value) in filedata.items(): 

1222 if key.endswith('_hex'): 

1223 key = key[:-4] 

1224 value = binascii.unhexlify(value) 

1225 elif key.endswith('_ascii'): 

1226 key = key[:-6] 

1227 value = value.encode('ascii') 

1228 

1229 if key in data: 

1230 raise self.LoadError("Datum %r present in multiple input files at %r."%(key, self.basedir)) 

1231 

1232 data[key] = value 

1233 

1234 self.algorithm = algorithms[data.get('algorithm', DEFAULT_ALGORITHM)] 

1235 self.hashfun = hashfunctions[data.get('kdf-hashfun', DEFAULT_HASHFUNCTION)] 

1236 

1237 windowsize = data.get('window', DEFAULT_WINDOWSIZE) 

1238 if not isinstance(windowsize, int): 

1239 raise self.LoadError("Non-integer replay window") 

1240 

1241 self.sender_id = data['sender-id'] 

1242 self.recipient_id = data['recipient-id'] 

1243 

1244 if max(len(self.sender_id), len(self.recipient_id)) > self.algorithm.iv_bytes - 6: 

1245 raise self.LoadError("Sender or Recipient ID too long (maximum length %s for this algorithm)" % (self.algorithm.iv_bytes - 6)) 

1246 

1247 master_secret = data['secret'] 

1248 master_salt = data.get('salt', b'') 

1249 self.id_context = data.get('id-context', None) 

1250 

1251 self.derive_keys(master_salt, master_secret) 

1252 

1253 self.recipient_replay_window = ReplayWindow(windowsize, self._replay_window_changed) 

1254 try: 

1255 with open(os.path.join(self.basedir, 'sequence.json')) as f: 

1256 sequence = json.load(f) 

1257 except FileNotFoundError: 

1258 self.sender_sequence_number = 0 

1259 self.recipient_replay_window.initialize_empty() 

1260 self.replay_window_persisted = True 

1261 else: 

1262 self.sender_sequence_number = int(sequence['next-to-send']) 

1263 received = sequence['received'] 

1264 if received == "unknown": 

1265 # The replay window will stay uninitialized, which triggers 

1266 # Echo recovery 

1267 self.replay_window_persisted = False 

1268 else: 

1269 try: 

1270 self.recipient_replay_window.initialize_from_persisted(received) 

1271 except (ValueError, TypeError, KeyError): 

1272 # Not being particularly careful about what could go wrong: If 

1273 # someone tampers with the replay data, we're already in *big* 

1274 # trouble, of which I fail to see how it would become worse 

1275 # than a crash inside the application around "failure to 

1276 # right-shift a string" or that like; at worst it'd result in 

1277 # nonce reuse which tampering with the replay window file 

1278 # already does. 

1279 raise self.LoadError("Persisted replay window state was not understood") 

1280 self.replay_window_persisted = True 

1281 

1282 # This is called internally whenever a new sequence number is taken or 

1283 # crossed out from the window, and blocks a lot; B.1 mode mitigates that. 

1284 # 

1285 # Making it async and block in a threadpool would mitigate the blocking of 

1286 # other messages, but the more visible effect of this will be that no 

1287 # matter if sync or async, a reply will need to wait for a file sync 

1288 # operation to conclude. 

1289 def _store(self): 

1290 tmphand, tmpnam = tempfile.mkstemp(dir=self.basedir, 

1291 prefix='.sequence-', suffix='.json', text=True) 

1292 

1293 data = {"next-to-send": self.sequence_number_persisted} 

1294 if not self.replay_window_persisted: 

1295 data['received'] = 'unknown' 

1296 else: 

1297 data['received'] = self.recipient_replay_window.persist() 

1298 

1299 # Using io.open (instead os.fdopen) and binary / write with encode 

1300 # rather than dumps as that works even while the interpreter is 

1301 # shutting down. 

1302 # 

1303 # This can be relaxed when there is a defined shutdown sequence for 

1304 # security contexts that's triggered from the general context shutdown 

1305 # -- but right now, there isn't. 

1306 with io.open(tmphand, 'wb') as tmpfile: 

1307 tmpfile.write(json.dumps(data).encode('utf8')) 

1308 tmpfile.flush() 

1309 os.fsync(tmpfile.fileno()) 

1310 

1311 os.replace(tmpnam, os.path.join(self.basedir, 'sequence.json')) 

1312 

1313 def _replay_window_changed(self): 

1314 if self.replay_window_persisted: 

1315 # Just remove the sequence numbers once from the file 

1316 self.replay_window_persisted = False 

1317 self._store() 

1318 else: 

1319 self._store() 

1320 

1321 def post_seqnoincrease(self): 

1322 if self.sender_sequence_number > self.sequence_number_persisted: 

1323 self.sequence_number_persisted += self.sequence_number_chunksize 

1324 

1325 self.sequence_number_chunksize = min(self.sequence_number_chunksize * 2, self.sequence_number_chunksize_limit) 

1326 # FIXME: this blocks -- see https://github.com/chrysn/aiocoap/issues/178 

1327 self._store() 

1328 

1329 # The = case would only happen if someone deliberately sets all 

1330 # numbers to 1 to force persisting on every step 

1331 assert self.sender_sequence_number <= self.sequence_number_persisted 

1332 

1333 def _destroy(self): 

1334 """Release the lock file, and ensure tha he object has become 

1335 unusable. 

1336 

1337 If there is unpersisted state from B.1 operation, the actually used 

1338 number and replay window gets written back to the file to allow 

1339 resumption without wasting digits or round-trips. 

1340 """ 

1341 # FIXME: Arrange for a more controlled shutdown through the credentials 

1342 

1343 self.replay_window_persisted = True 

1344 self.sequence_number_persisted = self.sender_sequence_number 

1345 self._store() 

1346 

1347 del self.sender_key 

1348 del self.recipient_key 

1349 

1350 os.unlink(self.lockfile.lock_file) 

1351 self.lockfile.release() 

1352 

1353 self.lockfile = None 

1354 

1355 def __del__(self): 

1356 if self.lockfile is not None: 

1357 self._destroy() 

1358 

1359class GroupContext: 

1360 is_signing = True 

1361 external_aad_is_group = True 

1362 responses_send_kid = True 

1363 

1364 @abc.abstractproperty 

1365 def private_key(self): 

1366 """Private key used to sign outgoing messages. 

1367 

1368 Contexts not designed to send messages may raise a RuntimeError here; 

1369 that necessity may later go away if some more accurate class modelling 

1370 is found.""" 

1371 

1372 @abc.abstractproperty 

1373 def recipient_public_key(self): 

1374 """Public key used to verify incoming messages. 

1375 

1376 Contexts not designed to receive messages (because they'd have aspects 

1377 for that) may raise a RuntimeError here; that necessity may later go 

1378 away if some more accurate class modelling is found.""" 

1379 

1380class SimpleGroupContext(GroupContext, CanProtect, CanUnprotect, SecurityContextUtils): 

1381 """A context for an OSCORE group 

1382 

1383 This is a non-persistable version of a group context that does not support 

1384 any group manager or rekeying; it is set up statically at startup. 

1385 

1386 It is intended for experimentation and demos, but aims to be correct enough 

1387 to be usable securely. 

1388 """ 

1389 

1390 # set during initialization 

1391 private_key = None 

1392 

1393 def __init__(self, algorithm, hashfun, alg_countersign, group_id, master_secret, master_salt, sender_id, private_key, peers): 

1394 self.sender_id = sender_id 

1395 self.id_context = group_id 

1396 self.private_key = private_key 

1397 self.algorithm = algorithm 

1398 self.hashfun = hashfun 

1399 self.alg_countersign = alg_countersign 

1400 

1401 self.peers = peers.keys() 

1402 self.recipient_public_keys = peers 

1403 self.recipient_replay_windows = {} 

1404 for k in self.peers: 

1405 # no need to persist, the whole group is ephemeral 

1406 w = ReplayWindow(32, lambda: None) 

1407 w.initialize_empty() 

1408 self.recipient_replay_windows[k] = w 

1409 

1410 self.derive_keys(master_salt, master_secret) 

1411 self.sender_sequence_number = 0 

1412 

1413 def __repr__(self): 

1414 return "<%s with group %r sender_id %r and %d peers>" % ( 

1415 type(self).__name__, 

1416 self.id_context.hex(), 

1417 self.sender_id.hex(), 

1418 len(self.peers), 

1419 ) 

1420 

1421 @property 

1422 def recipient_public_key(self): 

1423 raise RuntimeError("Group context without key indication was used for verification") 

1424 

1425 def derive_keys(self, master_salt, master_secret): 

1426 # FIXME unify with parent? 

1427 

1428 self.sender_key = self._kdf(master_salt, master_secret, self.sender_id, 'Key') 

1429 self.recipient_keys = {recipient_id: self._kdf(master_salt, master_secret, recipient_id, 'Key') for recipient_id in self.peers} 

1430 

1431 self.common_iv = self._kdf(master_salt, master_secret, b"", 'IV') 

1432 

1433 def post_seqnoincrease(self): 

1434 """No-op because it's ephemeral""" 

1435 

1436 def context_from_response(self, unprotected_bag) -> CanUnprotect: 

1437 # sender ID *needs to be* here -- if this were a pairwise request, it 

1438 # would not run through here 

1439 try: 

1440 sender_kid = unprotected_bag[COSE_KID] 

1441 except KeyError: 

1442 raise DecodeError("Group server failed to send own sender KID") 

1443 

1444 if COSE_COUNTERSINGATURE0 in unprotected_bag: 

1445 return _GroupContextAspect(self, sender_kid) 

1446 else: 

1447 return _PairwiseContextAspect(self, sender_kid) 

1448 

1449 def get_oscore_context_for(self, unprotected): 

1450 if unprotected.get(COSE_KID_CONTEXT, None) != self.id_context: 

1451 return None 

1452 

1453 kid = unprotected.get(COSE_KID, None) 

1454 if kid in self.peers: 

1455 if COSE_COUNTERSINGATURE0 in unprotected: 

1456 return _GroupContextAspect(self, kid) 

1457 elif self.recipient_public_keys[kid] is DETERMINISTIC_KEY: 

1458 return _DeterministicUnprotectProtoAspect(self, kid) 

1459 else: 

1460 return _PairwiseContextAspect(self, kid) 

1461 

1462 # yet to stabilize... 

1463 

1464 def pairwise_for(self, recipient_id): 

1465 return _PairwiseContextAspect(self, recipient_id) 

1466 

1467 def for_sending_deterministic_requests(self, deterministic_id, target_server: Optional[bytes]): 

1468 return _DeterministicProtectProtoAspect(self, deterministic_id, target_server) 

1469 

1470class _GroupContextAspect(GroupContext, CanUnprotect): 

1471 """The concrete context this host has with a particular peer 

1472 

1473 As all actual data is stored in the underlying groupcontext, this acts as 

1474 an accessor to that object (which picks the right recipient key). 

1475 

1476 This accessor is for receiving messages in group mode from a particular 

1477 peer; it does not send (and turns into a pairwise context through 

1478 context_for_response before it comes to that). 

1479 """ 

1480 

1481 def __init__(self, groupcontext, recipient_id): 

1482 self.groupcontext = groupcontext 

1483 self.recipient_id = recipient_id 

1484 

1485 def __repr__(self): 

1486 return "<%s inside %r with the peer %r>" % ( 

1487 type(self).__name__, 

1488 self.groupcontext, 

1489 self.recipient_id.hex(), 

1490 ) 

1491 

1492 id_context = property(lambda self: self.groupcontext.id_context) 

1493 algorithm = property(lambda self: self.groupcontext.algorithm) 

1494 alg_countersign = property(lambda self: self.groupcontext.alg_countersign) 

1495 common_iv = property(lambda self: self.groupcontext.common_iv) 

1496 

1497 recipient_key = property(lambda self: self.groupcontext.recipient_keys[self.recipient_id]) 

1498 recipient_public_key = property(lambda self: self.groupcontext.recipient_public_keys[self.recipient_id]) 

1499 recipient_replay_window = property(lambda self: self.groupcontext.recipient_replay_windows[self.recipient_id]) 

1500 

1501 def context_for_response(self): 

1502 return self.groupcontext.pairwise_for(self.recipient_id) 

1503 

1504class _PairwiseContextAspect(GroupContext, CanProtect, CanUnprotect, SecurityContextUtils): 

1505 is_signing = False 

1506 

1507 def __init__(self, groupcontext, recipient_id): 

1508 self.groupcontext = groupcontext 

1509 self.recipient_id = recipient_id 

1510 

1511 shared_secret = self.alg_countersign.staticstatic( 

1512 self.groupcontext.private_key, 

1513 self.groupcontext.recipient_public_keys[recipient_id] 

1514 ) 

1515 

1516 self.sender_key = self._kdf(self.groupcontext.sender_key, shared_secret, self.groupcontext.sender_id, 'Key') 

1517 self.recipient_key = self._kdf(self.groupcontext.recipient_keys[recipient_id], shared_secret, self.recipient_id, 'Key') 

1518 

1519 def __repr__(self): 

1520 return "<%s based on %r with the peer %r>" % ( 

1521 type(self).__name__, 

1522 self.groupcontext, 

1523 self.recipient_id.hex(), 

1524 ) 

1525 

1526 # FIXME: actually, only to be sent in requests 

1527 id_context = property(lambda self: self.groupcontext.id_context) 

1528 algorithm = property(lambda self: self.groupcontext.algorithm) 

1529 hashfun = property(lambda self: self.groupcontext.hashfun) 

1530 alg_countersign = property(lambda self: self.groupcontext.alg_countersign) 

1531 common_iv = property(lambda self: self.groupcontext.common_iv) 

1532 sender_id = property(lambda self: self.groupcontext.sender_id) 

1533 

1534 recipient_replay_window = property(lambda self: self.groupcontext.recipient_replay_windows[self.recipient_id]) 

1535 

1536 # Set at initialization 

1537 recipient_key = None 

1538 sender_key = None 

1539 

1540 @property 

1541 def sender_sequence_number(self): 

1542 return self.groupcontext.sender_sequence_number 

1543 @sender_sequence_number.setter 

1544 def sender_sequence_number(self, new): 

1545 self.groupcontext.sender_sequence_number = new 

1546 

1547 def post_seqnoincrease(self): 

1548 self.groupcontext.post_seqnoincrease() 

1549 

1550 # same here -- not needed because not signing 

1551 private_key = property(post_seqnoincrease) 

1552 recipient_public_key = property(post_seqnoincrease) 

1553 

1554 def context_from_response(self, unprotected_bag) -> CanUnprotect: 

1555 if unprotected_bag.get(COSE_KID, self.recipient_id) != self.recipient_id: 

1556 raise DecodeError("Response coming from a different server than requested, not attempting to decrypt") 

1557 

1558 if COSE_COUNTERSINGATURE0 in unprotected_bag: 

1559 # It'd be an odd thing to do, but it's source verified, so the 

1560 # server hopefully has reasons to make this readable to other group 

1561 # members. 

1562 return _GroupContextAspect(self.groupcontext, self.recipient_id) 

1563 else: 

1564 return self 

1565 

1566class _DeterministicProtectProtoAspect(CanProtect, SecurityContextUtils): 

1567 """This implements the sending side of Deterministic Requests. 

1568 

1569 While simialr to a _PairwiseContextAspect, it only derives the key at 

1570 protection time, as the plain text is hashed into the key.""" 

1571 

1572 deterministic_hashfun = hashes.SHA256() 

1573 

1574 def __init__(self, groupcontext, sender_id, target_server: Optional[bytes]): 

1575 self.groupcontext = groupcontext 

1576 self.sender_id = sender_id 

1577 self.target_server = target_server 

1578 

1579 def __repr__(self): 

1580 return "<%s based on %r with the sender ID %r%s>" % ( 

1581 type(self).__name__, 

1582 self.groupcontext, 

1583 self.sender_id.hex(), 

1584 "limited to responses from %s" % self.target_server if self.target_server is not None else "" 

1585 ) 

1586 

1587 def new_sequence_number(self): 

1588 return 0 

1589 

1590 def post_seqnoincrease(self): 

1591 pass 

1592 

1593 def context_from_response(self, unprotected_bag): 

1594 if self.target_server is None: 

1595 if COSE_KID not in unprotected_bag: 

1596 raise DecodeError("Server did not send a KID and no particular one was addressed") 

1597 else: 

1598 if unprotected_bag.get(COSE_KID, self.target_server) != self.target_server: 

1599 raise DecodeError("Response coming from a different server than requested, not attempting to decrypt") 

1600 

1601 if COSE_COUNTERSINGATURE0 not in unprotected_bag: 

1602 # Could just as well pass and later barf when the group context doesn't find a signature 

1603 raise DecodeError("Response to deterministic request came from unsecure pairwise context") 

1604 

1605 return _GroupContextAspect(self.groupcontext, unprotected_bag.get(COSE_KID, self.target_server)) 

1606 

1607 def _get_sender_key(self, outer_message, aad, plaintext, request_id): 

1608 if outer_message.code.is_response(): 

1609 raise RuntimeError("Deterministic contexts shouldn't protect responses") 

1610 

1611 basekey = self.groupcontext.recipient_keys[self.sender_id] 

1612 

1613 h = hashes.Hash(self.deterministic_hashfun) 

1614 h.update(basekey) 

1615 h.update(aad) 

1616 h.update(plaintext) 

1617 request_hash = h.finalize() 

1618 

1619 outer_message.opt.request_hash = request_hash 

1620 outer_message.code = FETCH 

1621 

1622 # By this time, the AADs have all been calculated already; setting this 

1623 # for the benefit of the response parsing later 

1624 request_id.request_hash = request_hash 

1625 # FIXME I don't think this ever comes to bear but want to be sure 

1626 # before removing this line (this should only be client-side) 

1627 request_id.can_reuse_nonce = False 

1628 # FIXME: we're still sending a h'00' PIV. Not wrong, just a wasted byte. 

1629 

1630 return self._kdf(basekey, request_hash, self.sender_id, 'Key') 

1631 

1632 external_aad_is_group = True 

1633 

1634 # details needed for various operations, especially eAAD generation 

1635 algorithm = property(lambda self: self.groupcontext.algorithm) 

1636 hashfun = property(lambda self: self.groupcontext.hashfun) 

1637 common_iv = property(lambda self: self.groupcontext.common_iv) 

1638 id_context = property(lambda self: self.groupcontext.id_context) 

1639 alg_countersign = property(lambda self: self.groupcontext.alg_countersign) 

1640 

1641class _DeterministicUnprotectProtoAspect(CanUnprotect, SecurityContextUtils): 

1642 """This implements the sending side of Deterministic Requests. 

1643 

1644 While simialr to a _PairwiseContextAspect, it only derives the key at 

1645 unprotection time, based on information given as Request-Hash.""" 

1646 

1647 # Unless None, this is the value by which the running process recognizes 

1648 # that the second phase of a B.1.2 replay window recovery Echo option comes 

1649 # from the current process, and thus its sequence number is fresh 

1650 echo_recovery = None 

1651 

1652 deterministic_hashfun = hashes.SHA256() 

1653 

1654 class ZeroIsAlwaysValid: 

1655 """Special-purpose replay window that accepts 0 indefinitely""" 

1656 

1657 def is_initialized(self): 

1658 return True 

1659 

1660 def is_valid(self, number): 

1661 # No particular reason to be lax here 

1662 return number == 0 

1663 

1664 def strike_out(self, number): 

1665 # FIXME: I'd rather indicate here that it's a potential replay, have the 

1666 # request_id.can_reuse_nonce = False 

1667 # set here rather than in _post_decrypt_checks, and thus also get 

1668 # the check for whether it's a safe method 

1669 pass 

1670 

1671 def persist(self): 

1672 pass 

1673 

1674 def __init__(self, groupcontext, recipient_id): 

1675 self.groupcontext = groupcontext 

1676 self.recipient_id = recipient_id 

1677 

1678 self.recipient_replay_window = self.ZeroIsAlwaysValid() 

1679 

1680 def __repr__(self): 

1681 return "<%s based on %r with the recipient ID %r>" % ( 

1682 type(self).__name__, 

1683 self.groupcontext, 

1684 self.recipient_id.hex(), 

1685 ) 

1686 

1687 def context_for_response(self): 

1688 return self.groupcontext 

1689 

1690 def _get_recipient_key(self, protected_message): 

1691 return self._kdf(self.groupcontext.recipient_keys[self.recipient_id], protected_message.opt.request_hash, self.recipient_id, 'Key') 

1692 

1693 def _post_decrypt_checks(self, aad, plaintext, protected_message, request_id): 

1694 if plaintext[0] not in (GET, FETCH): # FIXME: "is safe" 

1695 # FIXME: accept but return inner Unauthorized. (Raising Unauthorized 

1696 # here would just create an unprotected Unauthorized, which is not 

1697 # what's spec'd for here) 

1698 raise ProtectionInvalid("Request was not safe") 

1699 

1700 basekey = self.groupcontext.recipient_keys[self.recipient_id] 

1701 

1702 h = hashes.Hash(self.deterministic_hashfun) 

1703 h.update(basekey) 

1704 h.update(aad) 

1705 h.update(plaintext) 

1706 request_hash = h.finalize() 

1707 

1708 if request_hash != protected_message.opt.request_hash: 

1709 raise ProtectionInvalid("Client's hash of the plaintext diverges from the actual request hash") 

1710 

1711 # This is intended for the protection of the response, and the 

1712 # later use in signature in the unprotect function is not happening 

1713 # here anyway, neither is the later use for Echo requests 

1714 request_id.request_hash = request_hash 

1715 request_id.can_reuse_nonce = False 

1716 

1717 external_aad_is_group = True 

1718 

1719 # details needed for various operations, especially eAAD generation 

1720 algorithm = property(lambda self: self.groupcontext.algorithm) 

1721 hashfun = property(lambda self: self.groupcontext.hashfun) 

1722 common_iv = property(lambda self: self.groupcontext.common_iv) 

1723 id_context = property(lambda self: self.groupcontext.id_context) 

1724 alg_countersign = property(lambda self: self.groupcontext.alg_countersign) 

1725 

1726def verify_start(message): 

1727 """Extract the unprotected COSE options from a 

1728 message for the verifier to then pick a security context to actually verify 

1729 the message. (Future versions may also report fields from both unprotected 

1730 and protected, if the protected bag is ever used with OSCORE.). 

1731 

1732 Call this only requests; for responses, you'll have to know the security 

1733 context anyway, and there is usually no information to be gained.""" 

1734 

1735 _, _, unprotected, _ = CanUnprotect._extract_encrypted0(message) 

1736 

1737 return unprotected