openbadgeslib 4.4.3__tar.gz → 4.5.0__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 (171) hide show
  1. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/Changelog.txt +53 -0
  2. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/PKG-INFO +1 -1
  3. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/baking.py +66 -1
  4. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/confparser.py +9 -1
  5. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/mail.py +21 -1
  6. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob2/verifier.py +82 -13
  7. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/__init__.py +13 -1
  8. openbadgeslib-4.5.0/openbadgeslib/ob3/clr.py +176 -0
  9. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/__init__.py +3 -0
  10. openbadgeslib-4.5.0/openbadgeslib/ob3/contexts/clr_context_2.0.1.json +62 -0
  11. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/ldp.py +29 -1
  12. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/status.py +57 -9
  13. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/status_list.py +20 -7
  14. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/errors.py +4 -1
  15. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/handler.py +7 -4
  16. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_signer.py +26 -4
  17. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/util.py +81 -36
  18. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib.egg-info/PKG-INFO +1 -1
  19. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib.egg-info/SOURCES.txt +4 -0
  20. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/conformance/Dockerfile.ob3 +11 -1
  21. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/conformance/README.md +6 -1
  22. openbadgeslib-4.5.0/tests/conformance/relax-danubetech-checksums.awk +24 -0
  23. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_baking.py +32 -0
  24. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_cli_smoke.py +26 -0
  25. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_mail.py +16 -0
  26. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob2_error_branches.py +2 -1
  27. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob2_verifier.py +56 -1
  28. openbadgeslib-4.5.0/tests/test_ob3_clr.py +144 -0
  29. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_ldp_ecdsa_sd.py +64 -5
  30. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_status.py +37 -0
  31. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_status_list.py +18 -0
  32. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_oid4vci_config.py +10 -0
  33. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_oid4vci_protocol.py +7 -2
  34. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_util.py +26 -0
  35. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/CONTRIBUTING.md +0 -0
  36. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/GOVERNANCE.md +0 -0
  37. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/LICENSE.txt +0 -0
  38. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/MANIFEST.in +0 -0
  39. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/README.md +0 -0
  40. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/SECURITY.md +0 -0
  41. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/docs/README.md +0 -0
  42. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/__init__.py +0 -0
  43. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/_jsonmodel.py +0 -0
  44. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/_jws/__init__.py +0 -0
  45. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/_jws/exceptions.py +0 -0
  46. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/_jws/utils.py +0 -0
  47. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/_ob1_api.py +0 -0
  48. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/badge_model.py +0 -0
  49. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/cli_common.py +0 -0
  50. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/config.ini.example +0 -0
  51. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/errors.py +0 -0
  52. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/issue.py +0 -0
  53. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/keys.py +0 -0
  54. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/logs.py +0 -0
  55. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob1/__init__.py +0 -0
  56. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob1/badge.py +0 -0
  57. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob1/signer.py +0 -0
  58. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob1/verifier.py +0 -0
  59. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob2/__init__.py +0 -0
  60. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob2/models.py +0 -0
  61. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob2/signer.py +0 -0
  62. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
  63. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
  64. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
  65. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
  66. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/ob-v3p0-extensions.json +0 -0
  67. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
  68. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
  69. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/credential.py +0 -0
  70. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/did.py +0 -0
  71. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/eudi.py +0 -0
  72. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/publish.py +0 -0
  73. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/signer.py +0 -0
  74. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/status_registry.py +0 -0
  75. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/verifier.py +0 -0
  76. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/__init__.py +0 -0
  77. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/codes.py +0 -0
  78. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/formats.py +0 -0
  79. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/memory_store.py +0 -0
  80. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/metadata.py +0 -0
  81. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/nonce.py +0 -0
  82. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/offer.py +0 -0
  83. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/reconcile.py +0 -0
  84. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/sqlite_store.py +0 -0
  85. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/store.py +0 -0
  86. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_cli.py +0 -0
  87. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_init.py +0 -0
  88. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_keygenerator.py +0 -0
  89. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_publish.py +0 -0
  90. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_status.py +0 -0
  91. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_verifier.py +0 -0
  92. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/py.typed +0 -0
  93. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/verify.py +0 -0
  94. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib.egg-info/dependency_links.txt +0 -0
  95. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib.egg-info/entry_points.txt +0 -0
  96. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib.egg-info/requires.txt +0 -0
  97. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib.egg-info/top_level.txt +0 -0
  98. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/pyproject.toml +0 -0
  99. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/setup.cfg +0 -0
  100. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/config1.ini +0 -0
  101. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/conformance/conftest.py +0 -0
  102. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/conformance/docker-compose.yml +0 -0
  103. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/conformance/test_official_validators.py +0 -0
  104. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/conftest.py +0 -0
  105. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/fixtures/ob_schemas/README.md +0 -0
  106. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
  107. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
  108. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/fixtures/ob_schemas/refresh.sh +0 -0
  109. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/fixtures/vc_di_eddsa/README.md +0 -0
  110. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
  111. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
  112. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
  113. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
  114. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
  115. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/images/sample1.png +0 -0
  116. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/images/sample1.svg +0 -0
  117. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/images/userimage01.svg +0 -0
  118. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/ldp_helpers.py +0 -0
  119. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/logo Python Espan/314/203a.svg" +0 -0
  120. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/logo Python Espa/303/261a.svg" +0 -0
  121. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_cli_json.py +0 -0
  122. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_confparser.py +0 -0
  123. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_docs.py +0 -0
  124. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_eddsa.py +0 -0
  125. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_errors.py +0 -0
  126. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_examples.py +0 -0
  127. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_issue.py +0 -0
  128. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_issue_credential.py +0 -0
  129. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_jsonmodel.py +0 -0
  130. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_jws.py +0 -0
  131. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_key_operation.py +0 -0
  132. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_keys.py +0 -0
  133. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob1_badge_io.py +0 -0
  134. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob1_deprecation.py +0 -0
  135. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob1_signer.py +0 -0
  136. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob1_verifier.py +0 -0
  137. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob2_cli.py +0 -0
  138. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob2_models.py +0 -0
  139. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob2_signer.py +0 -0
  140. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_conformance_schema.py +0 -0
  141. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_contexts.py +0 -0
  142. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_credential.py +0 -0
  143. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_did.py +0 -0
  144. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_did_doc.py +0 -0
  145. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_endorsement.py +0 -0
  146. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_eudi_oid4vci.py +0 -0
  147. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_eudi_sd_jwt.py +0 -0
  148. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_eudi_type_metadata.py +0 -0
  149. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_eudi_x5c.py +0 -0
  150. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_ldp.py +0 -0
  151. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_ldp_cli.py +0 -0
  152. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_ldp_errors.py +0 -0
  153. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_ldp_sign.py +0 -0
  154. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_publish_cli.py +0 -0
  155. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_signer.py +0 -0
  156. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_signer_cli_ldp.py +0 -0
  157. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_status_registry.py +0 -0
  158. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_verifier.py +0 -0
  159. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_oid4vci_reservations.py +0 -0
  160. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_oid4vci_store.py +0 -0
  161. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_openbadges_cli.py +0 -0
  162. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_public_api.py +0 -0
  163. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_sign_ecc.pem +0 -0
  164. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_sign_rsa.pem +0 -0
  165. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_signer_batch.py +0 -0
  166. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_verifier_cli_coverage.py +0 -0
  167. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_verify.py +0 -0
  168. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_verify_ecc.pem +0 -0
  169. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_verify_rsa.pem +0 -0
  170. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/withoutxmlheader.svg +0 -0
  171. {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/withxmlheader.svg +0 -0
@@ -4,6 +4,59 @@ OpenBadgesLib - Changelog
4
4
  Newest first. Dates are ISO 8601 (YYYY-MM-DD).
5
5
 
6
6
 
7
+ * v4.5.0 - 2026-09-12
8
+
9
+ - feat(ob3): public `decode_encoded_list(encoded) -> bytes` and
10
+ `set_indices(bitstring) -> frozenset[int]` for a published Bitstring
11
+ Status List `encodedList` (#322). Same multibase `u` / base64url /
12
+ bounded-gzip (5 MiB) / MSB-first decode the verifier already uses;
13
+ the private `_decode_encoded_list` name remains as a thin alias.
14
+
15
+ - feat(ob3): `build_status_list_credential(..., ttl=)` writes Bitstring
16
+ Status List §2.2 `ttl` (milliseconds) on `credentialSubject`, never at
17
+ the top level — `ttl` is a JSON-LD term scoped to `BitstringStatusList`,
18
+ so a top-level copy would not be signed (#321). Negative values raise
19
+ `ValueError`.
20
+
21
+ - feat(ob3): native CLR 2.0 `ClrCredential` builder (`build_clr_credential`,
22
+ `sign_clr_credential`) with the versioned `context-2.0.1.json` bundled
23
+ and hash-pinned (`CLR_CONTEXT_SHA256`) (#323). Identifier objects and
24
+ member credentials are copied as given — email hashing and member-count
25
+ caps stay downstream. Signing injects the pinned context via
26
+ `extra_contexts` (requires `[ldp]`).
27
+
28
+ - feat(ob2): `OB2Verifier.verify_hosted(document)` verifies a hosted
29
+ assertion JSON document (dict or JSON string) without synthesizing a
30
+ dummy JWS. The HTTPS fetcher is injectable via
31
+ `OB2Verifier(..., download=)`.
32
+
33
+
34
+ * v4.4.4 - 2026-09-10
35
+
36
+ - security(baking): SVG baking strips ``<script>`` / ``foreignObject`` /
37
+ event-handler attributes and ``javascript:`` / hostile ``data:`` URLs
38
+ from the carrier so a source image cannot turn a baked badge into an
39
+ XSS gadget.
40
+ - security(ob3): ``ecdsa-sd-2023`` presentations that omit ``validUntil``
41
+ or ``credentialStatus`` fail closed — a holder can no longer hide expiry
42
+ or revocation behind selective disclosure.
43
+ - security(oid4vci): explicit ``credential_endpoint`` / ``token_endpoint``
44
+ / ``nonce_endpoint`` overrides must be absolute ``https://`` URLs, not
45
+ plaintext HTTP.
46
+ - security(cli): ``signer.log`` is created and kept at mode ``0600``
47
+ (recipient emails); a file inherited from a permissive umask is
48
+ tightened on the next append.
49
+ - security(ci): wiki-sync authenticates clone/push with an HTTP extraheader
50
+ instead of embedding ``GITHUB_TOKEN`` in the git remote URL.
51
+ - security(mail): SMTP connections time out after 30 s; plaintext SMTP to
52
+ a non-loopback host is upgraded with STARTTLS (validating context).
53
+ - security(util): ``allow_insecure=True`` HTTP downloads pin the connect
54
+ IP the same way HTTPS already did (DNS rebinding).
55
+ - security(oid4vci): a live PIN-gated offer presented without ``tx_code``
56
+ returns the same ``invalid_grant`` as an unknown code, so the token
57
+ endpoint is not an oracle for which offers are PIN-gated.
58
+
59
+
7
60
  * v4.4.3 - 2026-08-18
8
61
 
9
62
  - security(oid4vci): an issuer that advertises
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openbadgeslib
3
- Version: 4.4.3
3
+ Version: 4.5.0
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
@@ -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:
@@ -305,7 +305,15 @@ def oid4vci_config(conf: ConfigParser) -> OID4VCIConfig:
305
305
 
306
306
  def endpoint(key: str, default_path: str) -> str:
307
307
  explicit = (section.get(key) or '').strip() if section else ''
308
- 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
309
317
 
310
318
  store_path = (section.get('store_path') if section else None) or \
311
319
  os.path.join(conf['paths']['base'], 'oid4vci.sqlite3')
@@ -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,
@@ -83,9 +95,17 @@ class BadgeMail():
83
95
  # CERT_NONE), which would let an on-path attacker intercept the
84
96
  # connection and capture the AUTH credentials sent below.
85
97
  smtp = SMTP_SSL(self.smtp_server, self.smtp_port,
98
+ timeout=SMTP_TIMEOUT_SECONDS,
86
99
  context=ssl.create_default_context())
87
100
  else:
88
- 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())
89
109
 
90
110
  if self.username:
91
111
  smtp.login(self.username, self.password)
@@ -22,7 +22,7 @@
22
22
 
23
23
  import logging
24
24
  from datetime import datetime, timezone
25
- from typing import Any, List, Optional, Union
25
+ from typing import Any, Callable, List, Optional, Union
26
26
  from urllib.parse import urlparse
27
27
 
28
28
  from .models import Assertion, CryptographicKey, hash_identity, _parse_iso
@@ -67,10 +67,16 @@ class OB2Verifier:
67
67
  pubkey_pem: Optional PEM-encoded trusted public key. When supplied it is
68
68
  used to verify SignedBadge assertions and skips resolving the
69
69
  badge-declared key.
70
+ download: Optional ``url -> bytes`` fetcher used by hosted verification
71
+ (assertion id, BadgeClass, issuer Profile, revocation list).
72
+ Defaults to :func:`download_file` (HTTPS-only, size-capped).
70
73
  """
71
74
 
72
- def __init__(self, pubkey_pem: Optional[Any] = None) -> None:
75
+ def __init__(self, pubkey_pem: Optional[Any] = None, *,
76
+ download: Optional[Callable[[str], bytes]] = None) -> None:
73
77
  self.trusted_pubkey_pem: Optional[Union[str, bytes]] = None
78
+ self._download: Callable[[str], bytes] = (
79
+ download if download is not None else download_file)
74
80
  if pubkey_pem is not None:
75
81
  pem = key_to_pem(pubkey_pem)
76
82
  try:
@@ -122,6 +128,67 @@ class OB2Verifier:
122
128
 
123
129
  return assertion
124
130
 
131
+ def verify_hosted(self, document: Union[str, dict[str, Any]], *,
132
+ expected_recipient: Optional[str] = None,
133
+ check_revocation: bool = False) -> Assertion:
134
+ """Verify a hosted OB 2.0 assertion JSON document (not a compact JWS).
135
+
136
+ *document* is the assertion as a dict or a JSON string. The hosted
137
+ model is fetch-from-id: the copy served at ``assertion.id`` is the
138
+ trust anchor (scoped to the issuer Profile), so this does not
139
+ synthesize a dummy JWS. Defence-in-depth JWS verification inside
140
+ :meth:`_verify_hosted` is skipped when there is no token.
141
+
142
+ ``expected_recipient`` and ``check_revocation`` behave as in
143
+ :meth:`verify`. Raises :class:`OB2VerificationError` if the document
144
+ is not a HostedBadge assertion or if any check fails.
145
+ """
146
+ parsed = self._hosted_document(document)
147
+ try:
148
+ assertion = Assertion.from_dict(parsed)
149
+ except ValueError as exc:
150
+ raise OB2VerificationError(
151
+ "Malformed OB 2.0 assertion: %s" % exc) from exc
152
+ if assertion.verification.type != "HostedBadge":
153
+ raise OB2VerificationError(
154
+ "verify_hosted() requires verification.type HostedBadge, got %r"
155
+ % assertion.verification.type)
156
+
157
+ assertion = self._verify_hosted(assertion, "")
158
+
159
+ self._check_expiration(assertion)
160
+
161
+ if expected_recipient is not None:
162
+ self._check_identity(assertion, expected_recipient)
163
+
164
+ if check_revocation:
165
+ self._check_revocation(assertion)
166
+
167
+ return assertion
168
+
169
+ @staticmethod
170
+ def _hosted_document(document: Union[str, dict[str, Any]]) -> dict[str, Any]:
171
+ """Parse a hosted assertion from a dict or JSON string (never a JWS)."""
172
+ if isinstance(document, str):
173
+ text = document.strip()
174
+ if text.count('.') == 2 and not text.startswith('{'):
175
+ raise OB2VerificationError(
176
+ "verify_hosted() expects a hosted assertion JSON document, "
177
+ "not a compact JWS; use verify() for SignedBadge tokens")
178
+ try:
179
+ parsed = jws_utils.from_json(text)
180
+ except Exception as exc:
181
+ raise OB2VerificationError(
182
+ "hosted assertion is not valid JSON: %s" % exc) from exc
183
+ elif isinstance(document, dict):
184
+ parsed = document
185
+ else:
186
+ raise OB2VerificationError(
187
+ "hosted assertion must be a dict or JSON string")
188
+ if not isinstance(parsed, dict):
189
+ raise OB2VerificationError("hosted assertion is not a JSON object")
190
+ return parsed
191
+
125
192
  # ── signed path ──────────────────────────────────────────────────────────────
126
193
 
127
194
  def _verify_signed(self, assertion: Assertion, token: str) -> None:
@@ -255,16 +322,18 @@ class OB2Verifier:
255
322
  # verify it, but never fail the hosted verdict on it (a hosted badge is
256
323
  # not required to be signed at all). The creator comes from the
257
324
  # authoritative copy, so this never dereferences a holder-chosen URL.
258
- try:
259
- if self.trusted_pubkey_pem is not None:
260
- self._verify_jws(token, self.trusted_pubkey_pem)
261
- elif authoritative.verification.creator:
262
- key = self._resolve_creator(authoritative.verification.creator)
263
- self._verify_jws(token, key.public_key_pem.encode('utf-8'))
264
- except OB2VerificationError as exc:
265
- logger.debug("Hosted assertion %s: baked JWS did not verify (%s); "
266
- "hosted trust comes from the HTTPS fetch, not the signature.",
267
- assertion.id, exc)
325
+ # verify_hosted() passes an empty token (there is no JWS); skip then.
326
+ if token:
327
+ try:
328
+ if self.trusted_pubkey_pem is not None:
329
+ self._verify_jws(token, self.trusted_pubkey_pem)
330
+ elif authoritative.verification.creator:
331
+ key = self._resolve_creator(authoritative.verification.creator)
332
+ self._verify_jws(token, key.public_key_pem.encode('utf-8'))
333
+ except OB2VerificationError as exc:
334
+ logger.debug("Hosted assertion %s: baked JWS did not verify (%s); "
335
+ "hosted trust comes from the HTTPS fetch, not the signature.",
336
+ assertion.id, exc)
268
337
  return authoritative
269
338
 
270
339
  def _check_hosted_scope(self, assertion_id: str, issuer: dict[str, Any]) -> None:
@@ -373,7 +442,7 @@ class OB2Verifier:
373
442
 
374
443
  def _fetch_json(self, url: str, where: str) -> dict[str, Any]:
375
444
  try:
376
- raw = download_file(url)
445
+ raw = self._download(url)
377
446
  except Exception as exc:
378
447
  raise OB2VerificationError(
379
448
  "Could not fetch %s at %s: %s" % (where, url, exc)) from exc
@@ -27,9 +27,12 @@ from .verifier import (OB3VerificationError, OB3Verifier,
27
27
  verify_endorsement_jwt)
28
28
  from .ldp import (OB3LdpSigner, OB3LdpVerifier, add_data_integrity_proof,
29
29
  verify_data_integrity_proof)
30
- from .status import check_credential_status
30
+ from .status import check_credential_status, decode_encoded_list, set_indices
31
31
  from .status_list import (build_status_list_credential, encode_bitstring,
32
32
  sign_status_list_credential, status_entry)
33
+ from .clr import (CLR_CONTEXT_SHA256, CLR_CONTEXT_URL, OB_V3_CONTEXT,
34
+ VCDM_V2_CONTEXT, build_clr_credential, bundled_clr_contexts,
35
+ sign_clr_credential)
33
36
  from .status_registry import StatusRegistry
34
37
  from .did import (build_did_document, did_jwk_from_jwk, did_key_from_pem,
35
38
  did_web_from_url, multikey_from_pem, resolve_did,
@@ -68,16 +71,25 @@ __all__ = [
68
71
  'verify_data_integrity_proof',
69
72
  'StatusRegistry',
70
73
  'build_did_document',
74
+ 'build_clr_credential',
71
75
  'build_status_list_credential',
76
+ 'bundled_clr_contexts',
72
77
  'check_credential_status',
78
+ 'CLR_CONTEXT_SHA256',
79
+ 'CLR_CONTEXT_URL',
80
+ 'decode_encoded_list',
73
81
  'did_jwk_from_jwk',
74
82
  'did_key_from_pem',
75
83
  'did_web_from_url',
76
84
  'encode_bitstring',
77
85
  'multikey_from_pem',
86
+ 'OB_V3_CONTEXT',
78
87
  'resolve_did',
79
88
  'resolve_verification_method',
89
+ 'set_indices',
90
+ 'sign_clr_credential',
80
91
  'sign_status_list_credential',
81
92
  'status_entry',
93
+ 'VCDM_V2_CONTEXT',
82
94
  'verify_endorsement_jwt',
83
95
  ]
@@ -0,0 +1,176 @@
1
+ """
2
+ OpenBadges Library
3
+
4
+ Copyright (c) 2014-2026, Luis González Fernández, luisgf@luisgf.es
5
+ Copyright (c) 2014-2026, Jesús Cea Avión, jcea@jcea.es
6
+
7
+ All rights reserved.
8
+
9
+ This library is free software; you can redistribute it and/or
10
+ modify it under the terms of the GNU Lesser General Public
11
+ License as published by the Free Software Foundation; either
12
+ version 3.0 of the License, or (at your option) any later version.
13
+
14
+ This library is distributed in the hope that it will be useful,
15
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
16
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
17
+ Lesser General Public License for more details.
18
+
19
+ You should have received a copy of the GNU Lesser General Public
20
+ License along with this library.
21
+ """
22
+
23
+ # 1EdTech Comprehensive Learner Record 2.0 (Final v1.1) ClrCredential assembly.
24
+ #
25
+ # Builds an unsigned JSON-LD envelope whose credentialSubject.verifiableCredential
26
+ # array embeds already-signed member documents by value. Email hashing and
27
+ # member-count caps are product policy and stay downstream: this module copies
28
+ # the identifier objects and members it is given.
29
+ #
30
+ # The CLR JSON-LD context is pinned to the VERSIONED 2.0.1 revision. 1EdTech's
31
+ # unversioned …/clr/v2p0/context.json endpoint serves a stub whose ClrSubject
32
+ # scope defines no ``identifier`` term — signing against it silently drops the
33
+ # learner binding from the RDF. The versioned bytes are bundled next to the
34
+ # other contexts and hash-checked on every load so a bad bundle fails closed.
35
+
36
+ import copy
37
+ import hashlib
38
+ import json
39
+
40
+ from datetime import datetime, timezone
41
+ from importlib import resources
42
+ from typing import Any, Mapping, Optional, Sequence, Union
43
+
44
+ from ..errors import LibOpenBadgesException
45
+ from . import contexts as _contexts
46
+ from .credential import _iso
47
+
48
+ VCDM_V2_CONTEXT = "https://www.w3.org/ns/credentials/v2"
49
+ OB_V3_CONTEXT = "https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json"
50
+ CLR_CONTEXT_URL = "https://purl.imsglobal.org/spec/clr/v2p0/context-2.0.1.json"
51
+ CLR_CONTEXT_SHA256 = "880f1902d4158f2f33ce06af41b90719977cd1ea337a9de2e41edb3ff5e543d6"
52
+
53
+ _CLR_CONTEXT_RESOURCE = "clr_context_2.0.1.json"
54
+ _ENVELOPE_CONTEXTS = (VCDM_V2_CONTEXT, OB_V3_CONTEXT, CLR_CONTEXT_URL)
55
+
56
+
57
+ def _bundled_context_bytes() -> bytes:
58
+ """Raw bytes of the bundled CLR context file (split out so tests can stub)."""
59
+ return resources.files(_contexts).joinpath(_CLR_CONTEXT_RESOURCE).read_bytes()
60
+
61
+
62
+ def bundled_clr_contexts() -> dict[str, dict[str, Any]]:
63
+ """Pinned CLR 2.0.1 context mapping for Data Integrity ``extra_contexts``.
64
+
65
+ Hashes the bundled file's raw bytes against :data:`CLR_CONTEXT_SHA256` on
66
+ every call and raises :class:`LibOpenBadgesException` if they do not match
67
+ — a context is signing input, so a bad bundle must fail closed.
68
+ """
69
+ try:
70
+ raw = _bundled_context_bytes()
71
+ except (OSError, FileNotFoundError) as exc:
72
+ raise LibOpenBadgesException(
73
+ "bundled CLR context is unreadable") from exc
74
+ if hashlib.sha256(raw).hexdigest() != CLR_CONTEXT_SHA256:
75
+ raise LibOpenBadgesException(
76
+ "bundled CLR context does not match its pinned hash")
77
+ document = json.loads(raw)
78
+ if not isinstance(document, dict):
79
+ raise LibOpenBadgesException("bundled CLR context is not an object")
80
+ return {CLR_CONTEXT_URL: document}
81
+
82
+
83
+ def build_clr_credential(
84
+ issuer: Union[str, Mapping[str, Any]],
85
+ identifier: Sequence[Mapping[str, Any]],
86
+ members: Sequence[Mapping[str, Any]],
87
+ *,
88
+ credential_id: str,
89
+ valid_from: Optional[datetime] = None,
90
+ valid_until: Optional[datetime] = None,
91
+ status: Optional[Mapping[str, Any]] = None) -> dict[str, Any]:
92
+ """Build an unsigned CLR 2.0 ``ClrCredential`` JSON-LD document.
93
+
94
+ *identifier* is a list of IdentityObject dicts (already hashed — this
95
+ function does not hash emails). *members* are already-signed member
96
+ credential documents, copied into ``credentialSubject.verifiableCredential``.
97
+ *issuer* may be a Profile dict or a string URL. *status*, when given, is
98
+ copied onto ``credentialStatus``.
99
+
100
+ Raises ValueError if *members* is not a non-empty list of dicts, if
101
+ *credential_id* is empty, or if both *valid_from* and *valid_until* are
102
+ timezone-aware and *valid_until* is not strictly after *valid_from*.
103
+ """
104
+ if not isinstance(credential_id, str) or not credential_id:
105
+ raise ValueError("credential_id must be a non-empty string")
106
+ if not isinstance(members, list) or not members:
107
+ raise ValueError("members must be a non-empty list of credential dicts")
108
+ if not isinstance(identifier, list):
109
+ raise ValueError("identifier must be a list of IdentityObject dicts")
110
+
111
+ issued = valid_from if valid_from is not None else datetime.now(timezone.utc)
112
+ if (valid_until is not None
113
+ and issued.tzinfo is not None and valid_until.tzinfo is not None
114
+ and valid_until <= issued):
115
+ raise ValueError("validUntil must be after validFrom")
116
+
117
+ if isinstance(issuer, str):
118
+ if not issuer:
119
+ raise ValueError("issuer must be a non-empty string or a dict")
120
+ issuer_value: Union[str, dict[str, Any]] = issuer
121
+ elif isinstance(issuer, dict):
122
+ issuer_value = copy.deepcopy(dict(issuer))
123
+ else:
124
+ raise ValueError("issuer must be a string URL or a dict")
125
+
126
+ copied_ids: list[dict[str, Any]] = []
127
+ for i, item in enumerate(identifier):
128
+ if not isinstance(item, dict):
129
+ raise ValueError("identifier[%d] must be a JSON object" % i)
130
+ copied_ids.append(copy.deepcopy(item))
131
+
132
+ issuer_id = issuer_value if isinstance(issuer_value, str) else issuer_value.get("id")
133
+ copied_members: list[dict[str, Any]] = []
134
+ for i, member in enumerate(members):
135
+ if not isinstance(member, dict):
136
+ raise ValueError("members[%d] must be a JSON object" % i)
137
+ member_id = member.get("id", member.get("@id"))
138
+ if isinstance(member_id, str) and member_id in (credential_id, issuer_id):
139
+ raise ValueError(
140
+ "members[%d] id collides with the CLR envelope or issuer id" % i)
141
+ copied_members.append(copy.deepcopy(member))
142
+
143
+ document: dict[str, Any] = {
144
+ "@context": list(_ENVELOPE_CONTEXTS),
145
+ "type": ["VerifiableCredential", "ClrCredential"],
146
+ "id": credential_id,
147
+ "issuer": issuer_value,
148
+ "validFrom": _iso(issued),
149
+ "credentialSubject": {
150
+ "type": "ClrSubject",
151
+ "identifier": copied_ids,
152
+ "verifiableCredential": copied_members,
153
+ },
154
+ }
155
+ if valid_until is not None:
156
+ document["validUntil"] = _iso(valid_until)
157
+ if status is not None:
158
+ if not isinstance(status, dict):
159
+ raise ValueError("credentialStatus must be a JSON object")
160
+ document["credentialStatus"] = copy.deepcopy(dict(status))
161
+ return document
162
+
163
+
164
+ def sign_clr_credential(document: dict[str, Any], signing_key_pem: Any,
165
+ verification_method: str) -> dict[str, Any]:
166
+ """Sign a ``ClrCredential`` with an eddsa-rdfc-2022 Data Integrity proof.
167
+
168
+ Thin wrapper around :func:`add_data_integrity_proof` that injects the
169
+ hash-pinned CLR context via ``extra_contexts`` — the library never fetches
170
+ remote contexts, so without this injection canonicalization cannot see the
171
+ CLR terms. Requires the optional ``[ldp]`` extra.
172
+ """
173
+ from .ldp import add_data_integrity_proof
174
+ return add_data_integrity_proof(
175
+ document, signing_key_pem, verification_method,
176
+ extra_contexts=bundled_clr_contexts())
@@ -51,6 +51,9 @@
51
51
  # https://w3id.org/security/multikey/v1
52
52
  # security-multikey-v1.json
53
53
  # ba2c182de2d92f7e47184bcca8fcf0beaee6d3986c527bf664c195bbc7c58597
54
+ # https://purl.imsglobal.org/spec/clr/v2p0/context-2.0.1.json (captured 2026-08-01)
55
+ # clr_context_2.0.1.json
56
+ # 880f1902d4158f2f33ce06af41b90719977cd1ea337a9de2e41edb3ff5e543d6
54
57
  #
55
58
  # This module deliberately does NOT import pyld: it only hands out plain dicts
56
59
  # in the shape pyld's documentLoader expects, so it works (and is tested)
@@ -0,0 +1,62 @@
1
+ {
2
+ "@context": {
3
+ "id": "@id",
4
+ "type": "@type",
5
+ "ClrCredential": {
6
+ "@id": "https://purl.imsglobal.org/spec/vc/clr/vocab.html#ClrCredential",
7
+ "@context": {
8
+ "id": "@id",
9
+ "type": "@type",
10
+ "partial": {
11
+ "@id": "https://purl.imsglobal.org/spec/vc/clr/vocab.html#partial",
12
+ "@type": "xsd:boolean"
13
+ }
14
+ }
15
+ },
16
+ "ClrSubject": {
17
+ "@id": "https://purl.imsglobal.org/spec/vc/clr/vocab.html#ClrSubject",
18
+ "@context": {
19
+ "id": "@id",
20
+ "type": "@type",
21
+ "cred": "https://www.w3.org/2018/credentials#",
22
+ "obi": "https://purl.imsglobal.org/spec/vc/ob/vocab.html#",
23
+ "achievement": {
24
+ "@id": "https://purl.imsglobal.org/spec/vc/clr/vocab.html#achievement",
25
+ "@type": "obi:Achievement",
26
+ "@container": "@set"
27
+ },
28
+ "association": {
29
+ "@id": "https://purl.imsglobal.org/spec/vc/clr/vocab.html#association",
30
+ "@type": "https://purl.imsglobal.org/spec/vc/clr/vocab.html#Association",
31
+ "@container": "@set"
32
+ },
33
+ "verifiableCredential": {
34
+ "@id": "https://purl.imsglobal.org/spec/vc/clr/vocab.html#verifiableCredential",
35
+ "@type": "cred:verifiableCredential",
36
+ "@container": "@set"
37
+ },
38
+ "identifier": {
39
+ "@id": "https://purl.imsglobal.org/spec/vc/clr/vocab.html#identifier-1",
40
+ "@type": "obi:Identifier",
41
+ "@container": "@set"
42
+ }
43
+ }
44
+ },
45
+ "Association": {
46
+ "@id": "https://purl.imsglobal.org/spec/vc/clr/vocab.html#Association",
47
+ "@context": {
48
+ "associationType": {
49
+ "@id": "https://purl.imsglobal.org/spec/vc/clr/vocab.html#AssociationType"
50
+ },
51
+ "sourceId": {
52
+ "@id": "https://purl.imsglobal.org/spec/vc/clr/vocab.html#sourceId",
53
+ "@type": "xsd:anyURI"
54
+ },
55
+ "targetId": {
56
+ "@id": "https://purl.imsglobal.org/spec/vc/clr/vocab.html#targetId",
57
+ "@type": "xsd:anyURI"
58
+ }
59
+ }
60
+ }
61
+ }
62
+ }