Coverage for aiocoap/oscore.py: 85%

961 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-16 16:09 +0000

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

2# 

3# SPDX-License-Identifier: MIT 

4 

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

6 

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

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

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

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

11 

12from __future__ import annotations 

13 

14from collections import namedtuple 

15import io 

16import json 

17import binascii 

18import os 

19import os.path 

20import tempfile 

21import abc 

22from typing import Optional 

23import secrets 

24import warnings 

25 

26from aiocoap.message import Message 

27from aiocoap.util import cryptography_additions, deprecation_getattr 

28from aiocoap.numbers import GET, POST, FETCH, CHANGED, UNAUTHORIZED, CONTENT 

29from aiocoap import error 

30 

31from cryptography.hazmat.primitives.ciphers import aead 

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

33from cryptography.hazmat.primitives import hashes 

34import cryptography.hazmat.backends 

35import cryptography.exceptions 

36from cryptography.hazmat.primitives import asymmetric, serialization 

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

38 

39import cbor2 as cbor 

40 

41import filelock 

42 

43MAX_SEQNO = 2**40 - 1 

44 

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

46COSE_KID = 4 

47COSE_PIV = 6 

48COSE_KID_CONTEXT = 10 

49# from RFC9338 

50COSE_COUNTERSIGNATURE0 = 12 

51# from draft-ietf-lake-edhoc-19, guessing the value 

52COSE_KCCS = 13131313 

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 

60# While the original values were simple enough to be used in literals, starting 

61# with oscore-groupcomm we're using more compact values 

62 

63INFO_TYPE_KEYSTREAM_REQUEST = True 

64INFO_TYPE_KEYSTREAM_RESPONSE = False 

65 

66class CodeStyle(namedtuple("_CodeStyle", ("request", "response"))): 

67 @classmethod 

68 def from_request(cls, request) -> CodeStyle: 

69 if request == FETCH: 

70 return cls.FETCH_CONTENT 

71 elif request == POST: 

72 return cls.POST_CHANGED 

73 else: 

74 raise ValueError("Invalid request code %r" % request) 

75 

76CodeStyle.FETCH_CONTENT = CodeStyle(FETCH, CONTENT) 

77CodeStyle.POST_CHANGED = CodeStyle(POST, CHANGED) 

78 

79 

80class DeterministicKey: 

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

