Coverage for aiocoap/oscore.py: 82%

1067 statements  

« prev     ^ index     » next       coverage.py v7.5.4, created at 2024-07-10 11:47 +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, List 

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 

30from . import credentials 

31 

32from cryptography.hazmat.primitives.ciphers import aead 

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

34from cryptography.hazmat.primitives import hashes 

35import cryptography.hazmat.backends 

36import cryptography.exceptions 

37from cryptography.hazmat.primitives import asymmetric, serialization 

38from cryptography.hazmat.primitives.asymmetric.utils import ( 

39 decode_dss_signature, 

40 encode_dss_signature, 

41) 

42 

43import cbor2 as cbor 

44 

45import filelock 

46 

47MAX_SEQNO = 2**40 - 1 

48 

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

50COSE_KID = 4 

51COSE_PIV = 6 

52COSE_KID_CONTEXT = 10 

53# from RFC9338 

54COSE_COUNTERSIGNATURE0 = 12 

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

56COSE_KCCS = 13131313 

57 

58COMPRESSION_BITS_N = 0b111 

59COMPRESSION_BIT_K = 0b1000 

60COMPRESSION_BIT_H = 0b10000 

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

62COMPRESSION_BITS_RESERVED = 0b11000000 

63 

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

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

66 

67INFO_TYPE_KEYSTREAM_REQUEST = True 

68INFO_TYPE_KEYSTREAM_RESPONSE = False 

69 

70 

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

72 FETCH_CONTENT: CodeStyle 

73 POST_CHANGED: CodeStyle 

74 

75 @classmethod 

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

77 if request == FETCH: 

78 return cls.FETCH_CONTENT 

79 elif request == POST: 

80 return cls.POST_CHANGED 

81 else: 

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

83 

84 

85CodeStyle.FETCH_CONTENT = CodeStyle(FETCH, CONTENT) 

86CodeStyle.POST_CHANGED = CodeStyle(POST, CHANGED) 

87 

88 

89class DeterministicKey: 

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

