openbadgeslib 4.4.2__tar.gz → 4.4.4__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.4}/CONTRIBUTING.md +3 -2
  2. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/Changelog.txt +70 -0
  3. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/PKG-INFO +9 -7
  4. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/README.md +5 -3
  5. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/baking.py +66 -1
  6. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/config.ini.example +7 -0
  7. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/confparser.py +57 -2
  8. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/issue.py +6 -0
  9. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/mail.py +27 -5
  10. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob1/verifier.py +4 -1
  11. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob2/verifier.py +47 -8
  12. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/ldp.py +29 -1
  13. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/publish.py +23 -2
  14. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/errors.py +4 -1
  15. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/handler.py +36 -11
  16. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/memory_store.py +12 -2
  17. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/metadata.py +11 -4
  18. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/offer.py +41 -15
  19. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/reconcile.py +26 -10
  20. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/sqlite_store.py +31 -4
  21. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/store.py +6 -1
  22. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_publish.py +4 -3
  23. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_signer.py +26 -4
  24. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/util.py +81 -36
  25. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib.egg-info/PKG-INFO +9 -7
  26. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib.egg-info/requires.txt +3 -3
  27. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/pyproject.toml +7 -4
  28. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_baking.py +32 -0
  29. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_cli_smoke.py +26 -0
  30. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_docs.py +12 -0
  31. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_mail.py +20 -1
  32. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob1_signer.py +3 -0
  33. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob1_verifier.py +24 -0
  34. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob2_error_branches.py +6 -3
  35. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob2_verifier.py +54 -0
  36. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_ldp_ecdsa_sd.py +64 -5
  37. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_publish_cli.py +13 -0
  38. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_oid4vci_config.py +55 -1
  39. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_oid4vci_protocol.py +116 -6
  40. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_oid4vci_reservations.py +104 -5
  41. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_oid4vci_store.py +48 -0
  42. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_util.py +26 -0
  43. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/GOVERNANCE.md +0 -0
  44. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/LICENSE.txt +0 -0
  45. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/MANIFEST.in +0 -0
  46. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/SECURITY.md +0 -0
  47. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/docs/README.md +0 -0
  48. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/__init__.py +0 -0
  49. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/_jsonmodel.py +0 -0
  50. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/_jws/__init__.py +0 -0
  51. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/_jws/exceptions.py +0 -0
  52. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/_jws/utils.py +0 -0
  53. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/_ob1_api.py +0 -0
  54. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/badge_model.py +0 -0
  55. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/cli_common.py +0 -0
  56. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/errors.py +0 -0
  57. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/keys.py +0 -0
  58. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/logs.py +0 -0
  59. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob1/__init__.py +0 -0
  60. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob1/badge.py +0 -0
  61. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob1/signer.py +0 -0
  62. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob2/__init__.py +0 -0
  63. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob2/models.py +0 -0
  64. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob2/signer.py +0 -0
  65. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/__init__.py +0 -0
  66. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/__init__.py +0 -0
  67. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
  68. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
  69. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
  70. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
  71. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/ob-v3p0-extensions.json +0 -0
  72. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
  73. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
  74. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/credential.py +0 -0
  75. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/did.py +0 -0
  76. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/eudi.py +0 -0
  77. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/signer.py +0 -0
  78. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/status.py +0 -0
  79. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/status_list.py +0 -0
  80. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/status_registry.py +0 -0
  81. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/verifier.py +0 -0
  82. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/__init__.py +0 -0
  83. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/codes.py +0 -0
  84. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/formats.py +0 -0
  85. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/nonce.py +0 -0
  86. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_cli.py +0 -0
  87. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_init.py +0 -0
  88. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_keygenerator.py +0 -0
  89. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_status.py +0 -0
  90. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_verifier.py +0 -0
  91. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/py.typed +0 -0
  92. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/verify.py +0 -0
  93. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib.egg-info/SOURCES.txt +0 -0
  94. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib.egg-info/dependency_links.txt +0 -0
  95. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib.egg-info/entry_points.txt +0 -0
  96. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib.egg-info/top_level.txt +0 -0
  97. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/setup.cfg +0 -0
  98. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/config1.ini +0 -0
  99. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/conformance/Dockerfile.ob3 +0 -0
  100. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/conformance/README.md +0 -0
  101. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/conformance/conftest.py +0 -0
  102. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/conformance/docker-compose.yml +0 -0
  103. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/conformance/test_official_validators.py +0 -0
  104. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/conftest.py +0 -0
  105. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/fixtures/ob_schemas/README.md +0 -0
  106. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
  107. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
  108. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/fixtures/ob_schemas/refresh.sh +0 -0
  109. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/fixtures/vc_di_eddsa/README.md +0 -0
  110. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
  111. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
  112. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
  113. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
  114. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
  115. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/images/sample1.png +0 -0
  116. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/images/sample1.svg +0 -0
  117. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/images/userimage01.svg +0 -0
  118. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/ldp_helpers.py +0 -0
  119. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/logo Python Espan/314/203a.svg" +0 -0
  120. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/logo Python Espa/303/261a.svg" +0 -0
  121. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_cli_json.py +0 -0
  122. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_confparser.py +0 -0
  123. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_eddsa.py +0 -0
  124. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_errors.py +0 -0
  125. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_examples.py +0 -0
  126. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_issue.py +0 -0
  127. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_issue_credential.py +0 -0
  128. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_jsonmodel.py +0 -0
  129. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_jws.py +0 -0
  130. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_key_operation.py +0 -0
  131. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_keys.py +0 -0
  132. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob1_badge_io.py +0 -0
  133. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob1_deprecation.py +0 -0
  134. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob2_cli.py +0 -0
  135. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob2_models.py +0 -0
  136. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob2_signer.py +0 -0
  137. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_conformance_schema.py +0 -0
  138. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_contexts.py +0 -0
  139. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_credential.py +0 -0
  140. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_did.py +0 -0
  141. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_did_doc.py +0 -0
  142. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_endorsement.py +0 -0
  143. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_eudi_oid4vci.py +0 -0
  144. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_eudi_sd_jwt.py +0 -0
  145. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_eudi_type_metadata.py +0 -0
  146. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_eudi_x5c.py +0 -0
  147. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_ldp.py +0 -0
  148. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_ldp_cli.py +0 -0
  149. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_ldp_errors.py +0 -0
  150. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_ldp_sign.py +0 -0
  151. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_signer.py +0 -0
  152. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_signer_cli_ldp.py +0 -0
  153. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_status.py +0 -0
  154. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_status_list.py +0 -0
  155. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_status_registry.py +0 -0
  156. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_verifier.py +0 -0
  157. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_openbadges_cli.py +0 -0
  158. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_public_api.py +0 -0
  159. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_sign_ecc.pem +0 -0
  160. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_sign_rsa.pem +0 -0
  161. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_signer_batch.py +0 -0
  162. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_verifier_cli_coverage.py +0 -0
  163. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_verify.py +0 -0
  164. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_verify_ecc.pem +0 -0
  165. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_verify_rsa.pem +0 -0
  166. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/withoutxmlheader.svg +0 -0
  167. {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/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,76 @@ OpenBadgesLib - Changelog
4
4
  Newest first. Dates are ISO 8601 (YYYY-MM-DD).
5
5
 
6
6
 
7
+ * v4.4.4 - 2026-09-10
8
+
9
+ - security(baking): SVG baking strips ``<script>`` / ``foreignObject`` /
10
+ event-handler attributes and ``javascript:`` / hostile ``data:`` URLs
11
+ from the carrier so a source image cannot turn a baked badge into an
12
+ XSS gadget.
13
+ - security(ob3): ``ecdsa-sd-2023`` presentations that omit ``validUntil``
14
+ or ``credentialStatus`` fail closed — a holder can no longer hide expiry
15
+ or revocation behind selective disclosure.
16
+ - security(oid4vci): explicit ``credential_endpoint`` / ``token_endpoint``
17
+ / ``nonce_endpoint`` overrides must be absolute ``https://`` URLs, not
18
+ plaintext HTTP.
19
+ - security(cli): ``signer.log`` is created and kept at mode ``0600``
20
+ (recipient emails); a file inherited from a permissive umask is
21
+ tightened on the next append.
22
+ - security(ci): wiki-sync authenticates clone/push with an HTTP extraheader
23
+ instead of embedding ``GITHUB_TOKEN`` in the git remote URL.
24
+ - security(mail): SMTP connections time out after 30 s; plaintext SMTP to
25
+ a non-loopback host is upgraded with STARTTLS (validating context).
26
+ - security(util): ``allow_insecure=True`` HTTP downloads pin the connect
27
+ IP the same way HTTPS already did (DNS rebinding).
28
+ - security(oid4vci): a live PIN-gated offer presented without ``tx_code``
29
+ returns the same ``invalid_grant`` as an unknown code, so the token
30
+ endpoint is not an oracle for which offers are PIN-gated.
31
+
32
+
33
+ * v4.4.3 - 2026-08-18
34
+
35
+ - security(oid4vci): an issuer that advertises
36
+ ``key_attestations_required`` now refuses a proof without
37
+ ``key_attestation`` before the signature is checked and before the
38
+ ``c_nonce`` is spent (openvc-core 1.26's ``require_key_attestation``).
39
+ Set ``oid4vci_key_attestations_required`` on the badge section, or pass
40
+ ``require_key_attestation=True`` to ``handle_credential_request``.
41
+ Without this, a software wallet's §8.3.1 retry spent a fresh nonce on
42
+ every attempt and looped.
43
+
44
+ - build(deps): raise the openvc-core floors to `>=1.26` in the `[eudi]`,
45
+ `[ldp-sd]` and `[oid4vci]` extras.
46
+
47
+ - security(oid4vci): `--reclaim-unclaimed` no longer frees a status-list
48
+ index whose credential was already delivered to a wallet. Issued grants
49
+ are kept past the offer TTL (they are the only proof of delivery), and
50
+ a missing grant row is treated as undecided rather than "never claimed".
51
+ - security(oid4vci): presenting a pre-authorized code again after the
52
+ wallet has claimed no longer demotes the grant from issued to
53
+ invalidated (tokens are still revoked). That demotion made the next
54
+ `--reclaim-unclaimed` free the delivered credential's status index.
55
+ - security(ob2): a HostedBadge BadgeClass that embeds an issuer Profile
56
+ object is no longer trusted as-is. The Profile at ``issuer.id`` is
57
+ fetched and used for scope and revocation.
58
+ - security(publish): ``sd_jwt_vct`` paths are POSIX-normalized; a ``..``
59
+ that would write Type Metadata outside ``-o`` is refused.
60
+ - security(ob1): an empty revocation-list response fails closed instead
61
+ of treating the badge as not revoked.
62
+ - security(mail): attachment filenames are RFC 2231 encoded, and CR/LF
63
+ or quotes in a badge/recipient name are rejected before they can
64
+ inject MIME headers.
65
+ - security(ob2): HostedBadge ``startsWith`` is an origin + path-segment
66
+ prefix, not a raw string prefix (blocks subdomain / userinfo tricks).
67
+ - fix(oid4vci): a Credential Offer that fails validation (e.g. batch_size
68
+ > 1 with status_lists) no longer reserves a status-list index first.
69
+ - fix(ci): the Ubuntu test job installs ``[oid4vci]`` and sets
70
+ ``OPENBADGES_REQUIRE_OID4VCI=1`` so the credential-endpoint suite cannot
71
+ skip itself green.
72
+ - fix(oid4vci): minting an access token stretches the grant to cover the
73
+ advertised ``expires_in``, so redeeming late in the offer window no
74
+ longer clips the token's lifetime.
75
+
76
+
7
77
  * v4.4.2 - 2026-08-13
8
78
 
9
79
  - 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.4
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:
@@ -29,7 +29,7 @@
29
29
  from struct import pack
30
30
  from zlib import crc32
31
31
 
32
- from typing import List, Optional, Tuple, Union, cast
32
+ from typing import Any, List, Optional, Tuple, Union, cast
33
33
 
34
34
  from defusedxml.minidom import parseString
35
35
  from png import Reader, signature as _png_signature
@@ -76,6 +76,66 @@ def _bounded_inflate(data: bytes, limit: int = MAX_ITXT_DECOMPRESSED) -> bytes:
76
76
 
77
77
  # ── SVG ─────────────────────────────────────────────────────────────────────
78
78
 
79
+ # Active content stripped from the carrier at bake time. A baked badge is an
80
+ # image a recipient may open in a browser; leaving <script>, on* handlers or
81
+ # javascript: URLs in the source SVG would execute in that context (#328).
82
+ # `opacity` is the one SVG presentation attribute whose local name starts
83
+ # with "on" and is not an event handler.
84
+ _ACTIVE_SVG_ELEMENTS = frozenset({
85
+ 'script', 'foreignobject', 'iframe', 'embed', 'object', 'handler',
86
+ })
87
+ _URL_ATTRS = frozenset({'href', 'src', 'action'})
88
+ _SAFE_DATA_IMAGE_TYPES = (
89
+ 'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
90
+ )
91
+
92
+
93
+ def _svg_local_name(node: Any) -> str:
94
+ return str(node.tagName).split(':')[-1].lower()
95
+
96
+
97
+ def _iter_svg_elements(node: Any) -> List[Any]:
98
+ found: List[Any] = []
99
+ if getattr(node, 'nodeType', None) == node.ELEMENT_NODE:
100
+ found.append(node)
101
+ for child in list(node.childNodes):
102
+ found.extend(_iter_svg_elements(child))
103
+ return found
104
+
105
+
106
+ def _is_active_url(value: str) -> bool:
107
+ """True for javascript:/vbscript: and for data: that is not a raster image."""
108
+ lowered = value.strip().lower()
109
+ if lowered.startswith('javascript:') or lowered.startswith('vbscript:'):
110
+ return True
111
+ if not lowered.startswith('data:'):
112
+ return False
113
+ payload = lowered[5:]
114
+ return not any(payload.startswith(kind) for kind in _SAFE_DATA_IMAGE_TYPES)
115
+
116
+
117
+ def _strip_svg_active_content(root: Any) -> None:
118
+ """Drop script-class elements, on* handlers and active URLs under *root*."""
119
+ if root is None:
120
+ return
121
+ for el in _iter_svg_elements(root):
122
+ if _svg_local_name(el) in _ACTIVE_SVG_ELEMENTS:
123
+ parent = el.parentNode
124
+ if parent is not None:
125
+ parent.removeChild(el)
126
+ for el in _iter_svg_elements(root):
127
+ attrs = el.attributes
128
+ if attrs is None:
129
+ continue
130
+ for name in list(attrs.keys()):
131
+ local = name.split(':')[-1].lower()
132
+ if local.startswith('on') and local != 'opacity':
133
+ el.removeAttribute(name)
134
+ continue
135
+ if local in _URL_ATTRS and _is_active_url(attrs[name].value):
136
+ el.removeAttribute(name)
137
+
138
+
79
139
  def bake_svg(image_bytes: bytes, token: str, comment: Optional[str] = None, *,
80
140
  element: str = SVG_ELEMENT, namespace: str = SVG_NS,
81
141
  as_text: bool = False) -> bytes:
@@ -87,10 +147,15 @@ def bake_svg(image_bytes: bytes, token: str, comment: Optional[str] = None, *,
87
147
  instead of the ``verify`` attribute — the OB 3.0 carrier for credentials
88
148
  secured with a Data Integrity proof, whose payload is a JSON document
89
149
  rather than a compact JWT (OB 3.0 §5.3).
150
+
151
+ Active content in the source SVG (``<script>``, event-handler attributes,
152
+ ``javascript:`` / hostile ``data:`` URLs) is stripped before the token is
153
+ attached, so the baked carrier is not an XSS gadget (#328).
90
154
  """
91
155
  svg_doc = parseString(image_bytes)
92
156
  try:
93
157
  svg_tag = svg_doc.getElementsByTagName('svg').item(0)
158
+ _strip_svg_active_content(svg_tag)
94
159
  node = svg_doc.createElement(element)
95
160
  node.attributes['xmlns:openbadges'] = namespace
96
161
  if as_text:
@@ -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
@@ -304,7 +305,15 @@ def oid4vci_config(conf: ConfigParser) -> OID4VCIConfig:
304
305
 
305
306
  def endpoint(key: str, default_path: str) -> str:
306
307
  explicit = (section.get(key) or '').strip() if section else ''
307
- return explicit or urljoin(endpoint_base, default_path)
308
+ url = explicit or urljoin(endpoint_base, default_path)
309
+ # Derived endpoints inherit https from credential_issuer; an explicit
310
+ # override can be any string, and a plaintext http URL would advertise
311
+ # a token/credential/nonce endpoint wallets POST secrets to (#328).
312
+ if not url.startswith('https://'):
313
+ raise ConfigError(
314
+ "[oid4vci] %s must be an absolute https URL, got %r"
315
+ % (key, url))
316
+ return url
308
317
 
309
318
  store_path = (section.get('store_path') if section else None) or \
310
319
  os.path.join(conf['paths']['base'], 'oid4vci.sqlite3')
@@ -380,6 +389,52 @@ def oid4vci_formats(conf: ConfigParser, badge_section: str) -> Tuple[str, ...]:
380
389
  return tuple(formats)
381
390
 
382
391
 
392
+ def oid4vci_key_attestations_required(
393
+ conf: ConfigParser, badge_section: str) -> Optional[Dict[str, Any]]:
394
+ """The key-attestation policy a badge advertises over OID4VCI, or None.
395
+
396
+ Absent (the default) means the issuer does **not** require a key
397
+ attestation, and OpenID4VCI 1.0 §12.2.4 says the parameter MUST NOT then
398
+ appear in metadata. Present means it is required:
399
+
400
+ * ``true`` / ``yes`` / ``1`` / ``{}`` / empty advertise an unconstrained
401
+ requirement (a key attestation is needed, with no extra constraints);
402
+ * a JSON object is the Appendix D.2 constraint set, validated by the same
403
+ shape check :func:`~openbadgeslib.ob3.eudi.badge_credential_configuration`
404
+ uses.
405
+
406
+ The credential endpoint reads this same key, so advertising the
407
+ requirement without enforcing it — the wallet-retry loop openvc-core
408
+ 1.26's ``require_key_attestation`` exists to close — cannot happen from
409
+ configuration alone.
410
+ """
411
+ if (badge_section not in conf
412
+ or 'oid4vci_key_attestations_required' not in conf[badge_section]):
413
+ return None
414
+ raw = (conf[badge_section].get('oid4vci_key_attestations_required')
415
+ or '').strip()
416
+ if raw.lower() in ('', 'true', 'yes', '1', '{}'):
417
+ value: Any = {}
418
+ else:
419
+ try:
420
+ parsed = json.loads(raw)
421
+ except ValueError as exc:
422
+ raise ConfigError(
423
+ "[%s] oid4vci_key_attestations_required must be true or a "
424
+ "JSON object, got %r" % (badge_section, raw)) from exc
425
+ if not isinstance(parsed, dict):
426
+ raise ConfigError(
427
+ "[%s] oid4vci_key_attestations_required must be a JSON "
428
+ "object, got %s" % (badge_section, type(parsed).__name__))
429
+ value = parsed
430
+ from .ob3.eudi import EudiError, _key_attestations_required
431
+ try:
432
+ return _key_attestations_required(value)
433
+ except EudiError as exc:
434
+ raise ConfigError("[%s] oid4vci_key_attestations_required: %s"
435
+ % (badge_section, exc)) from exc
436
+
437
+
383
438
  # ── centralized config defaults ──────────────────────────────────────────────
384
439
  # One inventory for the accepted-key defaults, so each lives in a single place
385
440
  # 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
 
@@ -33,6 +33,18 @@ from email.header import Header
33
33
  from .badge_model import BadgeImgType # version-neutral model (was ob1.badge)
34
34
  from .errors import BadgeImgFormatUnsupported
35
35
 
36
+ #: Per-operation socket timeout for SMTP connect/read/write. smtplib's
37
+ #: default is None (block forever).
38
+ SMTP_TIMEOUT_SECONDS = 30
39
+
40
+
41
+ def _smtp_is_loopback(host: str) -> bool:
42
+ """True for the hostnames/addresses operators use for a local MTA."""
43
+ name = host.strip().lower()
44
+ if name.startswith('[') and name.endswith(']'):
45
+ name = name[1:-1]
46
+ return name in ('localhost', '127.0.0.1', '::1')
47
+
36
48
 
37
49
  class BadgeMail():
38
50
  def __init__(self, smtp_server: str = 'localhost', smtp_port: int = 25,
@@ -67,10 +79,12 @@ class BadgeMail():
67
79
  raise BadgeImgFormatUnsupported(
68
80
  'Unsupported image type: %r' % (badge.source.image_type,))
69
81
 
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)
82
+ image = MIMEImage(badge.source.image, _subtype=mime_type)
83
+ # Keyword form so the filename is RFC 2231 encoded, never interpolated
84
+ # into the header value as raw bytes (#316).
85
+ image.add_header('Content-Disposition', 'attachment',
86
+ filename=basename(badge.file_out))
87
+ image.add_header('Content-Description', 'Signed OpenBadge')
74
88
  msg.attach(image)
75
89
 
76
90
  try:
@@ -81,9 +95,17 @@ class BadgeMail():
81
95
  # CERT_NONE), which would let an on-path attacker intercept the
82
96
  # connection and capture the AUTH credentials sent below.
83
97
  smtp = SMTP_SSL(self.smtp_server, self.smtp_port,
98
+ timeout=SMTP_TIMEOUT_SECONDS,
84
99
  context=ssl.create_default_context())
85
100
  else:
86
- smtp = SMTP(self.smtp_server, self.smtp_port)
101
+ smtp = SMTP(self.smtp_server, self.smtp_port,
102
+ timeout=SMTP_TIMEOUT_SECONDS)
103
+ # Plain SMTP to a non-loopback host is upgraded with STARTTLS
104
+ # so the message (and any later AUTH) is not sent in the
105
+ # clear. localhost is left alone: a local MTA often has no
106
+ # TLS listener on :25 (#328).
107
+ if not _smtp_is_loopback(self.smtp_server):
108
+ smtp.starttls(context=ssl.create_default_context())
87
109
 
88
110
  if self.username:
89
111
  smtp.login(self.username, self.password)
@@ -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)
@@ -453,6 +453,29 @@ def _reject_unsigned_ldp_aliases(doc: dict[str, Any]) -> None:
453
453
  "not sign; use BitstringStatusListEntry")
454
454
 
455
455
 
456
+ def _reject_hidden_sd_lifecycle(doc: dict[str, Any], proof: dict[str, Any]) -> None:
457
+ """Fail closed when an ecdsa-sd-2023 presentation omits lifecycle claims.
458
+
459
+ Selective disclosure lets a holder drop statements the issuer signed.
460
+ ``validUntil`` and ``credentialStatus`` are the ones that bound a badge's
461
+ life: hiding them makes an expired or revoked credential look perpetual.
462
+ eddsa-rdfc-2022 signs the whole document, so this only applies to
463
+ ecdsa-sd-2023. The low-level ``verify_data_integrity_proof`` does not
464
+ enforce it (it checks the proof alone); ``OB3LdpVerifier.verify`` does
465
+ (#328).
466
+ """
467
+ if proof.get('cryptosuite') != 'ecdsa-sd-2023':
468
+ return
469
+ if 'validUntil' not in doc:
470
+ raise OB3VerificationError(
471
+ "ecdsa-sd-2023 presentation omitted validUntil, which a holder "
472
+ "could hide to un-expire the badge; disclose validUntil")
473
+ if 'credentialStatus' not in doc:
474
+ raise OB3VerificationError(
475
+ "ecdsa-sd-2023 presentation omitted credentialStatus, which a "
476
+ "holder could hide to un-revoke the badge; disclose credentialStatus")
477
+
478
+
456
479
  class OB3LdpVerifier:
457
480
  """Verifies OpenBadges 3.0 credentials secured with a Data Integrity
458
481
  (Linked Data Proof) embedded proof — cryptosuites eddsa-rdfc-2022 and,
@@ -511,7 +534,9 @@ class OB3LdpVerifier:
511
534
  own signature is verified too (reusing a pinned key, or resolving the
512
535
  issuer DID), unless ``verify_status_list=False``. ``download`` resolves
513
536
  a did:web verificationMethod and status list issuer DID (injectable for
514
- testing).
537
+ testing). An ``ecdsa-sd-2023`` presentation that omits ``validUntil``
538
+ or ``credentialStatus`` is rejected so a holder cannot hide expiry or
539
+ revocation (#328).
515
540
  """
516
541
  doc = self._parse_document(document)
517
542
 
@@ -528,6 +553,9 @@ class OB3LdpVerifier:
528
553
 
529
554
  proof = _select_proof(doc)
530
555
  _validate_proof(proof, 'assertionMethod')
556
+ # Before resolving keys or running crypto: an SD presentation that hid
557
+ # expiry/revocation is already a fail, regardless of the proof.
558
+ _reject_hidden_sd_lifecycle(doc, proof)
531
559
 
532
560
  vm = proof['verificationMethod']
533
561
  vm_did = vm.partition('#')[0]
@@ -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)
@@ -76,7 +76,10 @@ INVALID_NONCE = 'invalid_nonce'
76
76
  INVALID_ENCRYPTION_PARAMETERS = 'invalid_encryption_parameters'
77
77
  #: The pre-authorized code was unknown, expired, already redeemed or killed.
78
78
  INVALID_GRANT = 'invalid_grant'
79
- #: The token request itself was malformed (missing tx_code, bad grant type).
79
+ #: The token request itself was malformed (bad grant type, empty code).
80
+ #: A live PIN-gated offer missing tx_code is invalid_grant, not this — the
81
+ #: distinct invalid_request was an oracle that the offer exists and is
82
+ #: PIN-gated (#328).
80
83
  INVALID_REQUEST = 'invalid_request'
81
84
  #: The access token was missing, unknown or expired. Served as HTTP 401 with a
82
85
  #: ``WWW-Authenticate: Bearer`` header, per RFC 6750 — not 400.