82 is available because it is the Deterministic Client (see 

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

84 

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

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

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

88 """ 

89DETERMINISTIC_KEY = DeterministicKey() 

90del DeterministicKey 

91 

92class NotAProtectedMessage(error.Error, ValueError): 

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

94 

95 def __init__(self, message, plain_message): 

96 super().__init__(message) 

97 self.plain_message = plain_message 

98 

99class ProtectionInvalid(error.Error, ValueError): 

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

101 

102class DecodeError(ProtectionInvalid): 

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

104 

105class ReplayError(ProtectionInvalid): 

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

107 

108class ReplayErrorWithEcho(ProtectionInvalid, error.RenderableError): 

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

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

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

112 assisting in replay window recovery""" 

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

114 self.secctx = secctx 

115 self.request_id = request_id 

116 self.echo = echo 

117 

118 def to_message(self): 

119 inner = Message( 

120 code=UNAUTHORIZED, 

121 echo=self.echo, 

122 ) 

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

124 return outer 

125 

126class ContextUnavailable(error.Error, ValueError): 

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

128 protecting or unprotecting a message""" 

129 

130class RequestIdentifiers: 

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

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

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

134 around the request's partial IV. 

135 

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

137 just pass them around. 

138 """ 

139 def __init__(self, kid, partial_iv, nonce, can_reuse_nonce, request_code): 

140 self.kid = kid 

141 self.partial_iv = partial_iv 

142 self.nonce = nonce 

143 self.can_reuse_nonce = can_reuse_nonce 

144 self.code_style = CodeStyle.from_request(request_code) 

145 

146 self.request_hash = None 

147 

148 def get_reusable_nonce_and_piv(self): 

149 """Return the nonce and the partial IV if can_reuse_nonce is True, and 

150 set can_reuse_nonce to False.""" 

151 

152 if self.can_reuse_nonce: 

153 self.can_reuse_nonce = False 

154 return (self.nonce, self.partial_iv) 

155 else: 

156 return (None, None) 

157 

158def _xor_bytes(a, b): 

159 assert len(a) == len(b) 

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

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

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

163 

164class AeadAlgorithm(metaclass=abc.ABCMeta): 

165 @abc.abstractmethod 

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

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

168 

169 @abc.abstractmethod 

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

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

172 stemming from untrusted data.""" 

173 

174 @staticmethod 

175 def _build_encrypt0_structure(protected, external_aad): 

176 assert protected == {} 

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

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

179 

180 return cbor.dumps(enc_structure) 

181 

182class AES_CCM(AeadAlgorithm, metaclass=abc.ABCMeta): 

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

184 

185 @classmethod 

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

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

188 

189 @classmethod 

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

191 try: 

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

193 except cryptography.exceptions.InvalidTag: 

194 raise ProtectionInvalid("Tag invalid") 

195 

196class AES_CCM_16_64_128(AES_CCM): 

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

198 value = 10 

199 key_bytes = 16 # 128-bit key 

200 tag_bytes = 8 # 64-bit tag 

201 iv_bytes = 13 # 13-byte nonce 

202 

203class AES_CCM_16_64_256(AES_CCM): 

204 # from RFC8152 

205 value = 11 

206 key_bytes = 32 # 256-bit key 

207 tag_bytes = 8 # 64-bit tag 

208 iv_bytes = 13 # 13-byte nonce 

209 

210class AES_CCM_64_64_128(AES_CCM): 

211 # from RFC8152 

212 value = 12 

213 key_bytes = 16 # 128-bit key 

214 tag_bytes = 8 # 64-bit tag 

215 iv_bytes = 7 # 7-byte nonce 

216 

217class AES_CCM_64_64_256(AES_CCM): 

218 # from RFC8152 

219 value = 13 

220 key_bytes = 32 # 256-bit key 

221 tag_bytes = 8 # 64-bit tag 

222 iv_bytes = 7 # 7-byte nonce 

223 

224class AES_CCM_16_128_128(AES_CCM): 

225 # from RFC8152 

226 value = 30 

227 key_bytes = 16 # 128-bit key 

228 tag_bytes = 16 # 128-bit tag 

229 iv_bytes = 13 # 13-byte nonce 

230 

231class AES_CCM_16_128_256(AES_CCM): 

232 # from RFC8152 

233 value = 31 

234 key_bytes = 32 # 256-bit key 

235 tag_bytes = 16 # 128-bit tag 

236 iv_bytes = 13 # 13-byte nonce 

237 

238class AES_CCM_64_128_128(AES_CCM): 

239 # from RFC8152 

240 value = 32 

241 key_bytes = 16 # 128-bit key 

242 tag_bytes = 16 # 128-bit tag 

243 iv_bytes = 7 # 7-byte nonce 

244 

245class AES_CCM_64_128_256(AES_CCM): 

246 # from RFC8152 

247 value = 33 

248 key_bytes = 32 # 256-bit key 

249 tag_bytes = 16 # 128-bit tag 

250 iv_bytes = 7 # 7-byte nonce 

251 

252 

253class AES_GCM(AeadAlgorithm, metaclass=abc.ABCMeta): 

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

255 

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

257 

258 @classmethod 

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

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

261 

262 @classmethod 

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

264 try: 

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

266 except cryptography.exceptions.InvalidTag: 

267 raise ProtectionInvalid("Tag invalid") 

268 

269class A128GCM(AES_GCM): 

270 # from RFC8152 

271 value = 1 

272 key_bytes = 16 # 128-bit key 

273 tag_bytes = 16 # 128-bit tag 

274 

275class A192GCM(AES_GCM): 

276 # from RFC8152 

277 value = 2 

278 key_bytes = 24 # 192-bit key 

279 tag_bytes = 16 # 128-bit tag 

280 

281class A256GCM(AES_GCM): 

282 # from RFC8152 

283 value = 3 

284 key_bytes = 32 # 256-bit key 

285 tag_bytes = 16 # 128-bit tag 

286 

287class ChaCha20Poly1305(AeadAlgorithm): 

288 # from RFC8152 

289 value = 24 

290 key_bytes = 32 # 256-bit key 

291 tag_bytes = 16 # 128-bit tag 

292 iv_bytes = 12 # 96-bit nonce 

293 

294 @classmethod 

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

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

297 

298 @classmethod 

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

300 try: 

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

302 except cryptography.exceptions.InvalidTag: 

303 raise ProtectionInvalid("Tag invalid") 

304 

305class AlgorithmCountersign(metaclass=abc.ABCMeta): 

306 """A fully parameterized COSE countersign algorithm 

307 

308 An instance is able to provide all the alg_signature, par_countersign and 

309 par_countersign_key parameters taht go into the Group OSCORE algorithms 

310 field. 

311 """ 

312 @abc.abstractmethod 

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

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

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

316 

317 @abc.abstractmethod 

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

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

320 

321 @abc.abstractmethod 

322 def generate(self): 

323 """Return a usable private key""" 

324 

325 @abc.abstractmethod 

326 def public_from_private(self, private_key): 

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

328 

329 @staticmethod 

330 def _build_countersign_structure(body, external_aad): 

331 countersign_structure = [ 

332 "CounterSignature0", 

333 b"", 

334 b"", 

335 external_aad, 

336 body 

337 ] 

338 tobesigned = cbor.dumps(countersign_structure) 

339 return tobesigned 

340 

341 @property 

342 @abc.abstractproperty 

343 def signature_length(self): 

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

345 

346 @property 

347 @abc.abstractproperty 

348 def curve_number(self): 

349 """Registered curve number used with this algorithm. 

350 

351 Only used for verification of credentials' details""" 

352 

353class AlgorithmStaticStatic(metaclass=abc.ABCMeta): 

354 @abc.abstractmethod 

355 def staticstatic(self, private_key, public_key): 

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

357 

358class Ed25519(AlgorithmCountersign): 

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

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

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

362 

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

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

365 try: 

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

367 except cryptography.exceptions.InvalidSignature: 

368 raise ProtectionInvalid("Signature mismatch") 

369 

370 def generate(self): 

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

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

373 # current algorithm interfaces did not insist on passing the 

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

375 # efficient. 

376 return key.private_bytes( 

377 encoding=serialization.Encoding.Raw, 

378 format=serialization.PrivateFormat.Raw, 

379 encryption_algorithm=serialization.NoEncryption(), 

380 ) 

381 

382 def public_from_private(self, private_key): 

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

384 public_key = private_key.public_key() 

385 return public_key.public_bytes( 

386 encoding=serialization.Encoding.Raw, 

387 format=serialization.PublicFormat.Raw, 

388 ) 

389 

390 value = -8 

391 curve_number = 6 

392 

393 signature_length = 64 

394 

395class EcdhSsHkdf256(AlgorithmStaticStatic): 

396 # FIXME: This class uses the Edwards keys as private and public keys, and 

397 # not the converted ones. This will be problematic if pairwise-only 

398 # contexts are to be set up. 

399 

400 value = -27 # FIXME: or -28? (see shepherd review) 

401 

402 # FIXME these two will be different when using the Montgomery keys directly 

403 

404 # This one will only be used when establishing and distributing pairwise-only keys 

405 generate = Ed25519.generate 

406 public_from_private = Ed25519.public_from_private 

407 

408 def staticstatic(self, private_key, public_key): 

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

410 private_key = cryptography_additions.sk_to_curve25519(private_key) 

411 

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

413 public_key = cryptography_additions.pk_to_curve25519(public_key) 

414 

415 return private_key.exchange(public_key) 

416 

417class ECDSA_SHA256_P256(AlgorithmCountersign, AlgorithmStaticStatic): 

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

419 # we're just passing Python objects around 

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

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

422 return asymmetric.ec.EllipticCurvePublicNumbers( 

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

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

425 asymmetric.ec.SECP256R1() 

426 ).public_key() 

427 

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

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

430 private_numbers = asymmetric.ec.EllipticCurvePrivateNumbers( 

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

432 public_numbers) 

433 return private_numbers.private_key() 

434 

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

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

437 (r, s) = decode_dss_signature(der_signature) 

438 

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

440 

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

442 r = signature[:32] 

443 s = signature[32:] 

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

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

446 der_signature = encode_dss_signature(r, s) 

447 try: 

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

449 except cryptography.exceptions.InvalidSignature: 

450 raise ProtectionInvalid("Signature mismatch") 

451 

452 def generate(self): 

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

454 

455 def public_from_private(self, private_key): 

456 return private_key.public_key() 

457 

458 def staticstatic(self, private_key, public_key): 

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

460 

461 value = -7 # FIXME: when used as a static-static algorithm, does this become -27? see shepherd review. 

462 curve_number = 1 

463 

464 signature_length = 64 

465 

466algorithms = { 

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

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

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

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

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

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

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

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

475 'ChaCha20/Poly1305': ChaCha20Poly1305(), 

476 'A128GCM': A128GCM(), 

477 'A192GCM': A192GCM(), 

478 'A256GCM': A256GCM(), 

479 } 

480 

481# algorithms with full parameter set 

482algorithms_countersign = { 

483 # maybe needs a different name... 

484 'EdDSA on Ed25519': Ed25519(), 

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

486 } 

487 

488algorithms_staticstatic = { 

489 'ECDH-SS + HKDF-256': EcdhSsHkdf256(), 

490 } 

491 

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

493 

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

495hashfunctions = { 

496 'sha256': hashes.SHA256(), 

497 'sha384': hashes.SHA384(), 

498 'sha512': hashes.SHA512(), 

499 } 

500 

501DEFAULT_HASHFUNCTION = 'sha256' 

502 

503DEFAULT_WINDOWSIZE = 32 

504 

505class BaseSecurityContext: 

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

507 # rather than the regular OSCORE AADs. (Ie. alg_signature_enc etc are added to 

508 # the algorithms, and request_kid_context, OSCORE_option, sender_auth_cred and 

509 # gm_cred are added). 

510 # 

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

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

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

514 # signing and signature verification purposes). 

515 external_aad_is_group = False 

516 

517 # Authentication information carried with this security context; managed 

518 # externally by whatever creates the security context. 

519 authenticated_claims = [] 

520 

521 # AEAD algorithm. This may only be None in group contexts that do not use pairwise mode. 

522 alg_aead: Optional[AeadAlgorithm] 

523 

524 @property 

525 def algorithm(self): 

526 warnings.warn("Property was renamed to 'alg_aead'", DeprecationWarning, stacklevel=2) 

527 return self.alg_aead 

528 @algorithm.setter 

529 def algorithm(self, value): 

530 warnings.warn("Property was renamed to 'alg_aead'", DeprecationWarning, stacklevel=2) 

531 self.alg_aead = value 

532 

533 def _construct_nonce(self, partial_iv_short, piv_generator_id): 

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

535 

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

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

538 

539 components = s + \ 

540 pad_id + \ 

541 piv_generator_id + \ 

542 pad_piv + \ 

543 partial_iv_short 

544 

545 nonce = _xor_bytes(self.common_iv, components) 

546 

547 return nonce 

548 

549 def _extract_external_aad(self, message, request_id, local_is_sender: bool) -> bytes: 

550 """Build the serialized external AAD from information in the message 

551 and the request_id. 

552 

553 Information about whether the local context is the sender of the 

554 message is only relevant to group contexts, where it influences whose 

555 authentication credentials are placed in the AAD. 

556 """ 

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

558 # 

559 # the_options = pick some of(message) 

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

561 

562 oscore_version = 1 

563 class_i_options = b"" 

564 if request_id.request_hash is not None: 

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

566 

567 algorithms = [self.alg_aead.value] 

568 if self.external_aad_is_group: 

569 algorithms.append(self.alg_signature_enc.value) 

570 algorithms.append(self.alg_signature.value) 

571 algorithms.append(self.alg_pairwise_key_agreement.value) 

572 

573 external_aad = [ 

574 oscore_version, 

575 algorithms, 

576 request_id.kid, 

577 request_id.partial_iv, 

578 class_i_options, 

579 ] 

580 

581 if self.external_aad_is_group: 

582 # FIXME: We may need to carry this over in the request_id when 

583 # observation span group rekeyings 

584 external_aad.append(self.id_context) 

585 

586 assert message.opt.object_security is not None 

587 external_aad.append(message.opt.object_security) 

588 

589 if local_is_sender: 

590 external_aad.append(self.sender_auth_cred) 

591 else: 

592 external_aad.append(self.recipient_auth_cred) 

593 external_aad.append(self.group_manager_cred) 

594 

595 external_aad = cbor.dumps(external_aad) 

596 

597 return external_aad 

598 

599# FIXME pull interface components from SecurityContext up here 

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

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

602 # alg_signature attribute if this is true 

603 is_signing = False 

604 

605 # Send the KID when protecting responses 

606 # 

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

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

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

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

611 # context they came in on). 

612 responses_send_kid = False 

613 

614 @staticmethod 

615 def _compress(protected, unprotected, ciphertext): 

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

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

618 message body""" 

619 

620 if protected: 

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

622 

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

624 if len(piv) > COMPRESSION_BITS_N: 

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

626 

627 firstbyte = len(piv) 

628 if COSE_KID in unprotected: 

629 firstbyte |= COMPRESSION_BIT_K 

630 kid_data = unprotected.pop(COSE_KID) 

631 else: 

632 kid_data = b"" 

633 

634 if COSE_KID_CONTEXT in unprotected: 

635 firstbyte |= COMPRESSION_BIT_H 

636 kid_context = unprotected.pop(COSE_KID_CONTEXT) 

637 s = len(kid_context) 

638 if s > 255: 

639 raise ValueError("KID Context too long") 

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

641 else: 

642 s_kid_context = b"" 

643 

644 if COSE_COUNTERSIGNATURE0 in unprotected: 

645 firstbyte |= COMPRESSION_BIT_G 

646 

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

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

649 # signing. 

650 ciphertext += unprotected.pop(COSE_COUNTERSIGNATURE0) 

651 

652 if unprotected: 

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

654 

655 if firstbyte: 

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

657 else: 

658 option = b"" 

659 

660 return (option, ciphertext) 

661 

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

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

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

665 OSCOAP. 

666 

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

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

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

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

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

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

673 """ 

674 

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

676 

677 outer_message, plaintext = self._split_message(message, request_id) 

678 

679 protected = {} 

680 nonce = None 

681 unprotected = {} 

682 if request_id is not None: 

683 nonce, partial_iv_short = request_id.get_reusable_nonce_and_piv() 

684 if nonce is not None: 

685 partial_iv_generated_by = request_id.kid 

686 

687 if nonce is None: 

688 nonce, partial_iv_short = self._build_new_nonce() 

689 partial_iv_generated_by = self.sender_id 

690 

691 unprotected[COSE_PIV] = partial_iv_short 

692 

693 if message.code.is_request(): 

694 unprotected[COSE_KID] = self.sender_id 

695 

696 request_id = RequestIdentifiers(self.sender_id, partial_iv_short, nonce, can_reuse_nonce=None, request_code=outer_message.code) 

697 

698 if kid_context is True: 

699 if self.id_context is not None: 

700 unprotected[COSE_KID_CONTEXT] = self.id_context 

701 elif kid_context is not False: 

702 unprotected[COSE_KID_CONTEXT] = kid_context 

703 else: 

704 if self.responses_send_kid: 

705 unprotected[COSE_KID] = self.sender_id 

706 

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

708 if self.is_signing: 

709 unprotected[COSE_COUNTERSIGNATURE0] = b"" 

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

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

712 

713 outer_message.opt.object_security = option_data 

714 

715 external_aad = self._extract_external_aad(outer_message, request_id, local_is_sender=True) 

716 

717 aad = self.alg_aead._build_encrypt0_structure(protected, external_aad) 

718 

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

720 

721 ciphertext = self.alg_aead.encrypt(plaintext, aad, key, nonce) 

722 

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

724 

725 if self.is_signing: 

726 signature = self.alg_signature.sign(payload, external_aad, self.private_key) 

727 keystream = self._kdf_for_keystreams( 

728 partial_iv_generated_by, 

729 partial_iv_short, 

730 self.group_encryption_key, 

731 self.sender_id, 

732 INFO_TYPE_KEYSTREAM_REQUEST if message.code.is_request() else INFO_TYPE_KEYSTREAM_RESPONSE, 

733 ) 

734 encrypted_signature = _xor_bytes(signature, keystream) 

735 payload += encrypted_signature 

736 outer_message.payload = payload 

737 

738 # FIXME go through options section 

739 

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

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

742 # `if` and returning None? 

743 return outer_message, request_id 

744 

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

746 """Customization hook of the protect function 

747 

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

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

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

751 unprotect the response.""" 

752 return self.sender_key 

753 

754 def _split_message(self, message, request_id): 

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

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

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

758 options and the payload. 

759 

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

761 

762 if message.code.is_request(): 

763 outer_host = message.opt.uri_host 

764 proxy_uri = message.opt.proxy_uri 

765 

766 inner_message = message.copy( 

767 uri_host=None, 

768 uri_port=None, 

769 proxy_uri=None, 

770 proxy_scheme=None, 

771 ) 

772 inner_message.remote = None 

773 

774 if proxy_uri is not None: 

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

776 # components; extract, preserve and clear them. 

777 inner_message.set_request_uri(proxy_uri, set_uri_host=False) 

778 if inner_message.opt.proxy_uri is not None: 

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

780 outer_uri = inner_message.remote.uri_base 

781 inner_message.remote = None 

782 inner_message.opt.proxy_scheme = None 

783 

784 if message.opt.observe is None: 

785 outer_code = POST 

786 else: 

787 outer_code = FETCH 

788 else: 

789 outer_host = None 

790 proxy_uri = None 

791 

792 inner_message = message.copy() 

793 

794 outer_code = request_id.code_style.response 

795 

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

797 outer_message = Message(code=outer_code, 

798 uri_host=outer_host, 

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

800 ) 

801 if proxy_uri is not None: 

802 outer_message.set_request_uri(outer_uri) 

803 

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

805 if inner_message.payload: 

806 plaintext += bytes([0xFF]) 

807 plaintext += inner_message.payload 

808 

809 return outer_message, plaintext 

810 

811 def _build_new_nonce(self): 

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

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

814 as well.""" 

815 seqno = self.new_sequence_number() 

816 

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

818 

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

820 

821 # sequence number handling 

822 

823 def new_sequence_number(self): 

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

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

826 

827 May raise ContextUnavailable.""" 

828 retval = self.sender_sequence_number 

829 if retval >= MAX_SEQNO: 

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

831 self.sender_sequence_number += 1 

832 self.post_seqnoincrease() 

833 return retval 

834 

835 # implementation defined 

836 

837 @abc.abstractmethod 

838 def post_seqnoincrease(self): 

839 """Ensure that sender_sequence_number is stored""" 

840 raise 

841 

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

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

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

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

846 

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

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

849 behaivor is returning self. 

850 """ 

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

852 

853class CanUnprotect(BaseSecurityContext): 

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

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

856 is_response = protected_message.code.is_response() 

857 

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

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

860 replay_error = None 

861 

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

863 

864 if protected: 

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

866 

867 # FIXME check for duplicate keys in protected 

868 

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

870 # FIXME is this necessary? 

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

872 

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

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

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

876 # valid check 

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

878 

879 if COSE_PIV not in unprotected: 

880 if not is_response: 

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

882 

883 nonce = request_id.nonce 

884 seqno = None # sentinel for not striking out anyting 

885 partial_iv_short = request_id.partial_iv 

886 partial_iv_generated_by = request_id.kid 

887 else: 

888 partial_iv_short = unprotected.pop(COSE_PIV) 

889 partial_iv_generated_by = self.recipient_id 

890 

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

892 

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

894 

895 if not is_response: 

896 if not self.recipient_replay_window.is_initialized(): 

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

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

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

900 

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

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

903 raise replay_error 

904 

905 request_id = RequestIdentifiers(self.recipient_id, partial_iv_short, nonce, can_reuse_nonce=replay_error is None, request_code=protected_message.code) 

906 

907 if unprotected.pop(COSE_COUNTERSIGNATURE0, None) is not None: 

908 try: 

909 alg_signature = self.alg_signature 

910 except NameError: 

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

912 

913 siglen = alg_signature.signature_length 

914 if len(ciphertext) < siglen: 

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

916 encrypted_signature = ciphertext[-siglen:] 

917 

918 keystream = self._kdf_for_keystreams( 

919 partial_iv_generated_by, 

920 partial_iv_short, 

921 self.group_encryption_key, 

922 self.recipient_id, 

923 INFO_TYPE_KEYSTREAM_REQUEST if protected_message.code.is_request() else INFO_TYPE_KEYSTREAM_RESPONSE, 

924 ) 

925 signature = _xor_bytes(encrypted_signature, keystream) 

926 

927 ciphertext = ciphertext[:-siglen] 

928 else: 

929 signature = None 

930 

931 if unprotected: 

932 raise DecodeError("Unsupported unprotected option") 

933 

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

935 raise ProtectionInvalid("Ciphertext too short") 

936 

937 external_aad = self._extract_external_aad(protected_message, request_id, local_is_sender=False) 

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

939 aad = cbor.dumps(enc_structure) 

940 

941 key = self._get_recipient_key(protected_message) 

942 

943 plaintext = self.alg_aead.decrypt(ciphertext, aad, key, nonce) 

944 

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

946 

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

948 self.recipient_replay_window.strike_out(seqno) 

949 

950 if signature is not None: 

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

952 alg_signature.verify(signature, ciphertext, external_aad, self.recipient_public_key) 

953 

954 # FIXME add options from unprotected 

955 

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

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

958 

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

960 self.echo_recovery is not None 

961 if try_initialize: 

962 if protected_message.code.is_request(): 

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

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

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

966 self.recipient_replay_window.initialize_from_freshlyseen(seqno) 

967 replay_error = None 

968 else: 

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

970 else: 

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

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

973 # match a request sent by this process. 

974 # 

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

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

977 # acting again on a retransmitted safe request whose response 

978 # it did not cache. 

979 # 

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

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

982 # checked for a response anyway. 

983 if seqno is not None: 

984 self.recipient_replay_window.initialize_from_freshlyseen(seqno) 

985 

986 if replay_error is not None: 

987 raise replay_error 

988 

989 if unprotected_message.code.is_request(): 

990 if protected_message.opt.observe != 0: 

991 unprotected_message.opt.observe = None 

992 else: 

993 if protected_message.opt.observe is not None: 

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

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

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

997 # in this implementation accepted for passing around. 

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

999 

1000 return unprotected_message, request_id 

1001 

1002 def _get_recipient_key(self, protected_message): 

1003 """Customization hook of the unprotect function 

1004 

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

1006 requests build it on demand.""" 

1007 return self.recipient_key 

1008 

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

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

1011 

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

1013 deterministic requests need to perform additional checks while AAD and 

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

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

1016 

1017 @staticmethod 

1018 def _uncompress(option_data, payload): 

1019 if option_data == b"": 

1020 firstbyte = 0 

1021 else: 

1022 firstbyte = option_data[0] 

1023 tail = option_data[1:] 

1024 

1025 unprotected = {} 

1026 

1027 if firstbyte & COMPRESSION_BITS_RESERVED: 

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

1029 

1030 pivsz = firstbyte & COMPRESSION_BITS_N 

1031 if pivsz: 

1032 if len(tail) < pivsz: 

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

1034 unprotected[COSE_PIV] = tail[:pivsz] 

1035 tail = tail[pivsz:] 

1036 

1037 if firstbyte & COMPRESSION_BIT_H: 

1038 # kid context hint 

1039 s = tail[0] 

1040 if len(tail) - 1 < s: 

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

1042 tail = tail[1:] 

1043 unprotected[COSE_KID_CONTEXT] = tail[:s] 

1044 tail = tail[s:] 

1045 

1046 if firstbyte & COMPRESSION_BIT_K: 

1047 kid = tail 

1048 unprotected[COSE_KID] = kid 

1049 

1050 if firstbyte & COMPRESSION_BIT_G: 

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

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

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

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

1055 unprotected[COSE_COUNTERSIGNATURE0] = b"" 

1056 

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

1058 

1059 @classmethod 

1060 def _extract_encrypted0(cls, message): 

1061 if message.opt.object_security is None: 

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

1063 

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

1065 return protected_serialized, protected, unprotected, ciphertext 

1066 

1067 # implementation defined 

1068 

1069 def context_for_response(self) -> CanProtect: 

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

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

1072 same context.""" 

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

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

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

1076 # already? 

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

1078 

1079class SecurityContextUtils(BaseSecurityContext): 

1080 def _kdf(self, salt, ikm, role_id, out_type): 

1081 """The HKDF as used to derive sender and recipient key and IV in 

1082 RFC8613 Section 3.2.1, and analogously the Group Encryption Key of oscore-groupcomm. 

1083 """ 

1084 if out_type == 'Key': 

1085 out_bytes = self.alg_aead.key_bytes 

1086 elif out_type == 'IV': 

1087 out_bytes = self.alg_aead.iv_bytes 

1088 elif out_type == "Group Encryption Key": 

1089 out_bytes = self.alg_signature_enc.key_bytes 

1090 else: 

1091 raise ValueError("Output type not recognized") 

1092 

1093 info = [ 

1094 role_id, 

1095 self.id_context, 

1096 self.alg_aead.value, 

1097 out_type, 

1098 out_bytes 

1099 ] 

1100 return self._kdf_lowlevel(salt, ikm, info, out_bytes) 

1101 

1102 def _kdf_for_keystreams(self, piv_generated_by, salt, ikm, role_id, out_type): 

1103 """The HKDF as used to derive the keystreams of oscore-groupcomm.""" 

1104 

1105 out_bytes = self.alg_signature.signature_length 

1106 

1107 assert out_type in (INFO_TYPE_KEYSTREAM_REQUEST, INFO_TYPE_KEYSTREAM_RESPONSE), "Output type not recognized" 

1108 

1109 info = [ 

1110 piv_generated_by, 

1111 self.id_context, 

1112 out_type, 

1113 out_bytes 

1114 ] 

1115 return self._kdf_lowlevel(salt, ikm, info, out_bytes) 

1116 

1117 def _kdf_lowlevel(self, salt: bytes, ikm: bytes, info: list, l: int) -> bytes: 

1118 """The HKDF function as used in RFC8613 and oscore-groupcomm (notated 

1119 there as ``something = HKDF(...)`` 

1120 

1121 Note that `info` typically contains `L` at some point. 

1122 

1123 When `info` takes the conventional structure of pid, id_context, 

1124 ald_aead, type, L], it may make sense to extend the `_kdf` function to 

1125 support that case, or `_kdf_for_keystreams` for a different structure, as 

1126 they are the more high-level tools.""" 

1127 hkdf = HKDF( 

1128 algorithm=self.hashfun, 

1129 length=l, 

1130 salt=salt, 

1131 info=cbor.dumps(info), 

1132 backend=_hash_backend, 

1133 ) 

1134 expanded = hkdf.derive(ikm) 

1135 return expanded 

1136 

1137 def derive_keys(self, master_salt, master_secret): 

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

1139 hash function and id_context already configured beforehand, and from 

1140 the passed salt and secret.""" 

1141 

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

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

1144 

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

1146 

1147 # really more of the Credentials interface 

1148 

1149 def get_oscore_context_for(self, unprotected): 

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

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

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

1153 

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

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

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

1157 objects here. 

1158 """ 

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

1160 return self 

1161 

1162class ReplayWindow: 

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

1164 

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

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

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

1168 leading ones (think floating point normalization) happen. 

1169 

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

1171 >>> w.initialize_empty() 

1172 >>> w.strike_out(5) 

1173 >>> w.is_valid(3) 

1174 True 

1175 >>> w.is_valid(5) 

1176 False 

1177 >>> w.strike_out(0) 

1178 >>> w.strike_out(1) 

1179 >>> w.strike_out(2) 

1180 >>> w.is_valid(1) 

1181 False 

1182 

1183 Jumping ahead by the window size invalidates older numbers: 

1184 

1185 >>> w.is_valid(4) 

1186 True 

1187 >>> w.strike_out(35) 

1188 >>> w.is_valid(4) 

1189 True 

1190 >>> w.strike_out(36) 

1191 >>> w.is_valid(4) 

1192 False 

1193 

1194 Usage safety 

1195 ------------ 

1196 

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

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

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

1200 

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

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

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

1204 

1205 Stability 

1206 --------- 

1207 

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

1209 detail of the SecurityContext implementation(s). 

1210 """ 

1211 

1212 _index = None 

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

1214 _bitfield = None 

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

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

1217 already seen.""" 

1218 

1219 def __init__(self, size, strike_out_callback): 

1220 self._size = size 

1221 self.strike_out_callback = strike_out_callback 

1222 

1223 def is_initialized(self): 

1224 return self._index is not None 

1225 

1226 def initialize_empty(self): 

1227 self._index = 0 

1228 self._bitfield = 0 

1229 

1230 def initialize_from_persisted(self, persisted): 

1231 self._index = persisted['index'] 

1232 self._bitfield = persisted['bitfield'] 

1233 

1234 def initialize_from_freshlyseen(self, seen): 

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

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

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

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

1239 ones as valid.""" 

1240 self._index = seen 

1241 self._bitfield = 1 

1242 

1243 def is_valid(self, number): 

1244 if number < self._index: 

1245 return False 

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

1247 return True 

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

1249 

1250 def strike_out(self, number): 

1251 if not self.is_valid(number): 

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

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

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

1255 if overshoot > 0: 

1256 self._index += overshoot 

1257 self._bitfield >>= overshoot 

1258 assert self.is_valid(number) 

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

1260 

1261 self.strike_out_callback() 

1262 

1263 def persist(self): 

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

1265 to recreated the replay window.""" 

1266 

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

1268 

1269class FilesystemSecurityContext(CanProtect, CanUnprotect, SecurityContextUtils): 

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

1271 containing 

1272 

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

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

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

1276 only for the user) 

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