91 is available because it is the Deterministic Client (see 

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

93 

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

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

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

97 """ 

98 

99 

100DETERMINISTIC_KEY = DeterministicKey() 

101del DeterministicKey 

102 

103 

104class NotAProtectedMessage(error.Error, ValueError): 

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

106 

107 def __init__(self, message, plain_message): 

108 super().__init__(message) 

109 self.plain_message = plain_message 

110 

111 

112class ProtectionInvalid(error.Error, ValueError): 

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

114 

115 

116class DecodeError(ProtectionInvalid): 

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

118 

119 

120class ReplayError(ProtectionInvalid): 

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

122 

123 

124class ReplayErrorWithEcho(ProtectionInvalid, error.RenderableError): 

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

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

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

128 assisting in replay window recovery""" 

129 

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

131 self.secctx = secctx 

132 self.request_id = request_id 

133 self.echo = echo 

134 

135 def to_message(self): 

136 inner = Message( 

137 code=UNAUTHORIZED, 

138 echo=self.echo, 

139 ) 

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

141 return outer 

142 

143 

144class ContextUnavailable(error.Error, ValueError): 

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

146 protecting or unprotecting a message""" 

147 

148 

149class RequestIdentifiers: 

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

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

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

153 around the request's partial IV. 

154 

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

156 just pass them around. 

157 """ 

158 

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

160 self.kid = kid 

161 self.partial_iv = partial_iv 

162 self.nonce = nonce 

163 self.can_reuse_nonce = can_reuse_nonce 

164 self.code_style = CodeStyle.from_request(request_code) 

165 

166 self.request_hash = None 

167 

168 def get_reusable_nonce_and_piv(self): 

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

170 set can_reuse_nonce to False.""" 

171 

172 if self.can_reuse_nonce: 

173 self.can_reuse_nonce = False 

174 return (self.nonce, self.partial_iv) 

175 else: 

176 return (None, None) 

177 

178 

179def _xor_bytes(a, b): 

180 assert len(a) == len(b), "XOR needs consistent lengths" 

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

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

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

184 

185 

186class AeadAlgorithm(metaclass=abc.ABCMeta): 

187 value: int 

188 key_bytes: int 

189 tag_bytes: int 

190 iv_bytes: int 

191 

192 @abc.abstractmethod 

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

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

195 

196 @abc.abstractmethod 

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

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

199 stemming from untrusted data.""" 

200 

201 @staticmethod 

202 def _build_encrypt0_structure(protected, external_aad): 

203 assert protected == {}, "Unexpected data in protected bucket" 

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

205 enc_structure = ["Encrypt0", protected_serialized, external_aad] 

206 

207 return cbor.dumps(enc_structure) 

208 

209 

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

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

212 

213 @classmethod 

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

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

216 

217 @classmethod 

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

219 try: 

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

221 except cryptography.exceptions.InvalidTag: 

222 raise ProtectionInvalid("Tag invalid") 

223 

224 

225class AES_CCM_16_64_128(AES_CCM): 

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

227 value = 10 

228 key_bytes = 16 # 128-bit key 

229 tag_bytes = 8 # 64-bit tag 

230 iv_bytes = 13 # 13-byte nonce 

231 

232 

233class AES_CCM_16_64_256(AES_CCM): 

234 # from RFC8152 

235 value = 11 

236 key_bytes = 32 # 256-bit key 

237 tag_bytes = 8 # 64-bit tag 

238 iv_bytes = 13 # 13-byte nonce 

239 

240 

241class AES_CCM_64_64_128(AES_CCM): 

242 # from RFC8152 

243 value = 12 

244 key_bytes = 16 # 128-bit key 

245 tag_bytes = 8 # 64-bit tag 

246 iv_bytes = 7 # 7-byte nonce 

247 

248 

249class AES_CCM_64_64_256(AES_CCM): 

250 # from RFC8152 

251 value = 13 

252 key_bytes = 32 # 256-bit key 

253 tag_bytes = 8 # 64-bit tag 

254 iv_bytes = 7 # 7-byte nonce 

255 

256 

257class AES_CCM_16_128_128(AES_CCM): 

258 # from RFC8152 

259 value = 30 

260 key_bytes = 16 # 128-bit key 

261 tag_bytes = 16 # 128-bit tag 

262 iv_bytes = 13 # 13-byte nonce 

263 

264 

265class AES_CCM_16_128_256(AES_CCM): 

266 # from RFC8152 

267 value = 31 

268 key_bytes = 32 # 256-bit key 

269 tag_bytes = 16 # 128-bit tag 

270 iv_bytes = 13 # 13-byte nonce 

271 

272 

273class AES_CCM_64_128_128(AES_CCM): 

274 # from RFC8152 

275 value = 32 

276 key_bytes = 16 # 128-bit key 

277 tag_bytes = 16 # 128-bit tag 

278 iv_bytes = 7 # 7-byte nonce 

279 

280 

281class AES_CCM_64_128_256(AES_CCM): 

282 # from RFC8152 

283 value = 33 

284 key_bytes = 32 # 256-bit key 

285 tag_bytes = 16 # 128-bit tag 

286 iv_bytes = 7 # 7-byte nonce 

287 

288 

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

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

291 

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

293 

294 @classmethod 

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

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

297 

298 @classmethod 

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

300 try: 

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

302 except cryptography.exceptions.InvalidTag: 

303 raise ProtectionInvalid("Tag invalid") 

304 

305 

306class A128GCM(AES_GCM): 

307 # from RFC8152 

308 value = 1 

309 key_bytes = 16 # 128-bit key 

310 tag_bytes = 16 # 128-bit tag 

311 

312 

313class A192GCM(AES_GCM): 

314 # from RFC8152 

315 value = 2 

316 key_bytes = 24 # 192-bit key 

317 tag_bytes = 16 # 128-bit tag 

318 

319 

320class A256GCM(AES_GCM): 

321 # from RFC8152 

322 value = 3 

323 key_bytes = 32 # 256-bit key 

324 tag_bytes = 16 # 128-bit tag 

325 

326 

327class ChaCha20Poly1305(AeadAlgorithm): 

328 # from RFC8152 

329 value = 24 

330 key_bytes = 32 # 256-bit key 

331 tag_bytes = 16 # 128-bit tag 

332 iv_bytes = 12 # 96-bit nonce 

333 

334 @classmethod 

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

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

337 

338 @classmethod 

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

340 try: 

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

342 except cryptography.exceptions.InvalidTag: 

343 raise ProtectionInvalid("Tag invalid") 

344 

345 

346class AlgorithmCountersign(metaclass=abc.ABCMeta): 

347 """A fully parameterized COSE countersign algorithm 

348 

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

350 par_countersign_key parameters taht go into the Group OSCORE algorithms 

351 field. 

352 """ 

353 

354 value: int | str 

355 

356 @abc.abstractmethod 

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

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

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

360 

361 @abc.abstractmethod 

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

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

364 

365 @abc.abstractmethod 

366 def generate(self): 

367 """Return a usable private key""" 

368 

369 @abc.abstractmethod 

370 def public_from_private(self, private_key): 

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

372 

373 @staticmethod 

374 def _build_countersign_structure(body, external_aad): 

375 countersign_structure = [ 

376 "CounterSignature0", 

377 b"", 

378 b"", 

379 external_aad, 

380 body, 

381 ] 

382 tobesigned = cbor.dumps(countersign_structure) 

383 return tobesigned 

384 

385 @abc.abstractproperty 

386 def signature_length(self) -> int: 

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

388 

389 @abc.abstractproperty 

390 def curve_number(self) -> int: 

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

392 

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

394 

395 

396class AlgorithmStaticStatic(metaclass=abc.ABCMeta): 

397 @abc.abstractmethod 

398 def staticstatic(self, private_key, public_key): 

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

400 

401 

402class Ed25519(AlgorithmCountersign): 

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

404 private_key = asymmetric.ed25519.Ed25519PrivateKey.from_private_bytes( 

405 private_key 

406 ) 

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

408 

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

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

411 try: 

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

413 except cryptography.exceptions.InvalidSignature: 

414 raise ProtectionInvalid("Signature mismatch") 

415 

416 def generate(self): 

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

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

419 # current algorithm interfaces did not insist on passing the 

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

421 # efficient. 

422 return key.private_bytes( 

423 encoding=serialization.Encoding.Raw, 

424 format=serialization.PrivateFormat.Raw, 

425 encryption_algorithm=serialization.NoEncryption(), 

426 ) 

427 

428 def public_from_private(self, private_key): 

429 private_key = asymmetric.ed25519.Ed25519PrivateKey.from_private_bytes( 

430 private_key 

431 ) 

432 public_key = private_key.public_key() 

433 return public_key.public_bytes( 

434 encoding=serialization.Encoding.Raw, 

435 format=serialization.PublicFormat.Raw, 

436 ) 

437 

438 value = -8 

439 curve_number = 6 

440 

441 signature_length = 64 

442 

443 

444class EcdhSsHkdf256(AlgorithmStaticStatic): 

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

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

447 # contexts are to be set up. 

448 

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

450 

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

452 

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

454 generate = Ed25519.generate 

455 public_from_private = Ed25519.public_from_private 

456 

457 def staticstatic(self, private_key, public_key): 

458 private_key = asymmetric.ed25519.Ed25519PrivateKey.from_private_bytes( 

459 private_key 

460 ) 

461 private_key = cryptography_additions.sk_to_curve25519(private_key) 

462 

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

464 public_key = cryptography_additions.pk_to_curve25519(public_key) 

465 

466 return private_key.exchange(public_key) 

467 

468 

469class ECDSA_SHA256_P256(AlgorithmCountersign, AlgorithmStaticStatic): 

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

471 # we're just passing Python objects around 

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

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

474 return asymmetric.ec.EllipticCurvePublicNumbers( 

475 int.from_bytes(x, "big"), 

476 int.from_bytes(y, "big"), 

477 asymmetric.ec.SECP256R1(), 

478 ).public_key() 

479 

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

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

482 private_numbers = asymmetric.ec.EllipticCurvePrivateNumbers( 

483 int.from_bytes(d, "big"), public_numbers 

484 ) 

485 return private_numbers.private_key() 

486 

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

488 der_signature = private_key.sign( 

489 self._build_countersign_structure(body, aad), 

490 asymmetric.ec.ECDSA(hashes.SHA256()), 

491 ) 

492 (r, s) = decode_dss_signature(der_signature) 

493 

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

495 

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

497 r = signature[:32] 

498 s = signature[32:] 

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

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

501 der_signature = encode_dss_signature(r, s) 

502 try: 

503 public_key.verify( 

504 der_signature, 

505 self._build_countersign_structure(body, aad), 

506 asymmetric.ec.ECDSA(hashes.SHA256()), 

507 ) 

508 except cryptography.exceptions.InvalidSignature: 

509 raise ProtectionInvalid("Signature mismatch") 

510 

511 def generate(self): 

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

513 

514 def public_from_private(self, private_key): 

515 return private_key.public_key() 

516 

517 def staticstatic(self, private_key, public_key): 

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

519 

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

521 curve_number = 1 

522 

523 signature_length = 64 

524 

525 

526algorithms = { 

527 "AES-CCM-16-64-128": AES_CCM_16_64_128(), 

528 "AES-CCM-16-64-256": AES_CCM_16_64_256(), 

529 "AES-CCM-64-64-128": AES_CCM_64_64_128(), 

530 "AES-CCM-64-64-256": AES_CCM_64_64_256(), 

531 "AES-CCM-16-128-128": AES_CCM_16_128_128(), 

532 "AES-CCM-16-128-256": AES_CCM_16_128_256(), 

533 "AES-CCM-64-128-128": AES_CCM_64_128_128(), 

534 "AES-CCM-64-128-256": AES_CCM_64_128_256(), 

535 "ChaCha20/Poly1305": ChaCha20Poly1305(), 

536 "A128GCM": A128GCM(), 

537 "A192GCM": A192GCM(), 

538 "A256GCM": A256GCM(), 

539} 

540 

541# algorithms with full parameter set 

542algorithms_countersign = { 

543 # maybe needs a different name... 

544 "EdDSA on Ed25519": Ed25519(), 

545 "ECDSA w/ SHA-256 on P-256": ECDSA_SHA256_P256(), 

546} 

547 

548algorithms_staticstatic = { 

549 "ECDH-SS + HKDF-256": EcdhSsHkdf256(), 

550} 

551 

552DEFAULT_ALGORITHM = "AES-CCM-16-64-128" 

553 

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

555hashfunctions = { 

556 "sha256": hashes.SHA256(), 

557 "sha384": hashes.SHA384(), 

558 "sha512": hashes.SHA512(), 

559} 

560 

561DEFAULT_HASHFUNCTION = "sha256" 

562 

563DEFAULT_WINDOWSIZE = 32 

564 

565 

566class BaseSecurityContext: 

567 # Deprecated marker for whether the class uses the 

568 # ContextWhereExternalAadIsGroup mixin; see documentation there. 

569 external_aad_is_group = False 

570 

571 # Authentication information carried with this security context; managed 

572 # externally by whatever creates the security context. 

573 authenticated_claims: List[str] = [] 

574 

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

576 alg_aead: Optional[AeadAlgorithm] 

577 

578 @property 

579 def algorithm(self): 

580 warnings.warn( 

581 "Property was renamed to 'alg_aead'", DeprecationWarning, stacklevel=2 

582 ) 

583 return self.alg_aead 

584 

585 @algorithm.setter 

586 def algorithm(self, value): 

587 warnings.warn( 

588 "Property was renamed to 'alg_aead'", DeprecationWarning, stacklevel=2 

589 ) 

590 self.alg_aead = value 

591 

592 hashfun: hashes.HashAlgorithm 

593 

594 def _construct_nonce(self, partial_iv_short, piv_generator_id): 

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

596 

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

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

599 

600 components = s + pad_id + piv_generator_id + pad_piv + partial_iv_short 

601 

602 nonce = _xor_bytes(self.common_iv, components) 

603 

604 return nonce 

605 

606 def _extract_external_aad( 

607 self, message, request_id, local_is_sender: bool 

608 ) -> bytes: 

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

610 and the request_id. 

611 

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

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

614 authentication credentials are placed in the AAD. 

615 """ 

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

617 # 

618 # the_options = pick some of(message) 

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

620 

621 oscore_version = 1 

622 class_i_options = b"" 

623 if request_id.request_hash is not None: 

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

625 

626 algorithms: List[int | str | None] = [ 

627 self.alg_aead.value if self.alg_aead is not None else None 

628 ] 

629 if isinstance(self, ContextWhereExternalAadIsGroup): 

630 algorithms.append( 

631 None if self.alg_signature_enc is None else self.alg_signature_enc.value 

632 ) 

633 algorithms.append( 

634 None if self.alg_signature is None else self.alg_signature.value 

635 ) 

636 algorithms.append( 

637 None 

638 if self.alg_pairwise_key_agreement is None 

639 else self.alg_pairwise_key_agreement.value 

640 ) 

641 

642 external_aad = [ 

643 oscore_version, 

644 algorithms, 

645 request_id.kid, 

646 request_id.partial_iv, 

647 class_i_options, 

648 ] 

649 

650 if isinstance(self, ContextWhereExternalAadIsGroup): 

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

652 # observation span group rekeyings 

653 external_aad.append(self.id_context) 

654 

655 assert message.opt.oscore is not None, "Double OSCORE" 

656 external_aad.append(message.opt.oscore) 

657 

658 if local_is_sender: 

659 external_aad.append(self.sender_auth_cred) 

660 else: 

661 external_aad.append(self.recipient_auth_cred) 

662 external_aad.append(self.group_manager_cred) 

663 

664 return cbor.dumps(external_aad) 

665 

666 

667class ContextWhereExternalAadIsGroup(BaseSecurityContext): 

668 """The protection and unprotection functions will use the Group OSCORE AADs 

669 rather than the regular OSCORE AADs iff a context uses this mixin. (Ie. 

670 alg_signature_enc etc are added to the algorithms, and request_kid_context, 

671 OSCORE_option, sender_auth_cred and gm_cred are added). 

672 

673 This does not necessarily match the is_signing property (as pairwise 

674 contexts use this but don't sign), and is distinct from the added OSCORE 

675 option in the AAD (as that's only applicable for the external AAD as 

676 extracted for signing and signature verification purposes).""" 

677 

678 id_context: bytes 

679 

680 external_aad_is_group = True 

681 

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

683 alg_signature_enc: Optional[AeadAlgorithm] 

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

685 alg_signature: Optional[AlgorithmCountersign] 

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

687 # 

688 # This is also of type AlgorithmCountersign because the staticstatic 

689 # function is sitting on the same type. 

690 alg_pairwise_key_agreement: Optional[AlgorithmCountersign] 

691 

692 # FIXME: These probably should be `bytes` only; the condition for them 

693 # being None needs to be refined. 

694 sender_auth_cred: Optional[bytes] 

695 recipient_auth_cred: Optional[bytes] 

696 group_manager_cred: Optional[bytes] 

697 

698 

699# FIXME pull interface components from SecurityContext up here 

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

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

702 # alg_signature attribute if this is true 

703 is_signing = False 

704 

705 # Send the KID when protecting responses 

706 # 

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

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

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

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

711 # context they came in on). 

712 responses_send_kid = False 

713 

714 @staticmethod 

715 def _compress(protected, unprotected, ciphertext): 

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

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

718 message body""" 

719 

720 if protected: 

721 raise RuntimeError( 

722 "Protection produced a message that has uncompressable fields." 

723 ) 

724 

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

726 if len(piv) > COMPRESSION_BITS_N: 

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

728 

729 firstbyte = len(piv) 

730 if COSE_KID in unprotected: 

731 firstbyte |= COMPRESSION_BIT_K 

732 kid_data = unprotected.pop(COSE_KID) 

733 else: 

734 kid_data = b"" 

735 

736 if COSE_KID_CONTEXT in unprotected: 

737 firstbyte |= COMPRESSION_BIT_H 

738 kid_context = unprotected.pop(COSE_KID_CONTEXT) 

739 s = len(kid_context) 

740 if s > 255: 

741 raise ValueError("KID Context too long") 

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

743 else: 

744 s_kid_context = b"" 

745 

746 if COSE_COUNTERSIGNATURE0 in unprotected: 

747 firstbyte |= COMPRESSION_BIT_G 

748 

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

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

751 # signing. 

752 ciphertext += unprotected.pop(COSE_COUNTERSIGNATURE0) 

753 

754 if unprotected: 

755 raise RuntimeError( 

756 "Protection produced a message that has uncompressable fields." 

757 ) 

758 

759 if firstbyte: 

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

761 else: 

762 option = b"" 

763 

764 return (option, ciphertext) 

765 

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

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

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

769 OSCOAP. 

770 

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

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

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

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

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

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

777 """ 

778 

779 assert ( 

780 (request_id is None) == message.code.is_request() 

781 ), "Requestishness of code to protect does not match presence of request ID" 

782 

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

784 

785 protected = {} 

786 nonce = None 

787 unprotected = {} 

788 if request_id is not None: 

789 nonce, partial_iv_short = request_id.get_reusable_nonce_and_piv() 

790 if nonce is not None: 

791 partial_iv_generated_by = request_id.kid 

792 

793 if nonce is None: 

794 nonce, partial_iv_short = self._build_new_nonce() 

795 partial_iv_generated_by = self.sender_id 

796 

797 unprotected[COSE_PIV] = partial_iv_short 

798 

799 if message.code.is_request(): 

800 unprotected[COSE_KID] = self.sender_id 

801 

802 request_id = RequestIdentifiers( 

803 self.sender_id, 

804 partial_iv_short, 

805 nonce, 

806 can_reuse_nonce=None, 

807 request_code=outer_message.code, 

808 ) 

809 

810 if kid_context is True: 

811 if self.id_context is not None: 

812 unprotected[COSE_KID_CONTEXT] = self.id_context 

813 elif kid_context is not False: 

814 unprotected[COSE_KID_CONTEXT] = kid_context 

815 else: 

816 if self.responses_send_kid: 

817 unprotected[COSE_KID] = self.sender_id 

818 

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

820 if self.is_signing: 

821 unprotected[COSE_COUNTERSIGNATURE0] = b"" 

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

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

824 

825 outer_message.opt.oscore = option_data 

826 

827 external_aad = self._extract_external_aad( 

828 outer_message, request_id, local_is_sender=True 

829 ) 

830 

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

832 

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

834 

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

836 

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

838 

839 if self.is_signing: 

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

841 keystream = self._kdf_for_keystreams( 

842 partial_iv_generated_by, 

843 partial_iv_short, 

844 self.group_encryption_key, 

845 self.sender_id, 

846 INFO_TYPE_KEYSTREAM_REQUEST 

847 if message.code.is_request() 

848 else INFO_TYPE_KEYSTREAM_RESPONSE, 

849 ) 

850 encrypted_signature = _xor_bytes(signature, keystream) 

851 payload += encrypted_signature 

852 outer_message.payload = payload 

853 

854 # FIXME go through options section 

855 

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

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

858 # `if` and returning None? 

859 return outer_message, request_id 

860 

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

862 """Customization hook of the protect function 

863 

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

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

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

867 unprotect the response.""" 

868 return self.sender_key 

869 

870 def _split_message(self, message, request_id): 

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

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

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

874 options and the payload. 

875 

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

877 

878 if message.code.is_request(): 

879 outer_host = message.opt.uri_host 

880 proxy_uri = message.opt.proxy_uri 

881 

882 inner_message = message.copy( 

883 uri_host=None, 

884 uri_port=None, 

885 proxy_uri=None, 

886 proxy_scheme=None, 

887 ) 

888 inner_message.remote = None 

889 

890 if proxy_uri is not None: 

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

892 # components; extract, preserve and clear them. 

893 inner_message.set_request_uri(proxy_uri, set_uri_host=False) 

894 if inner_message.opt.proxy_uri is not None: 

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

896 outer_uri = inner_message.remote.uri_base 

897 inner_message.remote = None 

898 inner_message.opt.proxy_scheme = None 

899 

900 if message.opt.observe is None: 

901 outer_code = POST 

902 else: 

903 outer_code = FETCH 

904 else: 

905 outer_host = None 

906 proxy_uri = None 

907 

908 inner_message = message.copy() 

909 

910 outer_code = request_id.code_style.response 

911 

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

913 outer_message = Message( 

914 code=outer_code, 

915 uri_host=outer_host, 

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

917 ) 

918 if proxy_uri is not None: 

919 outer_message.set_request_uri(outer_uri) 

920 

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

922 if inner_message.payload: 

923 plaintext += bytes([0xFF]) 

924 plaintext += inner_message.payload 

925 

926 return outer_message, plaintext 

927 

928 def _build_new_nonce(self): 

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

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

931 as well.""" 

932 seqno = self.new_sequence_number() 

933 

934 partial_iv = seqno.to_bytes(5, "big") 

935 

936 return ( 

937 self._construct_nonce(partial_iv, self.sender_id), 

938 partial_iv.lstrip(b"\0") or b"\0", 

939 ) 

940 

941 # sequence number handling 

942 

943 def new_sequence_number(self): 

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

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

946 

947 May raise ContextUnavailable.""" 

948 retval = self.sender_sequence_number 

949 if retval >= MAX_SEQNO: 

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

951 self.sender_sequence_number += 1 

952 self.post_seqnoincrease() 

953 return retval 

954 

955 # implementation defined 

956 

957 @abc.abstractmethod 

958 def post_seqnoincrease(self): 

959 """Ensure that sender_sequence_number is stored""" 

960 raise 

961 

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

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

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

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

966 

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

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

969 behaivor is returning self. 

970 """ 

971 

972 # FIXME justify by moving into a mixin for CanProtectAndUnprotect 

973 return self # type: ignore 

974 

975 

976class CanUnprotect(BaseSecurityContext): 

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

978 assert ( 

979 (request_id is not None) == protected_message.code.is_response() 

980 ), "Requestishness of code to unprotect does not match presence of request ID" 

981 is_response = protected_message.code.is_response() 

982 

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

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

985 replay_error = None 

986 

987 protected_serialized, protected, unprotected, ciphertext = ( 

988 self._extract_encrypted0(protected_message) 

989 ) 

990 

991 if protected: 

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

993 

994 # FIXME check for duplicate keys in protected 

995 

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

997 # FIXME is this necessary? 

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

999 

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

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

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

1003 # valid check 

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

1005 

1006 if COSE_PIV not in unprotected: 

1007 if not is_response: 

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

1009 

1010 nonce = request_id.nonce 

1011 seqno = None # sentinel for not striking out anyting 

1012 partial_iv_short = request_id.partial_iv 

1013 partial_iv_generated_by = request_id.kid 

1014 else: 

1015 partial_iv_short = unprotected.pop(COSE_PIV) 

1016 partial_iv_generated_by = self.recipient_id 

1017 

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

1019 

1020 seqno = int.from_bytes(partial_iv_short, "big") 

1021 

1022 if not is_response: 

1023 if not self.recipient_replay_window.is_initialized(): 

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

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

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

1027 

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

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

1030 raise replay_error 

1031 

1032 request_id = RequestIdentifiers( 

1033 self.recipient_id, 

1034 partial_iv_short, 

1035 nonce, 

1036 can_reuse_nonce=replay_error is None, 

1037 request_code=protected_message.code, 

1038 ) 

1039 

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

1041 try: 

1042 alg_signature = self.alg_signature 

1043 except NameError: 

1044 raise DecodeError( 

1045 "Group messages can not be decoded with this non-group context" 

1046 ) 

1047 

1048 siglen = alg_signature.signature_length 

1049 if len(ciphertext) < siglen: 

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

1051 encrypted_signature = ciphertext[-siglen:] 

1052 

1053 keystream = self._kdf_for_keystreams( 

1054 partial_iv_generated_by, 

1055 partial_iv_short, 

1056 self.group_encryption_key, 

1057 self.recipient_id, 

1058 INFO_TYPE_KEYSTREAM_REQUEST 

1059 if protected_message.code.is_request() 

1060 else INFO_TYPE_KEYSTREAM_RESPONSE, 

1061 ) 

1062 signature = _xor_bytes(encrypted_signature, keystream) 

1063 

1064 ciphertext = ciphertext[:-siglen] 

1065 else: 

1066 signature = None 

1067 

1068 if unprotected: 

1069 raise DecodeError("Unsupported unprotected option") 

1070 

1071 if ( 

1072 len(ciphertext) < self.alg_aead.tag_bytes + 1 

1073 ): # +1 assures access to plaintext[0] (the code) 

1074 raise ProtectionInvalid("Ciphertext too short") 

1075 

1076 external_aad = self._extract_external_aad( 

1077 protected_message, request_id, local_is_sender=False 

1078 ) 

1079 enc_structure = ["Encrypt0", protected_serialized, external_aad] 

1080 aad = cbor.dumps(enc_structure) 

1081 

1082 key = self._get_recipient_key(protected_message) 

1083 

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

1085 

1086 self._post_decrypt_checks( 

1087 external_aad, plaintext, protected_message, request_id 

1088 ) 

1089 

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

1091 self.recipient_replay_window.strike_out(seqno) 

1092 

1093 if signature is not None: 

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

1095 alg_signature.verify( 

1096 signature, ciphertext, external_aad, self.recipient_public_key 

1097 ) 

1098 

1099 # FIXME add options from unprotected 

1100 

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

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

1103 

1104 try_initialize = ( 

1105 not self.recipient_replay_window.is_initialized() 

1106 and self.echo_recovery is not None 

1107 ) 

1108 if try_initialize: 

1109 if protected_message.code.is_request(): 

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

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

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

1113 self.recipient_replay_window.initialize_from_freshlyseen(seqno) 

1114 replay_error = None 

1115 else: 

1116 raise ReplayErrorWithEcho( 

1117 secctx=self, request_id=request_id, echo=self.echo_recovery 

1118 ) 

1119 else: 

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

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

1122 # match a request sent by this process. 

1123 # 

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

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

1126 # acting again on a retransmitted safe request whose response 

1127 # it did not cache. 

1128 # 

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

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

1131 # checked for a response anyway. 

1132 if seqno is not None: 

1133 self.recipient_replay_window.initialize_from_freshlyseen(seqno) 

1134 

1135 if replay_error is not None: 

1136 raise replay_error 

1137 

1138 if unprotected_message.code.is_request(): 

1139 if protected_message.opt.observe != 0: 

1140 unprotected_message.opt.observe = None 

1141 else: 

1142 if protected_message.opt.observe is not None: 

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

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

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

1146 # in this implementation accepted for passing around. 

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

1148 

1149 return unprotected_message, request_id 

1150 

1151 def _get_recipient_key(self, protected_message): 

1152 """Customization hook of the unprotect function 

1153 

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

1155 requests build it on demand.""" 

1156 return self.recipient_key 

1157 

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

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

1160 

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

1162 deterministic requests need to perform additional checks while AAD and 

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

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

1165 

1166 @staticmethod 

1167 def _uncompress(option_data, payload): 

1168 if option_data == b"": 

1169 firstbyte = 0 

1170 else: 

1171 firstbyte = option_data[0] 

1172 tail = option_data[1:] 

1173 

1174 unprotected = {} 

1175 

1176 if firstbyte & COMPRESSION_BITS_RESERVED: 

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

1178 

1179 pivsz = firstbyte & COMPRESSION_BITS_N 

1180 if pivsz: 

1181 if len(tail) < pivsz: 

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

1183 unprotected[COSE_PIV] = tail[:pivsz] 

1184 tail = tail[pivsz:] 

1185 

1186 if firstbyte & COMPRESSION_BIT_H: 

1187 # kid context hint 

1188 s = tail[0] 

1189 if len(tail) - 1 < s: 

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

1191 tail = tail[1:] 

1192 unprotected[COSE_KID_CONTEXT] = tail[:s] 

1193 tail = tail[s:] 

1194 

1195 if firstbyte & COMPRESSION_BIT_K: 

1196 kid = tail 

1197 unprotected[COSE_KID] = kid 

1198 

1199 if firstbyte & COMPRESSION_BIT_G: 

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

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

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

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

1204 unprotected[COSE_COUNTERSIGNATURE0] = b"" 

1205 

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

1207 

1208 @classmethod 

1209 def _extract_encrypted0(cls, message): 

1210 if message.opt.oscore is None: 

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

1212 

1213 protected_serialized, protected, unprotected, ciphertext = cls._uncompress( 

1214 message.opt.oscore, message.payload 

1215 ) 

1216 return protected_serialized, protected, unprotected, ciphertext 

1217 

1218 # implementation defined 

1219 

1220 def context_for_response(self) -> CanProtect: 

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

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

1223 same context.""" 

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

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

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

1227 # already? 

1228 

1229 # FIXME justify by moving into a mixin for CanProtectAndUnprotect 

1230 return self # type: ignore 

1231 

1232 

1233class SecurityContextUtils(BaseSecurityContext): 

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

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

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

1237 """ 

1238 if out_type == "Key": 

1239 out_bytes = self.alg_aead.key_bytes 

1240 elif out_type == "IV": 

1241 out_bytes = self.alg_aead.iv_bytes 

1242 elif out_type == "Group Encryption Key": 

1243 out_bytes = self.alg_signature_enc.key_bytes 

1244 else: 

1245 raise ValueError("Output type not recognized") 

1246 

1247 info = [ 

1248 role_id, 

1249 self.id_context, 

1250 self.alg_aead.value, 

1251 out_type, 

1252 out_bytes, 

1253 ] 

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

1255 

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

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

1258 

1259 out_bytes = self.alg_signature.signature_length 

1260 

1261 assert out_type in ( 

1262 INFO_TYPE_KEYSTREAM_REQUEST, 

1263 INFO_TYPE_KEYSTREAM_RESPONSE, 

1264 ), "Output type not recognized" 

1265 

1266 info = [ 

1267 piv_generated_by, 

1268 self.id_context, 

1269 out_type, 

1270 out_bytes, 

1271 ] 

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

1273 

1274 def _kdf_lowlevel(self, salt: bytes, ikm: bytes, info: list, l: int) -> bytes: # noqa: E741 (signature follows RFC definition) 

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

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

1277 

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

1279 

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

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

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

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

1284 hkdf = HKDF( 

1285 algorithm=self.hashfun, 

1286 length=l, 

1287 salt=salt, 

1288 info=cbor.dumps(info), 

1289 backend=_hash_backend, 

1290 ) 

1291 expanded = hkdf.derive(ikm) 

1292 return expanded 

1293 

1294 def derive_keys(self, master_salt, master_secret): 

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

1296 hash function and id_context already configured beforehand, and from 

1297 the passed salt and secret.""" 

1298 

1299 self.sender_key = self._kdf(master_salt, master_secret, self.sender_id, "Key") 

1300 self.recipient_key = self._kdf( 

1301 master_salt, master_secret, self.recipient_id, "Key" 

1302 ) 

1303 

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

1305 

1306 # really more of the Credentials interface 

1307 

1308 def get_oscore_context_for(self, unprotected): 

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

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

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

1312 

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

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

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

1316 objects here. 

1317 """ 

1318 if ( 

1319 unprotected.get(COSE_KID, None) == self.recipient_id 

1320 and unprotected.get(COSE_KID_CONTEXT, None) == self.id_context 

1321 ): 

1322 return self 

1323 

1324 

1325class ReplayWindow: 

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

1327 

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

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

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

1331 leading ones (think floating point normalization) happen. 

1332 

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

1334 >>> w.initialize_empty() 

1335 >>> w.strike_out(5) 

1336 >>> w.is_valid(3) 

1337 True 

1338 >>> w.is_valid(5) 

1339 False 

1340 >>> w.strike_out(0) 

1341 >>> w.strike_out(1) 

1342 >>> w.strike_out(2) 

1343 >>> w.is_valid(1) 

1344 False 

1345 

1346 Jumping ahead by the window size invalidates older numbers: 

1347 

1348 >>> w.is_valid(4) 

1349 True 

1350 >>> w.strike_out(35) 

1351 >>> w.is_valid(4) 

1352 True 

1353 >>> w.strike_out(36) 

1354 >>> w.is_valid(4) 

1355 False 

1356 

1357 Usage safety 

1358 ------------ 

1359 

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

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

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

1363 

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

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

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

1367 

1368 Stability 

1369 --------- 

1370 

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

1372 detail of the SecurityContext implementation(s). 

1373 """ 

1374 

1375 _index = None 

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

1377 _bitfield = None 

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

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

1380 already seen.""" 

1381 

1382 def __init__(self, size, strike_out_callback): 

1383 self._size = size 

1384 self.strike_out_callback = strike_out_callback 

1385 

1386 def is_initialized(self): 

1387 return self._index is not None 

1388 

1389 def initialize_empty(self): 

1390 self._index = 0 

1391 self._bitfield = 0 

1392 

1393 def initialize_from_persisted(self, persisted): 

1394 self._index = persisted["index"] 

1395 self._bitfield = persisted["bitfield"] 

1396 

1397 def initialize_from_freshlyseen(self, seen): 

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

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

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

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

1402 ones as valid.""" 

1403 self._index = seen 

1404 self._bitfield = 1 

1405 

1406 def is_valid(self, number): 

1407 if number < self._index: 

1408 return False 

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

1410 return True 

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

1412 

1413 def strike_out(self, number): 

1414 if not self.is_valid(number): 

1415 raise ValueError( 

1416 "Sequence number is not valid any more and " 

1417 "thus can't be removed from the window" 

1418 ) 

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

1420 if overshoot > 0: 

1421 self._index += overshoot 

1422 self._bitfield >>= overshoot 

1423 assert self.is_valid(number), "Sequence number was not valid before strike-out" 

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

1425 

1426 self.strike_out_callback() 

1427 

1428 def persist(self): 

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

1430 to recreated the replay window.""" 

1431 

1432 return {"index": self._index, "bitfield": self._bitfield} 

1433 

1434 

1435class FilesystemSecurityContext( 

1436 CanProtect, CanUnprotect, SecurityContextUtils, credentials._Objectish 

1437): 

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

1439 containing 

1440 

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

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

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

1444 only for the user) 

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

1446 process needs write access to) 

