openbadgeslib 4.4.4__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.4 → openbadgeslib-4.5.0}/Changelog.txt +27 -0
  2. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/PKG-INFO +1 -1
  3. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob2/verifier.py +82 -13
  4. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/__init__.py +13 -1
  5. openbadgeslib-4.5.0/openbadgeslib/ob3/clr.py +176 -0
  6. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/__init__.py +3 -0
  7. openbadgeslib-4.5.0/openbadgeslib/ob3/contexts/clr_context_2.0.1.json +62 -0
  8. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/status.py +57 -9
  9. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/status_list.py +20 -7
  10. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/util.py +1 -1
  11. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib.egg-info/PKG-INFO +1 -1
  12. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib.egg-info/SOURCES.txt +4 -0
  13. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/conformance/Dockerfile.ob3 +11 -1
  14. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/conformance/README.md +6 -1
  15. openbadgeslib-4.5.0/tests/conformance/relax-danubetech-checksums.awk +24 -0
  16. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob2_error_branches.py +2 -1
  17. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob2_verifier.py +56 -1
  18. openbadgeslib-4.5.0/tests/test_ob3_clr.py +144 -0
  19. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_status.py +37 -0
  20. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_status_list.py +18 -0
  21. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/CONTRIBUTING.md +0 -0
  22. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/GOVERNANCE.md +0 -0
  23. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/LICENSE.txt +0 -0
  24. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/MANIFEST.in +0 -0
  25. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/README.md +0 -0
  26. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/SECURITY.md +0 -0
  27. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/docs/README.md +0 -0
  28. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/__init__.py +0 -0
  29. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/_jsonmodel.py +0 -0
  30. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/_jws/__init__.py +0 -0
  31. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/_jws/exceptions.py +0 -0
  32. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/_jws/utils.py +0 -0
  33. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/_ob1_api.py +0 -0
  34. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/badge_model.py +0 -0
  35. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/baking.py +0 -0
  36. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/cli_common.py +0 -0
  37. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/config.ini.example +0 -0
  38. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/confparser.py +0 -0
  39. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/errors.py +0 -0
  40. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/issue.py +0 -0
  41. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/keys.py +0 -0
  42. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/logs.py +0 -0
  43. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/mail.py +0 -0
  44. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob1/__init__.py +0 -0
  45. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob1/badge.py +0 -0
  46. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob1/signer.py +0 -0
  47. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob1/verifier.py +0 -0
  48. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob2/__init__.py +0 -0
  49. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob2/models.py +0 -0
  50. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob2/signer.py +0 -0
  51. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
  52. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
  53. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
  54. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
  55. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/ob-v3p0-extensions.json +0 -0
  56. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
  57. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
  58. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/credential.py +0 -0
  59. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/did.py +0 -0
  60. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/eudi.py +0 -0
  61. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/ldp.py +0 -0
  62. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/publish.py +0 -0
  63. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/signer.py +0 -0
  64. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/status_registry.py +0 -0
  65. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/verifier.py +0 -0
  66. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/__init__.py +0 -0
  67. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/codes.py +0 -0
  68. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/errors.py +0 -0
  69. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/formats.py +0 -0
  70. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/handler.py +0 -0
  71. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/memory_store.py +0 -0
  72. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/metadata.py +0 -0
  73. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/nonce.py +0 -0
  74. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/offer.py +0 -0
  75. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/reconcile.py +0 -0
  76. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/sqlite_store.py +0 -0
  77. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/store.py +0 -0
  78. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_cli.py +0 -0
  79. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_init.py +0 -0
  80. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_keygenerator.py +0 -0
  81. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_publish.py +0 -0
  82. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_signer.py +0 -0
  83. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_status.py +0 -0
  84. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_verifier.py +0 -0
  85. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/py.typed +0 -0
  86. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/verify.py +0 -0
  87. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib.egg-info/dependency_links.txt +0 -0
  88. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib.egg-info/entry_points.txt +0 -0
  89. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib.egg-info/requires.txt +0 -0
  90. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib.egg-info/top_level.txt +0 -0
  91. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/pyproject.toml +0 -0
  92. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/setup.cfg +0 -0
  93. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/config1.ini +0 -0
  94. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/conformance/conftest.py +0 -0
  95. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/conformance/docker-compose.yml +0 -0
  96. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/conformance/test_official_validators.py +0 -0
  97. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/conftest.py +0 -0
  98. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/fixtures/ob_schemas/README.md +0 -0
  99. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
  100. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
  101. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/fixtures/ob_schemas/refresh.sh +0 -0
  102. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/fixtures/vc_di_eddsa/README.md +0 -0
  103. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
  104. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
  105. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
  106. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
  107. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
  108. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/images/sample1.png +0 -0
  109. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/images/sample1.svg +0 -0
  110. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/images/userimage01.svg +0 -0
  111. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/ldp_helpers.py +0 -0
  112. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/logo Python Espan/314/203a.svg" +0 -0
  113. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/logo Python Espa/303/261a.svg" +0 -0
  114. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_baking.py +0 -0
  115. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_cli_json.py +0 -0
  116. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_cli_smoke.py +0 -0
  117. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_confparser.py +0 -0
  118. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_docs.py +0 -0
  119. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_eddsa.py +0 -0
  120. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_errors.py +0 -0
  121. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_examples.py +0 -0
  122. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_issue.py +0 -0
  123. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_issue_credential.py +0 -0
  124. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_jsonmodel.py +0 -0
  125. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_jws.py +0 -0
  126. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_key_operation.py +0 -0
  127. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_keys.py +0 -0
  128. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_mail.py +0 -0
  129. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob1_badge_io.py +0 -0
  130. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob1_deprecation.py +0 -0
  131. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob1_signer.py +0 -0
  132. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob1_verifier.py +0 -0
  133. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob2_cli.py +0 -0
  134. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob2_models.py +0 -0
  135. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob2_signer.py +0 -0
  136. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_conformance_schema.py +0 -0
  137. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_contexts.py +0 -0
  138. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_credential.py +0 -0
  139. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_did.py +0 -0
  140. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_did_doc.py +0 -0
  141. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_endorsement.py +0 -0
  142. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_eudi_oid4vci.py +0 -0
  143. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_eudi_sd_jwt.py +0 -0
  144. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_eudi_type_metadata.py +0 -0
  145. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_eudi_x5c.py +0 -0
  146. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_ldp.py +0 -0
  147. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_ldp_cli.py +0 -0
  148. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_ldp_ecdsa_sd.py +0 -0
  149. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_ldp_errors.py +0 -0
  150. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_ldp_sign.py +0 -0
  151. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_publish_cli.py +0 -0
  152. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_signer.py +0 -0
  153. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_signer_cli_ldp.py +0 -0
  154. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_status_registry.py +0 -0
  155. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_verifier.py +0 -0
  156. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_oid4vci_config.py +0 -0
  157. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_oid4vci_protocol.py +0 -0
  158. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_oid4vci_reservations.py +0 -0
  159. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_oid4vci_store.py +0 -0
  160. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_openbadges_cli.py +0 -0
  161. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_public_api.py +0 -0
  162. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_sign_ecc.pem +0 -0
  163. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_sign_rsa.pem +0 -0
  164. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_signer_batch.py +0 -0
  165. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_util.py +0 -0
  166. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_verifier_cli_coverage.py +0 -0
  167. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_verify.py +0 -0
  168. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_verify_ecc.pem +0 -0
  169. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_verify_rsa.pem +0 -0
  170. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/withoutxmlheader.svg +0 -0
  171. {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/withxmlheader.svg +0 -0
@@ -4,6 +4,33 @@ 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
+
7
34
  * v4.4.4 - 2026-09-10
8
35
 
9
36
  - security(baking): SVG baking strips ``<script>`` / ``foreignObject`` /
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openbadgeslib
3
- Version: 4.4.4
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
@@ -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
+ }
@@ -142,7 +142,7 @@ def _check_entry(entry: dict[str, Any], download: Callable[[str], bytes],
142
142
  encoded = subject.get("encodedList")
143
143
  if not isinstance(encoded, str) or not encoded:
144
144
  raise OB3VerificationError("status list credential has no encodedList")
145
- bitstring = _decode_encoded_list(encoded)
145
+ bitstring = decode_encoded_list(encoded)
146
146
  list_purposes = set(_as_list(subject.get("statusPurpose")))
147
147
  except OB3VerificationError:
148
148
  raise
@@ -320,20 +320,68 @@ def _b64url_decode(value: str) -> bytes:
320
320
  return base64.urlsafe_b64decode(value)
321
321
 
322
322
 
323
- def _decode_encoded_list(encoded: str) -> bytes:
324
- """Decode encodedList: optional multibase base64url ('u') prefix, base64url,
325
- then a bounded GZIP inflate to the raw bitstring."""
326
- if encoded.startswith('u'): # multibase base64url-no-pad (Bitstring SL)
327
- encoded = encoded[1:]
328
- compressed = _b64url_decode(encoded)
329
- inflator = zlib.decompressobj(wbits=31) # 31 => gzip framing
330
- out = inflator.decompress(compressed, MAX_STATUS_LIST_BYTES)
323
+ def decode_encoded_list(encoded: str) -> bytes:
324
+ """Decode a Bitstring Status List ``encodedList`` to the raw bitstring.
325
+
326
+ Accepts the multibase base64url prefix ``u`` (Bitstring Status List v1.0)
327
+ or a bare base64url string, then inflates the GZIP payload. The
328
+ decompressed bitstring is capped at :data:`MAX_STATUS_LIST_BYTES` (5 MiB)
329
+ so a crafted gzip bomb cannot exhaust memory — the list is fetched from a
330
+ URL named in an untrusted credential.
331
+
332
+ Raises:
333
+ ValueError: *encoded* is not a non-empty string, or is not valid
334
+ base64url / gzip.
335
+ OB3VerificationError: the inflated bitstring exceeds the 5 MiB cap.
336
+ """
337
+ if not isinstance(encoded, str) or not encoded:
338
+ raise ValueError("encodedList must be a non-empty string")
339
+ payload = encoded[1:] if encoded.startswith('u') else encoded
340
+ if not payload:
341
+ raise ValueError("encodedList is missing base64url data")
342
+ try:
343
+ compressed = _b64url_decode(payload)
344
+ if not compressed:
345
+ raise ValueError("encodedList is not valid base64url")
346
+ inflator = zlib.decompressobj(wbits=31) # 31 => gzip framing
347
+ out = inflator.decompress(compressed, MAX_STATUS_LIST_BYTES)
348
+ except OB3VerificationError:
349
+ raise
350
+ except Exception as exc:
351
+ raise ValueError("invalid encodedList: %s" % exc) from exc
331
352
  if inflator.unconsumed_tail:
332
353
  raise OB3VerificationError(
333
354
  "status list bitstring exceeds the %d-byte limit" % MAX_STATUS_LIST_BYTES)
355
+ if not inflator.eof:
356
+ raise ValueError("invalid encodedList: truncated gzip")
334
357
  return out
335
358
 
336
359
 
360
+ def _decode_encoded_list(encoded: str) -> bytes:
361
+ """Private alias of :func:`decode_encoded_list` (kept for existing tests)."""
362
+ return decode_encoded_list(encoded)
363
+
364
+
365
+ def set_indices(bitstring: bytes) -> frozenset[int]:
366
+ """Return the indices of bits that are set in an MSB-first bitstring.
367
+
368
+ The bit order matches :func:`_bit_set` / Bitstring Status List v1.0:
369
+ index 0 is the high bit of the first byte. Pair with
370
+ :func:`decode_encoded_list` to recover the set of revoked/suspended
371
+ indices from a published ``encodedList``.
372
+ """
373
+ if not isinstance(bitstring, (bytes, bytearray)):
374
+ raise ValueError("bitstring must be bytes")
375
+ total = len(bitstring) * 8
376
+ rendered = format(int.from_bytes(bitstring, "big"), "0%db" % total)
377
+ found: set[int] = set()
378
+ index = rendered.find("1")
379
+ while index != -1:
380
+ found.add(index)
381
+ index = rendered.find("1", index + 1)
382
+ return frozenset(found)
383
+
384
+
337
385
  def _bit_set(bitstring: bytes, index: int) -> bool:
338
386
  """Return the bit at *index* in a big-endian (MSB-first) bitstring, matching
339
387
  the Bitstring Status List / StatusList2021 bit ordering."""
@@ -78,7 +78,8 @@ def build_status_list_credential(issuer_id: str, url: str, purpose: str,
78
78
  set_indices: Iterable[int],
79
79
  size_bits: int = DEFAULT_SIZE_BITS,
80
80
  issued: Optional[datetime] = None,
81
- valid_until: Optional[datetime] = None
81
+ valid_until: Optional[datetime] = None,
82
+ ttl: Optional[int] = None
82
83
  ) -> dict[str, Any]:
83
84
  """Build an (unsigned) BitstringStatusListCredential document.
84
85
 
@@ -96,10 +97,27 @@ def build_status_list_credential(issuer_id: str, url: str, purpose: str,
96
97
  authentic older copy (with fewer revocations) passes the window unchecked.
97
98
  openbadges-publish warns loudly when it does this; issuers of revocable
98
99
  badges should set ``status_validity_days`` and republish on that cadence.
100
+
101
+ *ttl*, when given, is Bitstring Status List §2.2's refresh hint in
102
+ milliseconds. It is written on ``credentialSubject`` (the JSON-LD term is
103
+ scoped to ``BitstringStatusList``); a top-level copy would be dropped
104
+ during expansion and would not be covered by a Data Integrity proof.
99
105
  """
100
106
  if purpose not in STATUS_PURPOSES:
101
107
  raise ValueError("statusPurpose must be one of %r, got %r"
102
108
  % (STATUS_PURPOSES, purpose))
109
+ if ttl is not None and (isinstance(ttl, bool) or not isinstance(ttl, int)
110
+ or ttl < 0):
111
+ raise ValueError("ttl must be a non-negative integer (milliseconds), "
112
+ "got %r" % (ttl,))
113
+ subject: dict[str, Any] = {
114
+ "id": url + "#list",
115
+ "type": "BitstringStatusList",
116
+ "statusPurpose": purpose,
117
+ "encodedList": encode_bitstring(set_indices, size_bits),
118
+ }
119
+ if ttl is not None:
120
+ subject["ttl"] = ttl
103
121
  vc: dict[str, Any] = {
104
122
  "@context": [_VC2_CONTEXT],
105
123
  "id": url,
@@ -107,12 +125,7 @@ def build_status_list_credential(issuer_id: str, url: str, purpose: str,
107
125
  "issuer": issuer_id,
108
126
  "validFrom": _iso(issued if issued is not None
109
127
  else datetime.now(tz=timezone.utc)),
110
- "credentialSubject": {
111
- "id": url + "#list",
112
- "type": "BitstringStatusList",
113
- "statusPurpose": purpose,
114
- "encodedList": encode_bitstring(set_indices, size_bits),
115
- },
128
+ "credentialSubject": subject,
116
129
  }
117
130
  if valid_until is not None:
118
131
  vc["validUntil"] = _iso(valid_until)
@@ -21,7 +21,7 @@
21
21
  License along with this library.
22
22
  """
23
23
 
24
- __version__ = '4.4.4'
24
+ __version__ = '4.5.0'
25
25
 
26
26
  import contextlib
27
27
  import hashlib
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openbadgeslib
3
- Version: 4.4.4
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
@@ -49,6 +49,7 @@ openbadgeslib/ob2/models.py
49
49
  openbadgeslib/ob2/signer.py
50
50
  openbadgeslib/ob2/verifier.py
51
51
  openbadgeslib/ob3/__init__.py
52
+ openbadgeslib/ob3/clr.py
52
53
  openbadgeslib/ob3/credential.py
53
54
  openbadgeslib/ob3/did.py
54
55
  openbadgeslib/ob3/eudi.py
@@ -60,6 +61,7 @@ openbadgeslib/ob3/status_list.py
60
61
  openbadgeslib/ob3/status_registry.py
61
62
  openbadgeslib/ob3/verifier.py
62
63
  openbadgeslib/ob3/contexts/__init__.py
64
+ openbadgeslib/ob3/contexts/clr_context_2.0.1.json
63
65
  openbadgeslib/ob3/contexts/credentials-v2.json
64
66
  openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json
65
67
  openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json
@@ -108,6 +110,7 @@ tests/test_ob2_error_branches.py
108
110
  tests/test_ob2_models.py
109
111
  tests/test_ob2_signer.py
110
112
  tests/test_ob2_verifier.py
113
+ tests/test_ob3_clr.py
111
114
  tests/test_ob3_conformance_schema.py
112
115
  tests/test_ob3_contexts.py
113
116
  tests/test_ob3_credential.py
@@ -150,6 +153,7 @@ tests/conformance/Dockerfile.ob3
150
153
  tests/conformance/README.md
151
154
  tests/conformance/conftest.py
152
155
  tests/conformance/docker-compose.yml
156
+ tests/conformance/relax-danubetech-checksums.awk
153
157
  tests/conformance/test_official_validators.py
154
158
  tests/fixtures/ob_schemas/README.md
155
159
  tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json