1278 process needs write access to) 

1279 

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

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

1282 is sufficient. 

1283 

1284 .. warning:: 

1285 

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

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

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

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

1290 

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

1292 a context by to concurrent programs. 

1293 

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

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

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

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

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

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

1300 owned by him. 

1301 

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

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

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

1305 sequence_number_chunksize_start up to sequence_number_chunksize_limit. 

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

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

1308 Appendix B.1.2 to recover. 

1309 """ 

1310 

1311 class LoadError(ValueError): 

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

1313 faulty security context""" 

1314 

1315 def __init__( 

1316 self, 

1317 basedir, 

1318 sequence_number_chunksize_start=10, 

1319 sequence_number_chunksize_limit=10000, 

1320 ): 

1321 self.basedir = basedir 

1322 

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

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

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

1326 try: 

1327 self.lockfile.acquire(timeout=0.001) 

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

1329 except: # noqa: E722 

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

1331 self.lockfile = None 

1332 raise 

1333 

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

1335 # would be a terrible burden. 

1336 self.echo_recovery = secrets.token_bytes(8) 

1337 

1338 try: 

1339 self._load() 

1340 except KeyError as k: 

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

1342 

1343 self.sequence_number_chunksize_start = sequence_number_chunksize_start 

1344 self.sequence_number_chunksize_limit = sequence_number_chunksize_limit 