1447 

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

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

1450 is sufficient. 

1451 

1452 .. warning:: 

1453 

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

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

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

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

1458 

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

1460 a context by to concurrent programs. 

1461 

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

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

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

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

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

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

1468 owned by him. 

1469 

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

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

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

1473 sequence_number_chunksize_start up to sequence_number_chunksize_limit. 

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

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

1476 Appendix B.1.2 to recover. 

1477 """ 

1478 

1479 # possibly overridden in constructor 

1480 alg_aead = algorithms[DEFAULT_ALGORITHM] 

1481 

1482 class LoadError(ValueError): 

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

1484 faulty security context""" 

1485 

1486 def __init__( 

1487 self, 

1488 basedir: str, 

1489 sequence_number_chunksize_start=10, 

1490 sequence_number_chunksize_limit=10000, 

1491 ): 

1492 self.basedir = basedir 

1493 

1494 self.lockfile: Optional[filelock.FileLock] = filelock.FileLock( 

1495 os.path.join(basedir, "lock") 

1496 ) 

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

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

1499 try: 

1500 self.lockfile.acquire(timeout=0.001) 

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

1502 except: # noqa: E722 

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

1504 self.lockfile = None 

1505 raise 

1506 

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

1508 # would be a terrible burden. 

1509 self.echo_recovery = secrets.token_bytes(8) 

