openbadgeslib 4.4.2__tar.gz → 4.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/CONTRIBUTING.md +3 -2
  2. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/Changelog.txt +44 -0
  3. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/PKG-INFO +9 -7
  4. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/README.md +5 -3
  5. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/config.ini.example +7 -0
  6. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/confparser.py +48 -1
  7. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/issue.py +6 -0
  8. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/mail.py +6 -4
  9. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob1/verifier.py +4 -1
  10. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob2/verifier.py +47 -8
  11. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/publish.py +23 -2
  12. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/oid4vci/handler.py +29 -7
  13. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/oid4vci/memory_store.py +12 -2
  14. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/oid4vci/metadata.py +11 -4
  15. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/oid4vci/offer.py +41 -15
  16. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/oid4vci/reconcile.py +26 -10
  17. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/oid4vci/sqlite_store.py +31 -4
  18. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/oid4vci/store.py +6 -1
  19. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/openbadges_publish.py +4 -3
  20. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/util.py +1 -1
  21. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib.egg-info/PKG-INFO +9 -7
  22. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib.egg-info/requires.txt +3 -3
  23. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/pyproject.toml +7 -4
  24. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_docs.py +12 -0
  25. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_mail.py +4 -1
  26. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob1_signer.py +3 -0
  27. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob1_verifier.py +24 -0
  28. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob2_error_branches.py +6 -3
  29. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob2_verifier.py +54 -0
  30. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_publish_cli.py +13 -0
  31. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_oid4vci_config.py +45 -1
  32. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_oid4vci_protocol.py +109 -4
  33. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_oid4vci_reservations.py +104 -5
  34. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_oid4vci_store.py +48 -0
  35. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/GOVERNANCE.md +0 -0
  36. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/LICENSE.txt +0 -0
  37. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/MANIFEST.in +0 -0
  38. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/SECURITY.md +0 -0
  39. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/docs/README.md +0 -0
  40. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/__init__.py +0 -0
  41. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/_jsonmodel.py +0 -0
  42. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/_jws/__init__.py +0 -0
  43. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/_jws/exceptions.py +0 -0
  44. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/_jws/utils.py +0 -0
  45. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/_ob1_api.py +0 -0
  46. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/badge_model.py +0 -0
  47. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/baking.py +0 -0
  48. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/cli_common.py +0 -0
  49. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/errors.py +0 -0
  50. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/keys.py +0 -0
  51. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/logs.py +0 -0
  52. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob1/__init__.py +0 -0
  53. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob1/badge.py +0 -0
  54. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob1/signer.py +0 -0
  55. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob2/__init__.py +0 -0
  56. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob2/models.py +0 -0
  57. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob2/signer.py +0 -0
  58. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/__init__.py +0 -0
  59. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/contexts/__init__.py +0 -0
  60. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
  61. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
  62. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
  63. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
  64. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/contexts/ob-v3p0-extensions.json +0 -0
  65. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
  66. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
  67. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/credential.py +0 -0
  68. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/did.py +0 -0
  69. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/eudi.py +0 -0
  70. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/ldp.py +0 -0
  71. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/signer.py +0 -0
  72. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/status.py +0 -0
  73. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/status_list.py +0 -0
  74. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/status_registry.py +0 -0
  75. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/verifier.py +0 -0
  76. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/oid4vci/__init__.py +0 -0
  77. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/oid4vci/codes.py +0 -0
  78. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/oid4vci/errors.py +0 -0
  79. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/oid4vci/formats.py +0 -0
  80. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/oid4vci/nonce.py +0 -0
  81. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/openbadges_cli.py +0 -0
  82. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/openbadges_init.py +0 -0
  83. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/openbadges_keygenerator.py +0 -0
  84. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/openbadges_signer.py +0 -0
  85. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/openbadges_status.py +0 -0
  86. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/openbadges_verifier.py +0 -0
  87. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/py.typed +0 -0
  88. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/verify.py +0 -0
  89. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib.egg-info/SOURCES.txt +0 -0
  90. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib.egg-info/dependency_links.txt +0 -0
  91. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib.egg-info/entry_points.txt +0 -0
  92. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib.egg-info/top_level.txt +0 -0
  93. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/setup.cfg +0 -0
  94. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/config1.ini +0 -0
  95. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/conformance/Dockerfile.ob3 +0 -0
  96. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/conformance/README.md +0 -0
  97. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/conformance/conftest.py +0 -0
  98. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/conformance/docker-compose.yml +0 -0
  99. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/conformance/test_official_validators.py +0 -0
  100. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/conftest.py +0 -0
  101. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/fixtures/ob_schemas/README.md +0 -0
  102. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
  103. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
  104. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/fixtures/ob_schemas/refresh.sh +0 -0
  105. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/fixtures/vc_di_eddsa/README.md +0 -0
  106. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
  107. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
  108. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
  109. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
  110. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
  111. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/images/sample1.png +0 -0
  112. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/images/sample1.svg +0 -0
  113. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/images/userimage01.svg +0 -0
  114. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/ldp_helpers.py +0 -0
  115. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/logo Python Espan/314/203a.svg" +0 -0
  116. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/logo Python Espa/303/261a.svg" +0 -0
  117. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_baking.py +0 -0
  118. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_cli_json.py +0 -0
  119. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_cli_smoke.py +0 -0
  120. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_confparser.py +0 -0
  121. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_eddsa.py +0 -0
  122. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_errors.py +0 -0
  123. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_examples.py +0 -0
  124. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_issue.py +0 -0
  125. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_issue_credential.py +0 -0
  126. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_jsonmodel.py +0 -0
  127. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_jws.py +0 -0
  128. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_key_operation.py +0 -0
  129. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_keys.py +0 -0
  130. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob1_badge_io.py +0 -0
  131. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob1_deprecation.py +0 -0
  132. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob2_cli.py +0 -0
  133. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob2_models.py +0 -0
  134. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob2_signer.py +0 -0
  135. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_conformance_schema.py +0 -0
  136. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_contexts.py +0 -0
  137. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_credential.py +0 -0
  138. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_did.py +0 -0
  139. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_did_doc.py +0 -0
  140. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_endorsement.py +0 -0
  141. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_eudi_oid4vci.py +0 -0
  142. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_eudi_sd_jwt.py +0 -0
  143. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_eudi_type_metadata.py +0 -0
  144. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_eudi_x5c.py +0 -0
  145. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_ldp.py +0 -0
  146. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_ldp_cli.py +0 -0
  147. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_ldp_ecdsa_sd.py +0 -0
  148. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_ldp_errors.py +0 -0
  149. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_ldp_sign.py +0 -0
  150. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_signer.py +0 -0
  151. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_signer_cli_ldp.py +0 -0
  152. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_status.py +0 -0
  153. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_status_list.py +0 -0
  154. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_status_registry.py +0 -0
  155. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_verifier.py +0 -0
  156. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_openbadges_cli.py +0 -0
  157. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_public_api.py +0 -0
  158. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_sign_ecc.pem +0 -0
  159. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_sign_rsa.pem +0 -0
  160. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_signer_batch.py +0 -0
  161. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_util.py +0 -0
  162. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_verifier_cli_coverage.py +0 -0
  163. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_verify.py +0 -0
  164. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_verify_ecc.pem +0 -0
  165. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_verify_rsa.pem +0 -0
  166. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/withoutxmlheader.svg +0 -0
  167. {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/withxmlheader.svg +0 -0
@@ -75,8 +75,9 @@ policy is deliberately simple and predictable:
75
75
 
76
76
  - A version is **dropped** — from `requires-python`, the classifiers, and the CI
77
77
  matrix — at the next **major** release after it reaches end-of-life. For
78
- example, Python **3.10 (EOL 2026-10)** is dropped in **4.0.0**; until then it
79
- stays supported. Grouping drops into majors keeps breaking changes rare.
78
+ example, Python **3.10 (EOL 2026-10)** stays supported through 4.x and is
79
+ dropped at the next major after that EOL (5.0). Grouping drops into majors
80
+ keeps breaking changes rare.
80
81
  - A new CPython minor is **added** to the CI matrix once it has a stable
81
82
  (non-pre) release, so downstreams can adopt it early.
82
83
  - The current matrix is **3.10–3.14**, and `requires-python = ">=3.10"`.
@@ -4,6 +4,50 @@ OpenBadgesLib - Changelog
4
4
  Newest first. Dates are ISO 8601 (YYYY-MM-DD).
5
5
 
6
6
 
7
+ * v4.4.3 - 2026-08-18
8
+
9
+ - security(oid4vci): an issuer that advertises
10
+ ``key_attestations_required`` now refuses a proof without
11
+ ``key_attestation`` before the signature is checked and before the
12
+ ``c_nonce`` is spent (openvc-core 1.26's ``require_key_attestation``).
13
+ Set ``oid4vci_key_attestations_required`` on the badge section, or pass
14
+ ``require_key_attestation=True`` to ``handle_credential_request``.
15
+ Without this, a software wallet's §8.3.1 retry spent a fresh nonce on
16
+ every attempt and looped.
17
+
18
+ - build(deps): raise the openvc-core floors to `>=1.26` in the `[eudi]`,
19
+ `[ldp-sd]` and `[oid4vci]` extras.
20
+
21
+ - security(oid4vci): `--reclaim-unclaimed` no longer frees a status-list
22
+ index whose credential was already delivered to a wallet. Issued grants
23
+ are kept past the offer TTL (they are the only proof of delivery), and
24
+ a missing grant row is treated as undecided rather than "never claimed".
25
+ - security(oid4vci): presenting a pre-authorized code again after the
26
+ wallet has claimed no longer demotes the grant from issued to
27
+ invalidated (tokens are still revoked). That demotion made the next
28
+ `--reclaim-unclaimed` free the delivered credential's status index.
29
+ - security(ob2): a HostedBadge BadgeClass that embeds an issuer Profile
30
+ object is no longer trusted as-is. The Profile at ``issuer.id`` is
31
+ fetched and used for scope and revocation.
32
+ - security(publish): ``sd_jwt_vct`` paths are POSIX-normalized; a ``..``
33
+ that would write Type Metadata outside ``-o`` is refused.
34
+ - security(ob1): an empty revocation-list response fails closed instead
35
+ of treating the badge as not revoked.
36
+ - security(mail): attachment filenames are RFC 2231 encoded, and CR/LF
37
+ or quotes in a badge/recipient name are rejected before they can
38
+ inject MIME headers.
39
+ - security(ob2): HostedBadge ``startsWith`` is an origin + path-segment
40
+ prefix, not a raw string prefix (blocks subdomain / userinfo tricks).
41
+ - fix(oid4vci): a Credential Offer that fails validation (e.g. batch_size
42
+ > 1 with status_lists) no longer reserves a status-list index first.
43
+ - fix(ci): the Ubuntu test job installs ``[oid4vci]`` and sets
44
+ ``OPENBADGES_REQUIRE_OID4VCI=1`` so the credential-endpoint suite cannot
45
+ skip itself green.
46
+ - fix(oid4vci): minting an access token stretches the grant to cover the
47
+ advertised ``expires_in``, so redeeming late in the offer window no
48
+ longer clips the token's lifetime.
49
+
50
+
7
51
  * v4.4.2 - 2026-08-13
8
52
 
9
53
  - fix(cli): key generation no longer crashes on Windows, where
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openbadgeslib
3
- Version: 4.4.2
3
+ Version: 4.4.3
4
4
  Summary: Production-ready Python library & CLI for Open Badges 3.0 (W3C Verifiable Credentials): issue, verify, and revoke JWT-VC credentials with Bitstring Status Lists and did:web — plus strict OB 2.0 and legacy OB 1.0.
5
5
  Author-email: Luis González Fernández <luisgf@luisgf.es>, Jesús Cea Avión <jcea@jcea.es>
6
6
  License: LGPLv3
@@ -36,11 +36,11 @@ Requires-Dist: defusedxml>=0.7
36
36
  Provides-Extra: ldp
37
37
  Requires-Dist: pyld>=2.0.4; extra == "ldp"
38
38
  Provides-Extra: eudi
39
- Requires-Dist: openvc-core<2,>=1.25; extra == "eudi"
39
+ Requires-Dist: openvc-core<2,>=1.26; extra == "eudi"
40
40
  Provides-Extra: ldp-sd
41
- Requires-Dist: openvc-core[data-integrity]<2,>=1.25; extra == "ldp-sd"
41
+ Requires-Dist: openvc-core[data-integrity]<2,>=1.26; extra == "ldp-sd"
42
42
  Provides-Extra: oid4vci
43
- Requires-Dist: openvc-core<2,>=1.25; extra == "oid4vci"
43
+ Requires-Dist: openvc-core<2,>=1.26; extra == "oid4vci"
44
44
  Provides-Extra: dev
45
45
  Requires-Dist: pytest>=8.0; extra == "dev"
46
46
  Requires-Dist: pytest-cov>=5.0; extra == "dev"
@@ -144,7 +144,7 @@ all three OB versions.
144
144
  - Python >= 3.10 (tested on 3.10–3.14)
145
145
  - [pypng](https://pypi.org/project/pypng/) >= 0.20220715.0
146
146
  - [PyJWT[crypto]](https://pypi.org/project/PyJWT/) >= 2.13
147
- - [cryptography](https://pypi.org/project/cryptography/) >= 42
147
+ - [cryptography](https://pypi.org/project/cryptography/) >= 45
148
148
  - [defusedxml](https://pypi.org/project/defusedxml/) >= 0.7
149
149
 
150
150
  ## Installation
@@ -157,8 +157,10 @@ All dependencies are installed automatically. The two OB 3.0 side-tracks ship
157
157
  as optional extras (the base install stays lean):
158
158
 
159
159
  ```bash
160
- pip install "openbadgeslib[ldp]" # W3C Data Integrity issuance/verification
161
- pip install "openbadgeslib[eudi]" # EUDI SD-JWT VC (pulls openvc-core)
160
+ pip install "openbadgeslib[ldp]" # W3C Data Integrity issuance/verification
161
+ pip install "openbadgeslib[ldp-sd]" # verify ecdsa-sd-2023 (selective disclosure)
162
+ pip install "openbadgeslib[eudi]" # EUDI SD-JWT VC (pulls openvc-core)
163
+ pip install "openbadgeslib[oid4vci]" # issue to wallets (pre-authorized code flow)
162
164
  ```
163
165
 
164
166
  For a development checkout with the test suite and linters:
@@ -89,7 +89,7 @@ all three OB versions.
89
89
  - Python >= 3.10 (tested on 3.10–3.14)
90
90
  - [pypng](https://pypi.org/project/pypng/) >= 0.20220715.0
91
91
  - [PyJWT[crypto]](https://pypi.org/project/PyJWT/) >= 2.13
92
- - [cryptography](https://pypi.org/project/cryptography/) >= 42
92
+ - [cryptography](https://pypi.org/project/cryptography/) >= 45
93
93
  - [defusedxml](https://pypi.org/project/defusedxml/) >= 0.7
94
94
 
95
95
  ## Installation
@@ -102,8 +102,10 @@ All dependencies are installed automatically. The two OB 3.0 side-tracks ship
102
102
  as optional extras (the base install stays lean):
103
103
 
104
104
  ```bash
105
- pip install "openbadgeslib[ldp]" # W3C Data Integrity issuance/verification
106
- pip install "openbadgeslib[eudi]" # EUDI SD-JWT VC (pulls openvc-core)
105
+ pip install "openbadgeslib[ldp]" # W3C Data Integrity issuance/verification
106
+ pip install "openbadgeslib[ldp-sd]" # verify ecdsa-sd-2023 (selective disclosure)
107
+ pip install "openbadgeslib[eudi]" # EUDI SD-JWT VC (pulls openvc-core)
108
+ pip install "openbadgeslib[oid4vci]" # issue to wallets (pre-authorized code flow)
107
109
  ```
108
110
 
109
111
  For a development checkout with the test suite and linters:
@@ -143,6 +143,13 @@ key_type = RSA
143
143
  ; above; vc+sd-jwt is the EUDI track and is IRREVOCABLE, so it cannot be
144
144
  ; combined with status_lists. vc+sd-jwt also needs key_type = ED25519 or ECC.
145
145
  ;oid4vci_formats = jwt_vc_json
146
+ ; Demand a key attestation from the wallet (OID4VCI 1.0 §12.2.4). Unset is
147
+ ; the spec default: the parameter MUST NOT appear unless you actually
148
+ ; require one, or software wallets are locked out. true / {} = required
149
+ ; with no extra constraints; a JSON object is the Appendix D.2 set.
150
+ ; Advertising this also refuses a proof without key_attestation before
151
+ ; the nonce is spent — do not publish it and then accept inline-jwk proofs.
152
+ ;oid4vci_key_attestations_required = true
146
153
  ;mail = ${paths:base}/badge_1_mail.txt
147
154
 
148
155
  [badge_2]
@@ -23,7 +23,8 @@
23
23
 
24
24
  from configparser import ConfigParser, ExtendedInterpolation, Error as ConfigParserError
25
25
  from dataclasses import dataclass
26
- from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
26
+ from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING
27
+ import json
27
28
  import os
28
29
  import sys
29
30
  import logging
@@ -380,6 +381,52 @@ def oid4vci_formats(conf: ConfigParser, badge_section: str) -> Tuple[str, ...]:
380
381
  return tuple(formats)
381
382
 
382
383
 
384
+ def oid4vci_key_attestations_required(
385
+ conf: ConfigParser, badge_section: str) -> Optional[Dict[str, Any]]:
386
+ """The key-attestation policy a badge advertises over OID4VCI, or None.
387
+
388
+ Absent (the default) means the issuer does **not** require a key
389
+ attestation, and OpenID4VCI 1.0 §12.2.4 says the parameter MUST NOT then
390
+ appear in metadata. Present means it is required:
391
+
392
+ * ``true`` / ``yes`` / ``1`` / ``{}`` / empty advertise an unconstrained
393
+ requirement (a key attestation is needed, with no extra constraints);
394
+ * a JSON object is the Appendix D.2 constraint set, validated by the same
395
+ shape check :func:`~openbadgeslib.ob3.eudi.badge_credential_configuration`
396
+ uses.
397
+
398
+ The credential endpoint reads this same key, so advertising the
399
+ requirement without enforcing it — the wallet-retry loop openvc-core
400
+ 1.26's ``require_key_attestation`` exists to close — cannot happen from
401
+ configuration alone.
402
+ """
403
+ if (badge_section not in conf
404
+ or 'oid4vci_key_attestations_required' not in conf[badge_section]):
405
+ return None
406
+ raw = (conf[badge_section].get('oid4vci_key_attestations_required')
407
+ or '').strip()
408
+ if raw.lower() in ('', 'true', 'yes', '1', '{}'):
409
+ value: Any = {}
410
+ else:
411
+ try:
412
+ parsed = json.loads(raw)
413
+ except ValueError as exc:
414
+ raise ConfigError(
415
+ "[%s] oid4vci_key_attestations_required must be true or a "
416
+ "JSON object, got %r" % (badge_section, raw)) from exc
417
+ if not isinstance(parsed, dict):
418
+ raise ConfigError(
419
+ "[%s] oid4vci_key_attestations_required must be a JSON "
420
+ "object, got %s" % (badge_section, type(parsed).__name__))
421
+ value = parsed
422
+ from .ob3.eudi import EudiError, _key_attestations_required
423
+ try:
424
+ return _key_attestations_required(value)
425
+ except EudiError as exc:
426
+ raise ConfigError("[%s] oid4vci_key_attestations_required: %s"
427
+ % (badge_section, exc)) from exc
428
+
429
+
383
430
  # ── centralized config defaults ──────────────────────────────────────────────
384
431
  # One inventory for the accepted-key defaults, so each lives in a single place
385
432
  # rather than being re-spelled at every read site. (The status-list defaults —
@@ -116,6 +116,12 @@ def _safe_filename_component(value: str, field_name: str) -> str:
116
116
  raise ValueError('%s is not safe for use in an output filename' % field_name)
117
117
  if os.path.basename(value) != value or ntpath.basename(value) != value:
118
118
  raise ValueError('%s must not contain path separators' % field_name)
119
+ # CR/LF/quotes/semicolons would inject MIME headers when this name is
120
+ # used as Content-Disposition filename (#316).
121
+ if any(ch in value for ch in '\r\n"\';'):
122
+ raise ValueError(
123
+ '%s contains characters that are not safe in a filename'
124
+ % field_name)
119
125
  return value
120
126
 
121
127
 
@@ -67,10 +67,12 @@ class BadgeMail():
67
67
  raise BadgeImgFormatUnsupported(
68
68
  'Unsupported image type: %r' % (badge.source.image_type,))
69
69
 
70
- image = MIMEImage(badge.source.image,
71
- Content_Disposition='attachment; filename=%s' % basename(badge.file_out),
72
- Content_Description='Signed OpenBadge',
73
- _subtype=mime_type)
70
+ image = MIMEImage(badge.source.image, _subtype=mime_type)
71
+ # Keyword form so the filename is RFC 2231 encoded, never interpolated
72
+ # into the header value as raw bytes (#316).
73
+ image.add_header('Content-Disposition', 'attachment',
74
+ filename=basename(badge.file_out))
75
+ image.add_header('Content-Description', 'Signed OpenBadge')
74
76
  msg.attach(image)
75
77
 
76
78
  try:
@@ -182,7 +182,10 @@ class Verifier():
182
182
 
183
183
  revocation_json = download_file(revocation_url)
184
184
  if not revocation_json:
185
- return None
185
+ # The issuer published a revocationList URL; an empty body is not
186
+ # "no revocations" — that is a missing key. Fail closed (#317).
187
+ raise AssertionFormatIncorrect(
188
+ "Revocation list at %s is empty" % revocation_url)
186
189
  try:
187
190
  revocation = jws_utils.from_json(revocation_json)
188
191
  except Exception:
@@ -274,7 +274,8 @@ class OB2Verifier:
274
274
  starts_with = _as_str_list(verification.get("startsWith"))
275
275
  allowed_origins = _as_str_list(verification.get("allowedOrigins"))
276
276
  if starts_with:
277
- if not any(assertion_id.startswith(prefix) for prefix in starts_with):
277
+ if not any(_url_starts_with(assertion_id, prefix)
278
+ for prefix in starts_with):
278
279
  raise OB2VerificationError(
279
280
  "Hosted assertion id %s is outside the issuer's declared "
280
281
  "startsWith scope" % assertion_id)
@@ -342,16 +343,33 @@ class OB2Verifier:
342
343
  # ── network / decode helpers ─────────────────────────────────────────────────
343
344
 
344
345
  def _fetch_issuer(self, assertion: Assertion) -> dict[str, Any]:
345
- """Resolve the issuer Profile via the assertion's BadgeClass ``badge`` URL."""
346
+ """Resolve the issuer Profile via the assertion's BadgeClass ``badge`` URL.
347
+
348
+ An embedded Profile object is not trusted: its ``id`` is fetched and
349
+ that retrieved document is the trust anchor (scope, revocation). A
350
+ holder-supplied BadgeClass could otherwise name a real issuer ``id``
351
+ while writing its own ``startsWith`` / ``revocationList`` (#315).
352
+ """
346
353
  badge = self._fetch_json(assertion.badge, "BadgeClass")
347
- issuer_url = badge.get("issuer")
348
- if isinstance(issuer_url, dict):
349
- # BadgeClass.issuer may be an embedded Profile object.
350
- return issuer_url
351
- if not isinstance(issuer_url, str) or not issuer_url:
354
+ issuer = badge.get("issuer")
355
+ if isinstance(issuer, dict):
356
+ issuer_url = issuer.get("id")
357
+ if not isinstance(issuer_url, str) or not issuer_url:
358
+ raise OB2VerificationError(
359
+ "BadgeClass at %s has an embedded issuer with no id"
360
+ % assertion.badge)
361
+ elif isinstance(issuer, str) and issuer:
362
+ issuer_url = issuer
363
+ else:
352
364
  raise OB2VerificationError(
353
365
  "BadgeClass at %s has no valid 'issuer'" % assertion.badge)
354
- return self._fetch_json(issuer_url, "issuer Profile")
366
+ fetched = self._fetch_json(issuer_url, "issuer Profile")
367
+ fetched_id = fetched.get("id")
368
+ if isinstance(fetched_id, str) and fetched_id != issuer_url:
369
+ raise OB2VerificationError(
370
+ "Issuer Profile at %s has id %r, expected %r"
371
+ % (issuer_url, fetched_id, issuer_url))
372
+ return fetched
355
373
 
356
374
  def _fetch_json(self, url: str, where: str) -> dict[str, Any]:
357
375
  try:
@@ -445,3 +463,24 @@ def _same_origin(a: str, b: str) -> bool:
445
463
  """Return True if two URLs share scheme + host + port (their origin)."""
446
464
  pa, pb = urlparse(a), urlparse(b)
447
465
  return (pa.scheme, pa.hostname, pa.port) == (pb.scheme, pb.hostname, pb.port)
466
+
467
+
468
+ def _url_starts_with(url: str, prefix: str) -> bool:
469
+ """True if *url* is in the hosted scope declared by *prefix*.
470
+
471
+ A raw ``str.startswith`` treats ``https://issuer.example`` as a prefix of
472
+ ``https://issuer.example.attacker.tld/…`` (and similar userinfo tricks).
473
+ Require the same origin, then a path that equals the prefix path or is
474
+ a strict path-segment descendant (#314).
475
+ """
476
+ if not prefix:
477
+ return False
478
+ if not _same_origin(url, prefix):
479
+ return False
480
+ u, p = urlparse(url), urlparse(prefix)
481
+ upath, ppath = u.path or '/', p.path or '/'
482
+ if upath == ppath:
483
+ return True
484
+ if not ppath.endswith('/'):
485
+ ppath += '/'
486
+ return upath.startswith(ppath)
@@ -39,6 +39,7 @@
39
39
 
40
40
  import configparser
41
41
  import os
42
+ import posixpath
42
43
  import shutil
43
44
  import tempfile
44
45
 
@@ -174,14 +175,28 @@ def _write_atomic(path: str, data: str) -> None:
174
175
  def _sd_jwt_vct_relpath(vct: str, publish_url: str) -> Optional[str]:
175
176
  """The output-relative path to write Type Metadata for *vct* so serving the
176
177
  output dir at *publish_url* resolves it there — or None if *vct* is not
177
- hosted under *publish_url* (same scheme+host, path under it)."""
178
+ hosted under *publish_url* (same scheme+host, path under it).
179
+
180
+ The remainder is POSIX-normalized. A ``..`` that would leave the publish
181
+ prefix is refused (PublishError) rather than joined onto ``-o`` — a vct
182
+ of ``https://host/out/../../../tmp/x`` used to write outside the output
183
+ directory (#318).
184
+ """
178
185
  v, p = urlparse(vct), urlparse(publish_url)
179
186
  if (v.scheme, v.netloc) != (p.scheme, p.netloc):
180
187
  return None
181
188
  base = p.path if p.path.endswith('/') else p.path + '/'
182
189
  if not v.path.startswith(base):
183
190
  return None
184
- return v.path[len(base):] or None
191
+ rel = v.path[len(base):]
192
+ if not rel:
193
+ return None
194
+ normalized = posixpath.normpath(rel)
195
+ if posixpath.isabs(normalized) or normalized == '..' \
196
+ or normalized.startswith('../'):
197
+ raise PublishError(
198
+ "[issuer] sd_jwt_vct path escapes the publish_url prefix")
199
+ return normalized
185
200
 
186
201
 
187
202
  def _publish_type_metadata(vct: str, publish_url: str, output: str,
@@ -198,6 +213,12 @@ def _publish_type_metadata(vct: str, publish_url: str, output: str,
198
213
  document = badge_type_metadata(vct)
199
214
  served = type_metadata_document_bytes(document)
200
215
  path = os.path.join(output, rel.replace('/', os.sep))
216
+ root = os.path.abspath(output)
217
+ dest = os.path.abspath(path)
218
+ if os.path.commonpath([root, dest]) != root:
219
+ raise PublishError(
220
+ "[issuer] sd_jwt_vct would write Type Metadata outside %s"
221
+ % output)
201
222
  parent = os.path.dirname(path)
202
223
  if parent:
203
224
  os.makedirs(parent, exist_ok=True)
@@ -43,7 +43,7 @@ from dataclasses import dataclass, field
43
43
  from datetime import datetime, timedelta, timezone
44
44
  from typing import Any, Dict, List, Mapping, Optional, Union
45
45
 
46
- from ..confparser import oid4vci_config
46
+ from ..confparser import oid4vci_config, oid4vci_key_attestations_required
47
47
  from ..issue import CredentialResult, issue_credential_from_conf
48
48
  from .codes import (dummy_tx_verification, new_secret, secret_id,
49
49
  verify_tx_code)
@@ -70,11 +70,13 @@ MAX_REQUEST_BYTES = 128 * 1024
70
70
  def _require_openvc() -> Any:
71
71
  """Import the openvc-core OID4VCI pieces, or raise with an actionable hint.
72
72
 
73
- The openvc-core floor is 1.25 across every extra that pulls it, so
74
- ``resolve_proof_key_in_context`` (1.24) and the discovery parsers (1.25)
75
- are guaranteed present when the import succeeds; a resolver that somehow
76
- resolves older anyway fails loudly at the unexpected-keyword call rather
77
- than silently dropping the attested-key form.
73
+ The openvc-core floor is 1.26 across every extra that pulls it, so
74
+ ``resolve_proof_key_in_context`` (1.24), the discovery parsers (1.25)
75
+ and ``require_key_attestation`` (1.26) are guaranteed present when the
76
+ import succeeds; a resolver that somehow resolves older anyway fails
77
+ loudly at the unexpected-keyword call rather than silently dropping
78
+ the attested-key form or spending a nonce on a proof that lacked the
79
+ advertised attestation.
78
80
  """
79
81
  try:
80
82
  from openvc.openid4vci import (parse_credential_request,
@@ -215,7 +217,8 @@ def handle_credential_request(conf: configparser.ConfigParser,
215
217
  access_token: Optional[str], store: Any,
216
218
  nonces: NonceIssuer,
217
219
  now: Optional[datetime] = None,
218
- resolve_proof_key_in_context: Any = None
220
+ resolve_proof_key_in_context: Any = None,
221
+ require_key_attestation: bool = False
219
222
  ) -> CredentialResponse:
220
223
  """Verify a Credential Request and issue what its grant authorises.
221
224
 
@@ -237,6 +240,19 @@ def handle_credential_request(conf: configparser.ConfigParser,
237
240
  decision, not this library's default. When None (the default), only
238
241
  proofs that carry their key inline are accepted, exactly as before.
239
242
 
243
+ *require_key_attestation* refuses a proof whose header has no
244
+ ``key_attestation`` **before** the signature is checked and **before**
245
+ the ``c_nonce`` is spent (openvc-core ≥1.26). Default False, matching
246
+ the normative metadata default: OpenID4VCI 1.0 §12.2.4 says
247
+ ``key_attestations_required`` MUST NOT appear unless the issuer
248
+ actually requires one. The flag is also forced on when the grant's
249
+ badge advertises that parameter (via
250
+ ``oid4vci_key_attestations_required`` in the config, or an explicit
251
+ True here for a caller that published the requirement through
252
+ :func:`~openbadgeslib.ob3.eudi.badge_credential_configuration`).
253
+ Advertising it without this check spends the nonce on a proof the
254
+ wallet will retry forever.
255
+
240
256
  Raises :class:`OID4VCIError` for anything the wallet did wrong, and lets
241
257
  :class:`~openbadgeslib.errors.IssuanceError` through untouched for anything
242
258
  the ISSUER did wrong (broken config, unreadable key, exhausted status
@@ -286,6 +302,9 @@ def handle_credential_request(conf: configparser.ConfigParser,
286
302
  'this issuer does not use credential_identifier; send a '
287
303
  'credential_configuration_id')
288
304
 
305
+ badge, _fmt = parse_credential_configuration_id(
306
+ conf, grant.credential_configuration_id)
307
+ advertised = oid4vci_key_attestations_required(conf, badge) is not None
289
308
  try:
290
309
  proofs = verify_proofs(
291
310
  request,
@@ -303,6 +322,9 @@ def handle_credential_request(conf: configparser.ConfigParser,
303
322
  resolve_proof_key=None,
304
323
  resolve_proof_key_in_context=resolve_proof_key_in_context,
305
324
  trust_anchors=None,
325
+ # OR, never AND-NOT: advertising the requirement without
326
+ # enforcing it is the retry loop this flag exists to close.
327
+ require_key_attestation=require_key_attestation or advertised,
306
328
  max_age_s=cfg.proof_max_age_s, now=moment,
307
329
  batch_size=grant.max_proofs)
308
330
  except OID4VCIStoreError:
@@ -118,7 +118,9 @@ class InMemoryOID4VCIStore:
118
118
  def invalidate_grant(self, grant_id: str) -> None:
119
119
  with self._lock:
120
120
  grant = self._grants.get(grant_id)
121
- if grant is not None:
121
+ # Keep STATE_ISSUED: it is the delivery evidence reclaim needs
122
+ # (#313). Tokens are still dropped (OAuth 2.1 code-reuse).
123
+ if grant is not None and grant.state != STATE_ISSUED:
122
124
  grant.state = STATE_INVALIDATED
123
125
  self._tokens = {tid: entry for tid, entry in self._tokens.items()
124
126
  if entry[0] != grant_id}
@@ -129,6 +131,10 @@ class InMemoryOID4VCIStore:
129
131
  expires_at: datetime) -> None:
130
132
  with self._lock:
131
133
  self._tokens[token_id] = (grant_id, expires_at)
134
+ grant = self._grants.get(grant_id)
135
+ # Cover the advertised token TTL (#320).
136
+ if grant is not None and grant.expires_at < expires_at:
137
+ grant.expires_at = expires_at
132
138
 
133
139
  def grant_for_token(self, token_id: str, *,
134
140
  now: datetime) -> Optional[PreAuthorizedGrant]:
@@ -179,8 +185,12 @@ class InMemoryOID4VCIStore:
179
185
  def purge_expired(self, *, now: datetime, limit: int = 500) -> PurgeStats:
180
186
  with self._lock:
181
187
  stats = PurgeStats()
188
+ # Keep STATE_ISSUED grants past expiry: they are the only proof
189
+ # a wallet claimed the offer, and reconcile needs them so it
190
+ # does not free a delivered credential's status-list index (#303).
182
191
  dead_grants = [gid for gid, g in self._grants.items()
183
- if g.is_expired(now)][:limit]
192
+ if g.is_expired(now)
193
+ and g.state != STATE_ISSUED][:limit]
184
194
  for gid in dead_grants:
185
195
  grant = self._grants.pop(gid)
186
196
  self._codes.pop(grant.code_id, None)
@@ -40,7 +40,7 @@ import logging
40
40
  from typing import Any, Dict, List, Optional, Sequence
41
41
 
42
42
  from ..confparser import (OID4VCIConfig, oid4vci_config, oid4vci_formats,
43
- resolve_key_type)
43
+ oid4vci_key_attestations_required, resolve_key_type)
44
44
  from ..errors import ConfigError
45
45
  from ..keys import KeyType
46
46
  from .formats import FORMAT_JWT_VC_JSON, FORMAT_SD_JWT_VC
@@ -175,9 +175,10 @@ def _validate_issuer_metadata(metadata: Dict[str, Any]) -> None:
175
175
  try:
176
176
  from openvc.openid4vci import parse_credential_issuer_metadata
177
177
  except ImportError:
178
- # An install WITH the extra that lands an openvc-core below the 1.25
179
- # parser would take this branch too — indistinguishable from a plain
180
- # minimal install, and silent about the skipped check either way.
178
+ # An install WITH the extra that lands an openvc-core below the 1.26
179
+ # floor (parsers landed in 1.25) would take this branch too —
180
+ # indistinguishable from a plain minimal install, and silent about
181
+ # the skipped check either way.
181
182
  logger.debug('openvc-core OID4VCI discovery parsers unavailable; '
182
183
  'the issuer metadata self-check is skipped (install the '
183
184
  '[oid4vci] extra for it)')
@@ -232,6 +233,12 @@ def _configuration(conf: configparser.ConfigParser, cfg: OID4VCIConfig,
232
233
  list(PROOF_SIGNING_ALGS)},
233
234
  },
234
235
  }
236
+ required = oid4vci_key_attestations_required(conf, badge)
237
+ if required is not None:
238
+ # Presence is the requirement. An empty object is the spec's
239
+ # "needed, unconstrained" spelling — not a placeholder.
240
+ entry['proof_types_supported']['jwt'][
241
+ 'key_attestations_required'] = required
235
242
  if credential_format == FORMAT_SD_JWT_VC:
236
243
  from ..ob3.eudi import OB3_SD_JWT_VCT
237
244
  entry['vct'] = (conf['issuer'].get('sd_jwt_vct') or '').strip() \
@@ -139,22 +139,22 @@ def build_credential_offer(conf: configparser.ConfigParser, badge: str,
139
139
  notices: List[str] = []
140
140
  expires_at = moment + timedelta(
141
141
  seconds=expires_in_s if expires_in_s is not None else cfg.offer_ttl_s)
142
+ if cfg.batch_size > 1:
143
+ # Check before reserving: a failed offer used to burn a status-list
144
+ # index on every retry (#319).
145
+ from ..confparser import ob3_status_config
146
+ if ob3_status_config(conf, badge) is not None:
147
+ raise ConfigError(
148
+ "[%s] configures status_lists and [oid4vci] sets batch_size = %d, "
149
+ "but one grant reserves one revocation slot — the extra "
150
+ "credentials would be irrevocable. Set batch_size = 1, or drop "
151
+ "status_lists from that section."
152
+ % (badge, cfg.batch_size))
153
+
142
154
  # The credential's id is fixed now, not at signing time: the status
143
155
  # registry indexes the reserved slot by it, and a signing-time id would
144
156
  # leave the registry naming a credential that never existed.
145
157
  credential_id = 'urn:uuid:%s' % uuid.uuid4()
146
- status_index = _reserve_status_index(conf, badge, chosen, recipient,
147
- credential_id, expires_at, notices)
148
- if status_index is not None and cfg.batch_size > 1:
149
- # A grant reserves exactly one status-list slot, so batch issuance
150
- # would hand out N credentials of which N-1 could never be revoked.
151
- raise ConfigError(
152
- "[%s] configures status_lists and [oid4vci] sets batch_size = %d, "
153
- "but one grant reserves one revocation slot — the extra "
154
- "credentials would be irrevocable. Set batch_size = 1, or drop "
155
- "status_lists from that section."
156
- % (badge, cfg.batch_size))
157
-
158
158
  code = new_secret()
159
159
  configuration_id = credential_configuration_id(badge, chosen)
160
160
 
@@ -162,7 +162,7 @@ def build_credential_offer(conf: configparser.ConfigParser, badge: str,
162
162
  grant_id=new_id(), code_id=secret_id(code), badge=badge,
163
163
  credential_configuration_id=configuration_id,
164
164
  credential_format=chosen, recipient=recipient,
165
- expires_at=expires_at, status_index=status_index,
165
+ expires_at=expires_at, status_index=None,
166
166
  credential_id=credential_id,
167
167
  max_proofs=cfg.batch_size, created_at=moment)
168
168
 
@@ -178,7 +178,15 @@ def build_credential_offer(conf: configparser.ConfigParser, badge: str,
178
178
  offer = _offer_document(cfg.credential_issuer, [configuration_id], code,
179
179
  cfg if tx_code else None)
180
180
  _validate_offer(offer)
181
- store.save_grant(grant)
181
+ status_index = _reserve_status_index(conf, badge, chosen, recipient,
182
+ credential_id, expires_at, notices)
183
+ grant.status_index = status_index
184
+ try:
185
+ store.save_grant(grant)
186
+ except BaseException:
187
+ if status_index is not None:
188
+ _release_status_index(conf, badge, credential_id)
189
+ raise
182
190
  return CredentialOffer(
183
191
  offer=offer, uri=_offer_uri(offer), pre_authorized_code=code,
184
192
  grant_id=grant.grant_id, recipient=recipient, expires_at=expires_at,
@@ -224,7 +232,8 @@ def _validate_offer(offer: Dict[str, Any]) -> None:
224
232
  from openvc.openid4vci import parse_credential_offer
225
233
  except ImportError:
226
234
  # Same caveat as the metadata self-check: an openvc-core older than
227
- # 1.25 takes this branch too, silently skipping the check.
235
+ # the 1.26 floor (parsers landed in 1.25) takes this branch too,
236
+ # silently skipping the check.
228
237
  logger.debug('openvc-core OID4VCI discovery parsers unavailable; '
229
238
  'the Credential Offer self-check is skipped (install '
230
239
  'the [oid4vci] extra for it)')
@@ -323,5 +332,22 @@ def _reserve_status_index(conf: configparser.ConfigParser, badge: str,
323
332
  return index
324
333
 
325
334
 
335
+ def _release_status_index(conf: configparser.ConfigParser, badge: str,
336
+ credential_id: str) -> None:
337
+ """Undo a reservation if the grant could not be persisted (#319)."""
338
+ from ..confparser import ob3_status_config
339
+ from ..ob3.status_registry import StatusRegistry
340
+
341
+ status_conf = ob3_status_config(conf, badge)
342
+ if status_conf is None:
343
+ return
344
+ with StatusRegistry.locked(status_conf.registry_path,
345
+ status_conf.size_bits) as registry:
346
+ if credential_id in registry.entries \
347
+ and registry.entries[credential_id].pending:
348
+ registry.reclaim(credential_id)
349
+ registry.save()
350
+
351
+
326
352
  __all__ = ['CredentialOffer', 'OFFER_SCHEME', 'QR_COMFORTABLE_LIMIT',
327
353
  'build_credential_offer', 'parse_received_credential_offer']