1345 self.sequence_number_chunksize = sequence_number_chunksize_start 

1346 

1347 self.sequence_number_persisted = self.sender_sequence_number 

1348 

1349 def _load(self): 

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

1351 # catch that 

1352 

1353 data = {} 

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

1355 try: 

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

1357 filedata = json.load(f) 

1358 except FileNotFoundError: 

1359 continue 

1360 

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

1362 if key.endswith('_hex'): 

1363 key = key[:-4] 

1364 value = binascii.unhexlify(value) 

1365 elif key.endswith('_ascii'): 

1366 key = key[:-6] 

1367 value = value.encode('ascii') 

1368 

1369 if key in data: 

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

1371 

1372 data[key] = value 

1373 

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

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

1376 

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

1378 if not isinstance(windowsize, int): 

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

1380 

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

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

1383 

1384 if max(len(self.sender_id), len(self.recipient_id)) > self.alg_aead.iv_bytes - 6: 

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

1386 

1387 master_secret = data['secret'] 

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

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

1390 

1391 self.derive_keys(master_salt, master_secret) 

1392 

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

1394 try: 

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

1396 sequence = json.load(f) 

1397 except FileNotFoundError: 

1398 self.sender_sequence_number = 0 

1399 self.recipient_replay_window.initialize_empty() 