1510 

1511 try: 

1512 self._load() 

1513 except KeyError as k: 

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

1515 

1516 self.sequence_number_chunksize_start = sequence_number_chunksize_start 

1517 self.sequence_number_chunksize_limit = sequence_number_chunksize_limit 

1518 self.sequence_number_chunksize = sequence_number_chunksize_start 

1519 

1520 self.sequence_number_persisted = self.sender_sequence_number 

1521 

1522 def _load(self): 

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

1524 # catch that 

1525 

1526 data = {} 

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

1528 try: 

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

1530 filedata = json.load(f) 

1531 except FileNotFoundError: 

1532 continue 

1533 

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

1535 if key.endswith("_hex"): 

1536 key = key[:-4] 

1537 value = binascii.unhexlify(value) 

1538 elif key.endswith("_ascii"): 

1539 key = key[:-6] 

1540 value = value.encode("ascii") 

1541 

1542 if key in data: 

1543 raise self.LoadError( 

1544 "Datum %r present in multiple input files at %r." 

1545 % (key, self.basedir) 

1546 ) 

1547 

1548 data[key] = value 

1549 

1550 self.alg_aead = algorithms[data.get("algorithm", DEFAULT_ALGORITHM)] 

1551 self.hashfun = hashfunctions[data.get("kdf-hashfun", DEFAULT_HASHFUNCTION)] 

1552 

1553 windowsize = data.get("window", DEFAULT_WINDOWSIZE) 

1554 if not isinstance(windowsize, int): 

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

1556 

1557 self.sender_id = data["sender-id"] 

1558 self.recipient_id = data["recipient-id"] 

1559 

1560 if ( 

1561 max(len(self.sender_id), len(self.recipient_id)) 

1562 > self.alg_aead.iv_bytes - 6 

1563 ): 

1564 raise self.LoadError( 

1565 "Sender or Recipient ID too long (maximum length %s for this algorithm)" 

1566 % (self.alg_aead.iv_bytes - 6) 

1567 ) 

1568 

1569 master_secret = data["secret"] 

1570 master_salt = data.get("salt", b"") 

1571 self.id_context = data.get("id-context", None) 

1572 

1573 self.derive_keys(master_salt, master_secret) 

1574 

1575 self.recipient_replay_window = ReplayWindow( 

1576 windowsize, self._replay_window_changed 

1577 ) 

1578 try: 

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

1580 sequence = json.load(f) 

1581 except FileNotFoundError: 

1582 self.sender_sequence_number = 0 

1583 self.recipient_replay_window.initialize_empty() 

1584 self.replay_window_persisted = True 

1585 else: 

1586 self.sender_sequence_number = int(sequence["next-to-send"]) 