1400 self.replay_window_persisted = True 

1401 else: 

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

1403 received = sequence['received'] 

1404 if received == "unknown": 

1405 # The replay window will stay uninitialized, which triggers 

1406 # Echo recovery 

1407 self.replay_window_persisted = False 

1408 else: 

1409 try: 

1410 self.recipient_replay_window.initialize_from_persisted(received) 

1411 except (ValueError, TypeError, KeyError): 

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

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

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

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

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

1417 # nonce reuse which tampering with the replay window file 

1418 # already does. 

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

1420 self.replay_window_persisted = True 

1421 

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

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

1424 # 

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

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

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

1428 # operation to conclude. 

1429 def _store(self): 

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

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

1432 

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

1434 if not self.replay_window_persisted: 

1435 data['received'] = 'unknown' 

1436 else: 

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

1438 

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

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

1441 # shutting down. 

1442 # 

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

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

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

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

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

1448 tmpfile.flush() 

1449 os.fsync(tmpfile.fileno()) 

1450 

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

1452 

1453 def _replay_window_changed(self): 

1454 if self.replay_window_persisted: 

1455 # Just remove the sequence numbers once from the file 

1456 self.replay_window_persisted = False 

1457 self._store() 

1458 

1459 def post_seqnoincrease(self): 

1460 if self.sender_sequence_number > self.sequence_number_persisted: 