1587 received = sequence["received"] 

1588 if received == "unknown": 

1589 # The replay window will stay uninitialized, which triggers 

1590 # Echo recovery 

1591 self.replay_window_persisted = False 

1592 else: 

1593 try: 

1594 self.recipient_replay_window.initialize_from_persisted(received) 

1595 except (ValueError, TypeError, KeyError): 

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

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

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

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

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

1601 # nonce reuse which tampering with the replay window file 

1602 # already does. 

1603 raise self.LoadError( 

1604 "Persisted replay window state was not understood" 

1605 ) 

1606 self.replay_window_persisted = True 

1607 

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

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

1610 # 

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

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

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

1614 # operation to conclude. 

1615 def _store(self): 

1616 tmphand, tmpnam = tempfile.mkstemp( 

1617 dir=self.basedir, prefix=".sequence-", suffix=".json", text=True 

1618 ) 

1619 

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

1621 if not self.replay_window_persisted: 

1622 data["received"] = "unknown" 

1623 else: 

1624 data["received"] = self.recipient_replay_window.persist() 

1625 

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

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

1628 # shutting down. 

1629 # 

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

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

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

1633 with io.open(tmphand, "wb") as tmpfile: 

1634 tmpfile.write(json.dumps(data).encode("utf8")) 

1635 tmpfile.flush() 

1636 os.fsync(tmpfile.fileno()) 

1637 

1638 os.replace(tmpnam, os.path.join(self.basedir, "sequence.json")) 

1639 

1640 def _replay_window_changed(self): 

1641 if self.replay_window_persisted: 

1642 # Just remove the sequence numbers once from the file 

1643 self.replay_window_persisted = False 

1644 self._store() 

1645 

1646 def post_seqnoincrease(self): 

1647 if self.sender_sequence_number > self.sequence_number_persisted: 

1648 self.sequence_number_persisted += self.sequence_number_chunksize 

1649 

1650 self.sequence_number_chunksize = min( 

1651 self.sequence_number_chunksize * 2, self.sequence_number_chunksize_limit 

1652 ) 

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

1654 self._store() 

1655 

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

1657 # numbers to 1 to force persisting on every step 

1658 assert ( 

1659 self.sender_sequence_number <= self.sequence_number_persisted 

1660 ), "Using a sequence number that has been persisted already" 

1661 

1662 def _destroy(self): 

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

1664 unusable. 

1665 

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

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

1668 resumption without wasting digits or round-trips. 

1669 """ 

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

1671 

1672 self.replay_window_persisted = True 

1673 self.sequence_number_persisted = self.sender_sequence_number 

1674 self._store() 

1675 

1676 del self.sender_key 

1677 del self.recipient_key 

1678 

1679 os.unlink(self.lockfile.lock_file) 

1680 self.lockfile.release() 

1681 

1682 self.lockfile = None 

1683 

1684 def __del__(self): 

1685 if self.lockfile is not None: 

1686 self._destroy() 

1687 

1688 @classmethod 

1689 def from_item(cls, init_data): 

1690 """Overriding _Objectish's from_item because the parameter name for 

1691 basedir is contextfile for historical reasons""" 

1692 

1693 def constructor( 

1694 basedir: Optional[str] = None, contextfile: Optional[str] = None 

1695 ): 

1696 if basedir is not None and contextfile is not None: 

1697 raise credentials.CredentialsLoadError( 

1698 "Conflicting arguments basedir and contextfile; just contextfile instead" 

1699 ) 

1700 if basedir is None and contextfile is None: 

1701 raise credentials.CredentialsLoadError("Missing item 'basedir'") 

1702 if contextfile is not None: 

1703 warnings.warn( 

1704 "Property contextfile was renamed to basedir in OSCORE credentials entries", 

1705 DeprecationWarning, 

1706 stacklevel=2, 

1707 ) 

1708 basedir = contextfile 

1709 assert ( 

1710 basedir is not None 

1711 ) # This helps mypy which would otherwise not see that the above ensures this already 

1712 return cls(basedir) 

1713 

1714 return credentials._call_from_structureddata( 

1715 constructor, cls.__name__, init_data 

1716 ) 

1717 

1718 def find_all_used_contextless_oscore_kid(self) -> set[bytes]: 

1719 return set((self.recipient_id,)) 

1720 

1721 

1722class GroupContext(ContextWhereExternalAadIsGroup, BaseSecurityContext): 

1723 is_signing = True 

1724 responses_send_kid = True 

1725 

1726 @abc.abstractproperty 

1727 def private_key(self): 

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

1729 

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

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

1732 is found.""" 

1733 

1734 @abc.abstractproperty 

1735 def recipient_public_key(self): 

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

1737 

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

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

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

1741 

1742 

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

1744 """A context for an OSCORE group 

1745 

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

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

1748 

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

1750 to be usable securely. 

1751 """ 

1752 

1753 # set during initialization (making all those attributes rather than 

1754 # possibly properties as they might be in super) 

1755 sender_id = None 

1756 id_context = None # type: ignore 

1757 private_key = None 

1758 alg_aead = None 

1759 hashfun = None # type: ignore 

1760 alg_signature = None 

1761 alg_signature_enc = None 

1762 alg_pairwise_key_agreement = None 

1763 sender_auth_cred = None 

1764 group_manager_cred = None 

1765 cred_fmt = None 

1766 

1767 def __init__( 

1768 self, 

1769 alg_aead, 

1770 hashfun, 

1771 alg_signature, 

1772 alg_signature_enc, 

1773 alg_pairwise_key_agreement, 

1774 group_id, 

1775 master_secret, 

1776 master_salt, 

1777 sender_id, 

1778 private_key, 

1779 sender_auth_cred, 

1780 peers, 

1781 group_manager_cred=None, 

1782 cred_fmt=COSE_KCCS, 

1783 ): 

1784 self.sender_id = sender_id 

1785 self.id_context = group_id 

1786 self.private_key = private_key 

1787 self.alg_aead = alg_aead 

1788 self.hashfun = hashfun 

1789 self.alg_signature = alg_signature 

1790 self.alg_signature_enc = alg_signature_enc 

1791 self.alg_pairwise_key_agreement = alg_pairwise_key_agreement 

1792 self.sender_auth_cred = sender_auth_cred 

1793 self.group_manager_cred = group_manager_cred 

1794 self.cred_fmt = cred_fmt 

1795 

1796 self.peers = peers.keys() 

1797 self.recipient_public_keys = { 

1798 k: self._parse_credential(v) for (k, v) in peers.items() 

1799 } 

1800 self.recipient_auth_creds = peers 

1801 self.recipient_replay_windows = {} 

1802 for k in self.peers: 

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

1804 w = ReplayWindow(32, lambda: None) 

1805 w.initialize_empty() 

1806 self.recipient_replay_windows[k] = w 

1807 

1808 self.derive_keys(master_salt, master_secret) 

1809 self.sender_sequence_number = 0 

1810 

1811 sender_public_key = self._parse_credential(sender_auth_cred) 

1812 if ( 

1813 self.alg_signature.public_from_private(self.private_key) 

1814 != sender_public_key 

1815 ): 

1816 raise ValueError( 

1817 "The key in the provided sender credential does not match the private key" 

1818 ) 

1819 

1820 def _parse_credential(self, credential: bytes): 

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

1822 AlgorithmCountersign needs) from credentials. This raises a ValueError 

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

1824 parameters do not match those configured in the group. 

1825 

1826 This currently discards any information that is present in the 

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

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

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

1830 """ 

1831 

1832 if self.cred_fmt != COSE_KCCS: 

1833 raise ValueError( 

1834 "Credential parsing is currently only implemented for CCSs" 

1835 ) 

1836 

1837 try: 

1838 parsed = cbor.loads(credential) 

1839 except cbor.CBORDecodeError as e: 

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

1841 

1842 CWT_CLAIM_CNF = 8 

1843 CWT_CNF_COSE_KEY = 1 

1844 if ( 

1845 not isinstance(parsed, dict) 

1846 or CWT_CLAIM_CNF not in parsed 

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

1848 or CWT_CNF_COSE_KEY not in parsed[CWT_CLAIM_CNF] 

1849 ): 

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

1851 

1852 COSE_KEY_COMMON_KTY = 1 

1853 COSE_KTY_OKP = 1 

1854 COSE_KTY_EC2 = 2 

1855 COSE_KEY_COMMON_ALG = 3 

1856 COSE_KEY_OKP_CRV = -1 

1857 COSE_KEY_OKP_X = -2 

1858 COSE_KEY_EC2_X = -2 

1859 COSE_KEY_EC2_Y = -3 

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

1861 cose_key = parsed[CWT_CLAIM_CNF][CWT_CNF_COSE_KEY] 

1862 if not isinstance(cose_key, dict): 

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

1864 

1865 if ( 

1866 cose_key.get(COSE_KEY_COMMON_KTY) == COSE_KTY_OKP 

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

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

1869 and COSE_KEY_OKP_X in cose_key 

1870 ): 

1871 return cose_key[COSE_KEY_OKP_X] 

1872 elif ( 

1873 cose_key.get(COSE_KEY_COMMON_KTY) == COSE_KTY_EC2 

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

1875 and COSE_KEY_EC2_X in cose_key 

1876 and COSE_KEY_EC2_Y in cose_key 

1877 ): 

1878 return ECDSA_SHA256_P256().from_public_parts( 

1879 x=cose_key[COSE_KEY_EC2_X], 

1880 y=cose_key[COSE_KEY_EC2_Y], 

1881 ) 

1882 else: 

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

1884 

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

1886 

1887 def __repr__(self): 

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

1889 type(self).__name__, 

1890 self.id_context.hex(), 

1891 self.sender_id.hex(), 

1892 len(self.peers), 

1893 ) 

1894 

1895 @property 

1896 def recipient_public_key(self): 

1897 raise RuntimeError( 

1898 "Group context without key indication was used for verification" 

1899 ) 

1900 

1901 def derive_keys(self, master_salt, master_secret): 

1902 # FIXME unify with parent? 

1903 

1904 self.sender_key = self._kdf(master_salt, master_secret, self.sender_id, "Key") 

1905 self.recipient_keys = { 

1906 recipient_id: self._kdf(master_salt, master_secret, recipient_id, "Key") 

1907 for recipient_id in self.peers 

1908 } 

1909 

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

1911 

1912 # but this one is new 

1913 

1914 self.group_encryption_key = self._kdf( 

1915 master_salt, master_secret, b"", "Group Encryption Key" 

1916 ) 

1917 

1918 def post_seqnoincrease(self): 

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

1920 

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

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

1923 # would not run through here 

1924 try: 

1925 sender_kid = unprotected_bag[COSE_KID] 

1926 except KeyError: 

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

1928 

1929 if COSE_COUNTERSIGNATURE0 in unprotected_bag: 

1930 return _GroupContextAspect(self, sender_kid) 

1931 else: 

1932 return _PairwiseContextAspect(self, sender_kid) 

1933 

1934 def get_oscore_context_for(self, unprotected): 

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

1936 return None 

1937 

1938 kid = unprotected.get(COSE_KID, None) 

1939 if kid in self.peers: 

1940 if COSE_COUNTERSIGNATURE0 in unprotected: 

1941 return _GroupContextAspect(self, kid) 

1942 elif self.recipient_public_keys[kid] is DETERMINISTIC_KEY: 

1943 return _DeterministicUnprotectProtoAspect(self, kid) 

1944 else: 

1945 return _PairwiseContextAspect(self, kid) 

1946 

1947 def find_all_used_contextless_oscore_kid(self) -> set[bytes]: 

1948 # not conflicting: groups always send KID Context 

1949 return set() 

1950 

1951 # yet to stabilize... 

1952 

1953 def pairwise_for(self, recipient_id): 

1954 return _PairwiseContextAspect(self, recipient_id) 

1955 

1956 def for_sending_deterministic_requests( 

1957 self, deterministic_id, target_server: Optional[bytes] 

1958 ): 

1959 return _DeterministicProtectProtoAspect(self, deterministic_id, target_server) 

1960 

1961 

1962class _GroupContextAspect(GroupContext, CanUnprotect, SecurityContextUtils): 

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

1964 

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

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

1967 

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

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

1970 context_for_response before it comes to that). 

1971 """ 

1972 

1973 def __init__(self, groupcontext: GroupContext, recipient_id: bytes) -> None: 

1974 self.groupcontext = groupcontext 

1975 self.recipient_id = recipient_id 

1976 

1977 def __repr__(self): 

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

1979 type(self).__name__, 

1980 self.groupcontext, 

1981 self.recipient_id.hex(), 

1982 ) 

1983 

1984 private_key = None 

1985 

1986 # not inline because the equivalent lambda would not be recognized by mypy 

1987 # (workaround for <https://github.com/python/mypy/issues/8083>) 

1988 @property 

1989 def id_context(self): 

1990 return self.groupcontext.id_context 

1991 

1992 @property 

1993 def alg_aead(self): 

1994 return self.groupcontext.alg_aead 

1995 

1996 @property 

1997 def alg_signature(self): 

1998 return self.groupcontext.alg_signature 

1999 

2000 @property 

2001 def alg_signature_enc(self): 

2002 return self.groupcontext.alg_signature_enc 

2003 

2004 @property 

2005 def alg_pairwise_key_agreement(self): 

2006 return self.groupcontext.alg_pairwise_key_agreement 

2007 

2008 @property 

2009 def group_manager_cred(self): 

2010 return self.groupcontext.group_manager_cred 

2011 

2012 @property 

2013 def common_iv(self): 

2014 return self.groupcontext.common_iv 

2015 

2016 @property 

2017 def hashfun(self): 

2018 return self.groupcontext.hashfun 

2019 

2020 @property 

2021 def group_encryption_key(self): 

2022 return self.groupcontext.group_encryption_key 

2023 

2024 @property 

2025 def recipient_key(self): 

2026 return self.groupcontext.recipient_keys[self.recipient_id] 

2027 

2028 @property 

2029 def recipient_public_key(self): 

2030 return self.groupcontext.recipient_public_keys[self.recipient_id] 

2031 

2032 @property 

2033 def recipient_auth_cred(self): 

2034 return self.groupcontext.recipient_auth_creds[self.recipient_id] 

2035 

2036 @property 

2037 def recipient_replay_window(self): 

2038 return self.groupcontext.recipient_replay_windows[self.recipient_id] 

2039 

2040 def context_for_response(self): 

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

2042 

2043 @property 

2044 def sender_auth_cred(self): 

2045 raise RuntimeError( 

2046 "Could relay the sender auth credential from the group context, but it shouldn't matter here" 

2047 ) 

2048 

2049 

2050class _PairwiseContextAspect( 

2051 GroupContext, CanProtect, CanUnprotect, SecurityContextUtils 

2052): 

2053 is_signing = False 

2054 

2055 def __init__(self, groupcontext, recipient_id): 

2056 self.groupcontext = groupcontext 

2057 self.recipient_id = recipient_id 

2058 

2059 shared_secret = self.alg_pairwise_key_agreement.staticstatic( 

2060 self.groupcontext.private_key, 

2061 self.groupcontext.recipient_public_keys[recipient_id], 

2062 ) 

2063 

2064 self.sender_key = self._kdf( 

2065 self.groupcontext.sender_key, 

2066 ( 

2067 self.groupcontext.sender_auth_cred 

2068 + self.groupcontext.recipient_auth_creds[recipient_id] 

2069 + shared_secret 

2070 ), 

2071 self.groupcontext.sender_id, 

2072 "Key", 

2073 ) 

2074 self.recipient_key = self._kdf( 

2075 self.groupcontext.recipient_keys[recipient_id], 

2076 ( 

2077 self.groupcontext.recipient_auth_creds[recipient_id] 

2078 + self.groupcontext.sender_auth_cred 

2079 + shared_secret 

2080 ), 

2081 self.recipient_id, 

2082 "Key", 

2083 ) 

2084 

2085 def __repr__(self): 

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

2087 type(self).__name__, 

2088 self.groupcontext, 

2089 self.recipient_id.hex(), 

2090 ) 

2091 

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

2093 

2094 # not inline because the equivalent lambda would not be recognized by mypy 

2095 # (workaround for <https://github.com/python/mypy/issues/8083>) 

2096 @property 

2097 def id_context(self): 

2098 return self.groupcontext.id_context 

2099 

2100 @property 

2101 def alg_aead(self): 

2102 return self.groupcontext.alg_aead 

2103 

2104 @property 

2105 def hashfun(self): 

2106 return self.groupcontext.hashfun 

2107 