1461 self.sequence_number_persisted += self.sequence_number_chunksize 

1462 

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

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

1465 self._store() 

1466 

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

1468 # numbers to 1 to force persisting on every step 

1469 assert self.sender_sequence_number <= self.sequence_number_persisted 

1470 

1471 def _destroy(self): 

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

1473 unusable. 

1474 

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

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

1477 resumption without wasting digits or round-trips. 

1478 """ 

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

1480 

1481 self.replay_window_persisted = True 

1482 self.sequence_number_persisted = self.sender_sequence_number 

1483 self._store() 

1484 

1485 del self.sender_key 

1486 del self.recipient_key 

1487 

1488 os.unlink(self.lockfile.lock_file) 

1489 self.lockfile.release() 

1490 

1491 self.lockfile = None 

1492 

1493 def __del__(self): 

1494 if self.lockfile is not None: 

1495 self._destroy() 

1496 

1497class GroupContext(BaseSecurityContext): 

1498 is_signing = True 

1499 external_aad_is_group = True 

1500 responses_send_kid = True 

1501 

1502 # This is None iff the group does not support group mode 

1503 alg_signature_enc: Optional[AeadAlgorithm] 

1504 # This is None iff the group does not support group mode 

1505 alg_signature: Optional[AlgorithmCountersign] 

1506 # This is None iff the group does not support pairwise 

1507 # 

1508 # This is also of type AlgorithmCountersign because the staticstatic 

1509 # function is sitting on the same type. 

1510 alg_pairwise_key_agreement: Optional[AlgorithmCountersign] 

1511 

1512 @abc.abstractproperty 

1513 def private_key(self): 

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

1515 

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

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

1518 is found.""" 

1519 

1520 @abc.abstractproperty 

1521 def recipient_public_key(self): 

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

1523 

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

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

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

1527 

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

1529 """A context for an OSCORE group 

1530 

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

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

1533 

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

1535 to be usable securely. 

1536 """ 

1537 

1538 # set during initialization 

1539 private_key = None 

1540 sender_auth_cred = None 

1541 

1542 def __init__(self, alg_aead, hashfun, alg_signature, alg_signature_enc, alg_pairwise_key_agreement, group_id, master_secret, master_salt, sender_id, private_key, sender_auth_cred, peers, group_manager_cred=None, cred_fmt=COSE_KCCS): 

1543 self.sender_id = sender_id 

1544 self.id_context = group_id 

1545 self.private_key = private_key 

1546 self.alg_aead = alg_aead 

1547 self.hashfun = hashfun 

1548 self.alg_signature = alg_signature 

1549 self.alg_signature_enc = alg_signature_enc 

1550 self.alg_pairwise_key_agreement = alg_pairwise_key_agreement 

1551 self.sender_auth_cred = sender_auth_cred 

1552 self.group_manager_cred = group_manager_cred 

1553 self.cred_fmt = cred_fmt 

1554 

1555 self.peers = peers.keys() 

1556 self.recipient_public_keys = {k: self._parse_credential(v) for (k, v) in peers.items()} 

1557 self.recipient_auth_creds = peers 

1558 self.recipient_replay_windows = {} 

1559 for k in self.peers: 

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

1561 w = ReplayWindow(32, lambda: None) 

1562 w.initialize_empty() 

1563 self.recipient_replay_windows[k] = w 

1564 

1565 self.derive_keys(master_salt, master_secret) 

1566 self.sender_sequence_number = 0 

1567 

1568 sender_public_key = self._parse_credential(sender_auth_cred) 

1569 if self.alg_signature.public_from_private(self.private_key) != sender_public_key: 

1570 raise ValueError("The key in the provided sender credential does not match the private key") 

1571 

1572 def _parse_credential(self, credential: bytes): 

1573 """Extract the public key (in the public_key format the respective 

1574 AlgorithmCountersign needs) from credentials. This raises a ValueError 

1575 if the credentials do not match the group's cred_fmt, or if the 

1576 parameters do not match those configured in the group. 

1577 

1578 This currently discards any information that is present in the 

1579 credential that exceeds the key. (In a future version, this could 

1580 return both the key and extracted other data, where that other data 

1581 would be stored with the peer this is parsed from). 

1582 """ 

1583 

1584 if self.cred_fmt != COSE_KCCS: 

1585 raise ValueError("Credential parsing is currently only implemented for CCSs") 

1586 

1587 try: 

1588 parsed = cbor.loads(credential) 

1589 except cbor.CBORDecodeError as e: 

1590 raise ValueError("CCS not in CBOR format") from e 

1591 

1592 CWT_CLAIM_CNF = 8 

1593 CWT_CNF_COSE_KEY = 1 

1594 if ( 

1595 not isinstance(parsed, dict) 

1596 or CWT_CLAIM_CNF not in parsed 

1597 or not isinstance(parsed[CWT_CLAIM_CNF], dict) 

1598 or CWT_CNF_COSE_KEY not in parsed[CWT_CLAIM_CNF] 

1599 ): 

1600 raise ValueError("CCS must contain a COSE Key in a CNF") 

1601 

1602 COSE_KEY_COMMON_KTY = 1 

1603 COSE_KTY_OKP = 1 

1604 COSE_KTY_EC2 = 2 

1605 COSE_KEY_COMMON_ALG = 3 

1606 COSE_KEY_OKP_CRV = -1 

1607 COSE_KEY_OKP_X = -2 

1608 COSE_KEY_EC2_X = -2 

1609 COSE_KEY_EC2_Y = -3 

1610 # eg. {1: 1, 3: -8, -1: 6, -2: h'77 / ... / 88'} 

1611 cose_key = parsed[CWT_CLAIM_CNF][CWT_CNF_COSE_KEY] 

1612 if not isinstance(cose_key, dict): 

1613 raise ValueError("COSE Key in CCS must be a map") 

1614 

1615 if ( 

1616 cose_key.get(COSE_KEY_COMMON_KTY) == COSE_KTY_OKP 

1617 and cose_key.get(COSE_KEY_COMMON_ALG) == Ed25519.value 

1618 and cose_key.get(COSE_KEY_OKP_CRV) == Ed25519.curve_number 

1619 and COSE_KEY_OKP_X in cose_key 

1620 ): 

1621 return cose_key[COSE_KEY_OKP_X] 

1622 elif ( 

1623 cose_key.get(COSE_KEY_COMMON_KTY) == COSE_KTY_EC2 

1624 and cose_key.get(COSE_KEY_COMMON_ALG) == ECDSA_SHA256_P256.value 

1625 and COSE_KEY_EC2_X in cose_key 

1626 and COSE_KEY_EC2_Y in cose_key 

1627 ): 

1628 return ECDSA_SHA256_P256().from_public_parts( 

1629 x=cose_key[COSE_KEY_EC2_X], 

1630 y=cose_key[COSE_KEY_EC2_Y], 

1631 ) 

1632 else: 

1633 raise ValueError("Key type not recognized from CCS key %r" % cose_key) 

1634 

1635 return cbor.loads(credential)[8][1][-2] 

1636 

1637 def __repr__(self): 

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

1639 type(self).__name__, 

1640 self.id_context.hex(), 

1641 self.sender_id.hex(), 

1642 len(self.peers), 

1643 ) 

1644 

1645 @property 

1646 def recipient_public_key(self): 

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

1648 

1649 def derive_keys(self, master_salt, master_secret): 

1650 # FIXME unify with parent? 

1651 

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

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

1654 

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

1656 

1657 # but this one is new 

1658 

1659 self.group_encryption_key = self._kdf(master_salt, master_secret, b"", "Group Encryption Key") 

1660 

1661 def post_seqnoincrease(self): 

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

1663 

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

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

1666 # would not run through here 

1667 try: 

1668 sender_kid = unprotected_bag[COSE_KID] 

1669 except KeyError: 

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

1671 

1672 if COSE_COUNTERSIGNATURE0 in unprotected_bag: 

1673 return _GroupContextAspect(self, sender_kid) 

1674 else: 

1675 return _PairwiseContextAspect(self, sender_kid) 

1676 

1677 def get_oscore_context_for(self, unprotected): 

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

1679 return None 

1680 

1681 kid = unprotected.get(COSE_KID, None) 

1682 if kid in self.peers: 

1683 if COSE_COUNTERSIGNATURE0 in unprotected: 

1684 return _GroupContextAspect(self, kid) 

1685 elif self.recipient_public_keys[kid] is DETERMINISTIC_KEY: 

1686 return _DeterministicUnprotectProtoAspect(self, kid) 

1687 else: 

1688 return _PairwiseContextAspect(self, kid) 

1689 

1690 # yet to stabilize... 

1691 

1692 def pairwise_for(self, recipient_id): 

1693 return _PairwiseContextAspect(self, recipient_id) 

1694 

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

1696 return _DeterministicProtectProtoAspect(self, deterministic_id, target_server) 

1697 

1698class _GroupContextAspect(GroupContext, CanUnprotect, SecurityContextUtils): 

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

1700 

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

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

1703 

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

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

1706 context_for_response before it comes to that). 