2108 @property 

2109 def alg_signature(self): 

2110 return self.groupcontext.alg_signature 

2111 

2112 @property 

2113 def alg_signature_enc(self): 

2114 return self.groupcontext.alg_signature_enc 

2115 

2116 @property 

2117 def alg_pairwise_key_agreement(self): 

2118 return self.groupcontext.alg_pairwise_key_agreement 

2119 

2120 @property 

2121 def group_manager_cred(self): 

2122 return self.groupcontext.group_manager_cred 

2123 

2124 @property 

2125 def common_iv(self): 

2126 return self.groupcontext.common_iv 

2127 

2128 @property 

2129 def sender_id(self): 

2130 return self.groupcontext.sender_id 

2131 

2132 @property 

2133 def recipient_auth_cred(self): 

2134 return self.groupcontext.recipient_auth_creds[self.recipient_id] 

2135 

2136 @property 

2137 def sender_auth_cred(self): 

2138 return self.groupcontext.sender_auth_cred 

2139 

2140 @property 

2141 def recipient_replay_window(self): 

2142 return self.groupcontext.recipient_replay_windows[self.recipient_id] 

2143 

2144 # Set at initialization 

2145 recipient_key = None 

2146 sender_key = None 

2147 

2148 @property 

2149 def sender_sequence_number(self): 

2150 return self.groupcontext.sender_sequence_number 

2151 

2152 @sender_sequence_number.setter 

2153 def sender_sequence_number(self, new): 

2154 self.groupcontext.sender_sequence_number = new 

2155 

2156 def post_seqnoincrease(self): 

2157 self.groupcontext.post_seqnoincrease() 

2158 

2159 # same here -- not needed because not signing 

2160 private_key = property(post_seqnoincrease) 

2161 recipient_public_key = property(post_seqnoincrease) 

2162 

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

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

2165 raise DecodeError( 

2166 "Response coming from a different server than requested, not attempting to decrypt" 

2167 ) 

2168 

2169 if COSE_COUNTERSIGNATURE0 in unprotected_bag: 

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

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

2172 # members. 

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

2174 else: 

2175 return self 

2176 

2177 

2178class _DeterministicProtectProtoAspect( 

2179 ContextWhereExternalAadIsGroup, CanProtect, SecurityContextUtils 

2180): 

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

2182 

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

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

2185 

2186 deterministic_hashfun = hashes.SHA256() 

2187 

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

2189 self.groupcontext = groupcontext 

2190 self.sender_id = sender_id 

2191 self.target_server = target_server 

2192 

2193 def __repr__(self): 

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

2195 type(self).__name__, 

2196 self.groupcontext, 

2197 self.sender_id.hex(), 

2198 "limited to responses from %s" % self.target_server 

2199 if self.target_server is not None 

2200 else "", 

2201 ) 

2202 

2203 def new_sequence_number(self): 

2204 return 0 

2205 

2206 def post_seqnoincrease(self): 

2207 pass 

2208 

2209 def context_from_response(self, unprotected_bag): 

2210 if self.target_server is None: 

2211 if COSE_KID not in unprotected_bag: 

2212 raise DecodeError( 

2213 "Server did not send a KID and no particular one was addressed" 

2214 ) 

2215 else: 

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

2217 raise DecodeError( 

2218 "Response coming from a different server than requested, not attempting to decrypt" 

2219 ) 

2220 

2221 if COSE_COUNTERSIGNATURE0 not in unprotected_bag: 

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

2223 raise DecodeError( 

2224 "Response to deterministic request came from unsecure pairwise context" 

2225 ) 

2226 

2227 return _GroupContextAspect( 

2228 self.groupcontext, unprotected_bag.get(COSE_KID, self.target_server) 

2229 ) 

2230 

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

2232 if outer_message.code.is_response(): 

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

2234 

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

2236 

2237 h = hashes.Hash(self.deterministic_hashfun) 

2238 h.update(basekey) 

2239 h.update(aad) 

2240 h.update(plaintext) 

2241 request_hash = h.finalize() 

2242 

2243 outer_message.opt.request_hash = request_hash 

2244 outer_message.code = FETCH 

2245 

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

2247 # for the benefit of the response parsing later 

2248 request_id.request_hash = request_hash 

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

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

2251 request_id.can_reuse_nonce = False 

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

2253 

2254 return self._kdf(basekey, request_hash, self.sender_id, "Key") 

2255 

2256 # details needed for various operations, especially eAAD generation 

2257 

2258 # not inline because the equivalent lambda would not be recognized by mypy 

2259 # (workaround for <https://github.com/python/mypy/issues/8083>) 

2260 @property 

2261 def alg_aead(self): 

2262 return self.groupcontext.alg_aead 

2263 

2264 @property 

2265 def hashfun(self): 

2266 return self.groupcontext.hashfun 

2267 

2268 @property 

2269 def common_iv(self): 

2270 return self.groupcontext.common_iv 

2271 

2272 @property 

2273 def id_context(self): 

2274 return self.groupcontext.id_context 

2275 

2276 @property 

2277 def alg_signature(self): 

2278 return self.groupcontext.alg_signature 

2279 

2280 

2281class _DeterministicUnprotectProtoAspect( 

2282 ContextWhereExternalAadIsGroup, CanUnprotect, SecurityContextUtils 

2283): 

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

2285 

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

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

2288 

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

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

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

2292 echo_recovery = None 

2293 

2294 deterministic_hashfun = hashes.SHA256() 

2295 

2296 class ZeroIsAlwaysValid: 

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

2298 

2299 def is_initialized(self): 

2300 return True 

2301 

2302 def is_valid(self, number): 

2303 # No particular reason to be lax here 

2304 return number == 0 

2305 

2306 def strike_out(self, number): 

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

2308 # request_id.can_reuse_nonce = False 

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

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

2311 pass 

2312 

2313 def persist(self): 

2314 pass 

2315 

2316 def __init__(self, groupcontext, recipient_id): 

2317 self.groupcontext = groupcontext 

2318 self.recipient_id = recipient_id 

2319 

2320 self.recipient_replay_window = self.ZeroIsAlwaysValid() 

2321 

2322 def __repr__(self): 

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

2324 type(self).__name__, 

2325 self.groupcontext, 

2326 self.recipient_id.hex(), 

2327 ) 

2328 

2329 def context_for_response(self): 

2330 return self.groupcontext 

2331 

2332 def _get_recipient_key(self, protected_message): 

2333 return self._kdf( 

2334 self.groupcontext.recipient_keys[self.recipient_id], 

2335 protected_message.opt.request_hash, 

2336 self.recipient_id, 

2337 "Key", 

2338 ) 

2339 

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

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

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

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

2344 # what's spec'd for here) 

2345 raise ProtectionInvalid("Request was not safe") 

2346 

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

2348 

2349 h = hashes.Hash(self.deterministic_hashfun) 

2350 h.update(basekey) 

2351 h.update(aad) 

2352 h.update(plaintext) 

2353 request_hash = h.finalize() 

2354 

2355 if request_hash != protected_message.opt.request_hash: 

2356 raise ProtectionInvalid( 

2357 "Client's hash of the plaintext diverges from the actual request hash" 

2358 ) 

2359 

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

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

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

2363 request_id.request_hash = request_hash 

2364 request_id.can_reuse_nonce = False 

2365 

2366 # details needed for various operations, especially eAAD generation 

2367 

2368 # not inline because the equivalent lambda would not be recognized by mypy 

2369 # (workaround for <https://github.com/python/mypy/issues/8083>) 

2370 @property 

2371 def alg_aead(self): 

2372 return self.groupcontext.alg_aead 

2373 

2374 @property 

2375 def hashfun(self): 

2376 return self.groupcontext.hashfun 

2377 

2378 @property 

2379 def common_iv(self): 

2380 return self.groupcontext.common_iv 

2381 

2382 @property 

2383 def id_context(self): 

2384 return self.groupcontext.id_context 

2385 

2386 @property 

2387 def alg_signature(self): 

2388 return self.groupcontext.alg_signature 

2389 

2390 

2391def verify_start(message): 

2392 """Extract the unprotected COSE options from a 

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

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

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

2396 

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

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

2399 

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

2401 

2402 return unprotected 

2403 

2404 

2405_getattr__ = deprecation_getattr( 

2406 { 

2407 "COSE_COUNTERSINGATURE0": "COSE_COUNTERSIGNATURE0", 

2408 "Algorithm": "AeadAlgorithm", 

2409 }, 

2410 globals(), 

2411)