1707 """ 

1708 

1709 def __init__(self, groupcontext, recipient_id): 

1710 self.groupcontext = groupcontext 

1711 self.recipient_id = recipient_id 

1712 

1713 def __repr__(self): 

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

1715 type(self).__name__, 

1716 self.groupcontext, 

1717 self.recipient_id.hex(), 

1718 ) 

1719 

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

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

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

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

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

1725 group_manager_cred = property(lambda self: self.groupcontext.group_manager_cred) 

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

1727 

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

1729 group_encryption_key = property(lambda self: self.groupcontext.group_encryption_key) 

1730 

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

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

1733 recipient_auth_cred = property(lambda self: self.groupcontext.recipient_auth_creds[self.recipient_id]) 

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

1735 

1736 def context_for_response(self): 

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

1738 

1739 @property 

1740 def sender_auth_cred(self): 

1741 raise RuntimeError("Could relay the sender auth credential from the group context, but it shouldn't matter here") 

1742 

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

1744 is_signing = False 

1745 

1746 def __init__(self, groupcontext, recipient_id): 

1747 self.groupcontext = groupcontext 

1748 self.recipient_id = recipient_id 

1749 

1750 shared_secret = self.alg_pairwise_key_agreement.staticstatic( 

1751 self.groupcontext.private_key, 

1752 self.groupcontext.recipient_public_keys[recipient_id] 

1753 ) 

1754 

1755 self.sender_key = self._kdf( 

1756 self.groupcontext.sender_key, 

1757 ( 

1758 self.groupcontext.sender_auth_cred 

1759 + self.groupcontext.recipient_auth_creds[recipient_id] 

1760 + shared_secret 

1761 ), 

1762 self.groupcontext.sender_id, 

1763 'Key', 

1764 ) 

1765 self.recipient_key = self._kdf( 

1766 self.groupcontext.recipient_keys[recipient_id], 

1767 ( 

1768 self.groupcontext.recipient_auth_creds[recipient_id] 

1769 + self.groupcontext.sender_auth_cred 

1770 + shared_secret 

1771 ), 

1772 self.recipient_id, 

1773 'Key', 

1774 ) 

1775 

1776 def __repr__(self): 

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

1778 type(self).__name__, 

1779 self.groupcontext, 

1780 self.recipient_id.hex(), 

1781 ) 

1782 

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

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

1785 alg_aead = property(lambda self: self.groupcontext.alg_aead) 

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

1787 alg_signature = property(lambda self: self.groupcontext.alg_signature) 

1788 alg_signature_enc = property(lambda self: self.groupcontext.alg_signature_enc) 

1789 alg_pairwise_key_agreement = property(lambda self: self.groupcontext.alg_pairwise_key_agreement) 

1790 group_manager_cred = property(lambda self: self.groupcontext.group_manager_cred) 

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

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

1793 

1794 recipient_auth_cred = property(lambda self: self.groupcontext.recipient_auth_creds[self.recipient_id]) 

1795 sender_auth_cred = property(lambda self: self.groupcontext.sender_auth_cred) 

1796 

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

1798 

1799 # Set at initialization 

1800 recipient_key = None 

1801 sender_key = None 

1802 

1803 @property 

1804 def sender_sequence_number(self): 

1805 return self.groupcontext.sender_sequence_number 

1806 @sender_sequence_number.setter 

1807 def sender_sequence_number(self, new): 

1808 self.groupcontext.sender_sequence_number = new 

1809 

1810 def post_seqnoincrease(self): 

1811 self.groupcontext.post_seqnoincrease() 

1812 

1813 # same here -- not needed because not signing 

1814 private_key = property(post_seqnoincrease) 

1815 recipient_public_key = property(post_seqnoincrease) 

1816 

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

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

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

1820 

1821 if COSE_COUNTERSIGNATURE0 in unprotected_bag: 

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

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

1824 # members. 

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

1826 else: 

1827 return self 

1828 

1829class _DeterministicProtectProtoAspect(CanProtect, SecurityContextUtils): 

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

1831 

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

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

1834 

1835 deterministic_hashfun = hashes.SHA256() 

1836 

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

1838 self.groupcontext = groupcontext 

1839 self.sender_id = sender_id 

1840 self.target_server = target_server 

1841 

1842 def __repr__(self): 

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

1844 type(self).__name__, 

1845 self.groupcontext, 

1846 self.sender_id.hex(), 

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

1848 ) 

1849 

1850 def new_sequence_number(self): 

1851 return 0 

1852 

1853 def post_seqnoincrease(self): 

1854 pass 

1855 

1856 def context_from_response(self, unprotected_bag): 

1857 if self.target_server is None: 

1858 if COSE_KID not in unprotected_bag: 

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

1860 else: 

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

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

1863 

1864 if COSE_COUNTERSIGNATURE0 not in unprotected_bag: 

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

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

1867 

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

1869 

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

1871 if outer_message.code.is_response(): 

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

1873 

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

1875 

1876 h = hashes.Hash(self.deterministic_hashfun) 

1877 h.update(basekey) 

1878 h.update(aad) 

1879 h.update(plaintext) 

1880 request_hash = h.finalize() 

1881 

1882 outer_message.opt.request_hash = request_hash 

1883 outer_message.code = FETCH 

1884 

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

1886 # for the benefit of the response parsing later 

1887 request_id.request_hash = request_hash 

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

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

1890 request_id.can_reuse_nonce = False 

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

1892 

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

1894 

1895 external_aad_is_group = True 

1896 

1897 # details needed for various operations, especially eAAD generation 

1898 alg_aead = property(lambda self: self.groupcontext.alg_aead) 

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

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

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

1902 alg_signature = property(lambda self: self.groupcontext.alg_signature) 

1903 

1904class _DeterministicUnprotectProtoAspect(CanUnprotect, SecurityContextUtils): 

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

1906 

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

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

1909 

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

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

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

1913 echo_recovery = None 

1914 

1915 deterministic_hashfun = hashes.SHA256() 

1916 

1917 class ZeroIsAlwaysValid: 

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

1919 

1920 def is_initialized(self): 

1921 return True 

1922 

1923 def is_valid(self, number): 

1924 # No particular reason to be lax here 

1925 return number == 0 

1926 

1927 def strike_out(self, number): 

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

1929 # request_id.can_reuse_nonce = False 

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

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

1932 pass 

1933 

1934 def persist(self): 

1935 pass 

1936 

1937 def __init__(self, groupcontext, recipient_id): 

1938 self.groupcontext = groupcontext 

1939 self.recipient_id = recipient_id 

1940 

1941 self.recipient_replay_window = self.ZeroIsAlwaysValid() 

1942 

1943 def __repr__(self): 

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

1945 type(self).__name__, 

1946 self.groupcontext, 

1947 self.recipient_id.hex(), 

1948 ) 

1949 

1950 def context_for_response(self): 

1951 return self.groupcontext 

1952 

1953 def _get_recipient_key(self, protected_message): 

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

1955 

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

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

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

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

1960 # what's spec'd for here) 

1961 raise ProtectionInvalid("Request was not safe") 

1962 

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

1964 

1965 h = hashes.Hash(self.deterministic_hashfun) 

1966 h.update(basekey) 

1967 h.update(aad) 

1968 h.update(plaintext) 

1969 request_hash = h.finalize() 

1970 

1971 if request_hash != protected_message.opt.request_hash: 

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

1973 

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

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

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

1977 request_id.request_hash = request_hash 

1978 request_id.can_reuse_nonce = False 

1979 

1980 external_aad_is_group = True 

1981 

1982 # details needed for various operations, especially eAAD generation 

1983 alg_aead = property(lambda self: self.groupcontext.alg_aead) 

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

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

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

1987 alg_signature = property(lambda self: self.groupcontext.alg_signature) 

1988 

1989def verify_start(message): 

1990 """Extract the unprotected COSE options from a 

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

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

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

1994 

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

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

1997 

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

1999 

2000 return unprotected 

2001 

2002 

2003 

2004_getattr__ = deprecation_getattr({ 

2005 'COSE_COUNTERSINGATURE0': 'COSE_COUNTERSIGNATURE0', 

2006 'Algorithm': 'AeadAlgorithm', 

2007 }, globals())