openbadgeslib 4.1.0__tar.gz → 4.3.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 (167) hide show
  1. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/Changelog.txt +110 -0
  2. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/PKG-INFO +5 -3
  3. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/__init__.py +7 -3
  4. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/config.ini.example +45 -0
  5. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/confparser.py +187 -2
  6. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/errors.py +4 -7
  7. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/issue.py +187 -2
  8. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/__init__.py +4 -2
  9. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/credential.py +21 -0
  10. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/did.py +47 -0
  11. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/eudi.py +68 -4
  12. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/status_registry.py +91 -7
  13. openbadgeslib-4.3.0/openbadgeslib/oid4vci/__init__.py +110 -0
  14. openbadgeslib-4.3.0/openbadgeslib/oid4vci/codes.py +138 -0
  15. openbadgeslib-4.3.0/openbadgeslib/oid4vci/errors.py +128 -0
  16. openbadgeslib-4.3.0/openbadgeslib/oid4vci/formats.py +61 -0
  17. openbadgeslib-4.3.0/openbadgeslib/oid4vci/handler.py +348 -0
  18. openbadgeslib-4.3.0/openbadgeslib/oid4vci/memory_store.py +209 -0
  19. openbadgeslib-4.3.0/openbadgeslib/oid4vci/metadata.py +239 -0
  20. openbadgeslib-4.3.0/openbadgeslib/oid4vci/nonce.py +138 -0
  21. openbadgeslib-4.3.0/openbadgeslib/oid4vci/offer.py +260 -0
  22. openbadgeslib-4.3.0/openbadgeslib/oid4vci/reconcile.py +135 -0
  23. openbadgeslib-4.3.0/openbadgeslib/oid4vci/sqlite_store.py +596 -0
  24. openbadgeslib-4.3.0/openbadgeslib/oid4vci/store.py +318 -0
  25. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/openbadges_publish.py +114 -1
  26. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/openbadges_signer.py +11 -3
  27. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/openbadges_verifier.py +5 -1
  28. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/util.py +1 -1
  29. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib.egg-info/PKG-INFO +5 -3
  30. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib.egg-info/SOURCES.txt +17 -0
  31. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib.egg-info/requires.txt +5 -2
  32. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/pyproject.toml +16 -2
  33. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_cli_json.py +30 -0
  34. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_cli_smoke.py +49 -0
  35. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_errors.py +19 -0
  36. openbadgeslib-4.3.0/tests/test_issue_credential.py +288 -0
  37. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob3_eudi_oid4vci.py +71 -0
  38. openbadgeslib-4.3.0/tests/test_oid4vci_config.py +265 -0
  39. openbadgeslib-4.3.0/tests/test_oid4vci_protocol.py +897 -0
  40. openbadgeslib-4.3.0/tests/test_oid4vci_reservations.py +295 -0
  41. openbadgeslib-4.3.0/tests/test_oid4vci_store.py +607 -0
  42. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/CONTRIBUTING.md +0 -0
  43. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/GOVERNANCE.md +0 -0
  44. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/LICENSE.txt +0 -0
  45. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/MANIFEST.in +0 -0
  46. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/README.md +0 -0
  47. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/SECURITY.md +0 -0
  48. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/docs/README.md +0 -0
  49. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/_jsonmodel.py +0 -0
  50. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/_jws/__init__.py +0 -0
  51. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/_jws/exceptions.py +0 -0
  52. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/_jws/utils.py +0 -0
  53. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/_ob1_api.py +0 -0
  54. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/badge_model.py +0 -0
  55. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/baking.py +0 -0
  56. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/cli_common.py +0 -0
  57. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/keys.py +0 -0
  58. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/logs.py +0 -0
  59. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/mail.py +0 -0
  60. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob1/__init__.py +0 -0
  61. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob1/badge.py +0 -0
  62. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob1/signer.py +0 -0
  63. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob1/verifier.py +0 -0
  64. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob2/__init__.py +0 -0
  65. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob2/models.py +0 -0
  66. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob2/signer.py +0 -0
  67. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob2/verifier.py +0 -0
  68. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/contexts/__init__.py +0 -0
  69. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
  70. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
  71. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
  72. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
  73. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/contexts/ob-v3p0-extensions.json +0 -0
  74. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
  75. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
  76. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/ldp.py +0 -0
  77. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/publish.py +0 -0
  78. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/signer.py +0 -0
  79. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/status.py +0 -0
  80. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/status_list.py +0 -0
  81. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/verifier.py +0 -0
  82. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/openbadges_cli.py +0 -0
  83. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/openbadges_init.py +0 -0
  84. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/openbadges_keygenerator.py +0 -0
  85. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/openbadges_status.py +0 -0
  86. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/py.typed +0 -0
  87. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib/verify.py +0 -0
  88. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib.egg-info/dependency_links.txt +0 -0
  89. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib.egg-info/entry_points.txt +0 -0
  90. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/openbadgeslib.egg-info/top_level.txt +0 -0
  91. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/setup.cfg +0 -0
  92. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/config1.ini +0 -0
  93. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/conformance/Dockerfile.ob3 +0 -0
  94. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/conformance/README.md +0 -0
  95. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/conformance/conftest.py +0 -0
  96. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/conformance/docker-compose.yml +0 -0
  97. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/conformance/test_official_validators.py +0 -0
  98. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/conftest.py +0 -0
  99. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/fixtures/ob_schemas/README.md +0 -0
  100. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
  101. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
  102. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/fixtures/ob_schemas/refresh.sh +0 -0
  103. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/fixtures/vc_di_eddsa/README.md +0 -0
  104. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
  105. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
  106. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
  107. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
  108. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
  109. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/images/sample1.png +0 -0
  110. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/images/sample1.svg +0 -0
  111. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/images/userimage01.svg +0 -0
  112. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/ldp_helpers.py +0 -0
  113. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/logo Python Espan/314/203a.svg" +0 -0
  114. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/logo Python Espa/303/261a.svg" +0 -0
  115. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_baking.py +0 -0
  116. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_confparser.py +0 -0
  117. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_docs.py +0 -0
  118. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_eddsa.py +0 -0
  119. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_examples.py +0 -0
  120. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_issue.py +0 -0
  121. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_jsonmodel.py +0 -0
  122. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_jws.py +0 -0
  123. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_key_operation.py +0 -0
  124. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_keys.py +0 -0
  125. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_mail.py +0 -0
  126. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob1_badge_io.py +0 -0
  127. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob1_deprecation.py +0 -0
  128. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob1_signer.py +0 -0
  129. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob1_verifier.py +0 -0
  130. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob2_cli.py +0 -0
  131. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob2_error_branches.py +0 -0
  132. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob2_models.py +0 -0
  133. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob2_signer.py +0 -0
  134. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob2_verifier.py +0 -0
  135. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob3_conformance_schema.py +0 -0
  136. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob3_contexts.py +0 -0
  137. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob3_credential.py +0 -0
  138. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob3_did.py +0 -0
  139. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob3_did_doc.py +0 -0
  140. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob3_endorsement.py +0 -0
  141. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob3_eudi_sd_jwt.py +0 -0
  142. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob3_eudi_type_metadata.py +0 -0
  143. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob3_eudi_x5c.py +0 -0
  144. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob3_ldp.py +0 -0
  145. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob3_ldp_cli.py +0 -0
  146. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob3_ldp_ecdsa_sd.py +0 -0
  147. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob3_ldp_errors.py +0 -0
  148. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob3_ldp_sign.py +0 -0
  149. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob3_publish_cli.py +0 -0
  150. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob3_signer.py +0 -0
  151. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob3_signer_cli_ldp.py +0 -0
  152. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob3_status.py +0 -0
  153. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob3_status_list.py +0 -0
  154. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob3_status_registry.py +0 -0
  155. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_ob3_verifier.py +0 -0
  156. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_openbadges_cli.py +0 -0
  157. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_public_api.py +0 -0
  158. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_sign_ecc.pem +0 -0
  159. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_sign_rsa.pem +0 -0
  160. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_signer_batch.py +0 -0
  161. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_util.py +0 -0
  162. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_verifier_cli_coverage.py +0 -0
  163. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_verify.py +0 -0
  164. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_verify_ecc.pem +0 -0
  165. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/test_verify_rsa.pem +0 -0
  166. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/withoutxmlheader.svg +0 -0
  167. {openbadgeslib-4.1.0 → openbadgeslib-4.3.0}/tests/withxmlheader.svg +0 -0
@@ -4,6 +4,116 @@ OpenBadgesLib - Changelog
4
4
  Newest first. Dates are ISO 8601 (YYYY-MM-DD).
5
5
 
6
6
 
7
+ * v4.3.0 - 2026-08-01
8
+
9
+ Issue a badge straight to a wallet, over OpenID4VCI.
10
+
11
+ - feat(oid4vci): issue badges to a wallet over OpenID for Verifiable
12
+ Credential Issuance 1.0, pre-authorized code flow. New `openbadgeslib.
13
+ oid4vci` package: Credential Offers, the issuer and RFC 8414 discovery
14
+ documents, and handlers for the token, nonce and credential endpoints. It
15
+ ships NO web server — the handlers return response bodies and the
16
+ integrator mounts the routes — which keeps the boundary openbadges-publish
17
+ already has (it writes files to serve, it does not serve them). The wallet
18
+ key-proof cryptography is delegated to openvc-core's `openvc.openid4vci`
19
+ behind a new `[oid4vci]` extra; the state that openvc refuses to hold —
20
+ codes, tokens, nonces, grants and their lifetimes — lives here, and with it
21
+ the "no wrong-issue" property. Formats: `jwt_vc_json` (revocable) and
22
+ `vc+sd-jwt` (the EUDI track, irrevocable per #226).
23
+ NOT HAIP-conformant and nothing built on it may claim to be: no DPoP, no
24
+ verified key attestations, no client authentication, no authorization code
25
+ flow. See wiki/Issuing-to-Wallets-with-OID4VCI.md and the new Security
26
+ Model section for the full list of what is and is not defended.
27
+ The state store is an interface with two backends: in-memory for a single
28
+ process, and SQLite for the threads and processes of one host. Single-use is
29
+ settled in the database rather than in Python — burning a nonce and claiming
30
+ an issuance are INSERTs under BEGIN IMMEDIATE — so exactly one caller wins
31
+ no matter how many workers race. Secrets are stored as digests, the file is
32
+ 0600 in a 0700 directory, and an unknown schema version is refused.
33
+ - feat(issue): `issue_credential_from_conf()` issues an OB 3.0 credential as
34
+ a TOKEN rather than a baked badge image, returning a `CredentialResult`.
35
+ The existing `issue_from_conf` always bakes a PNG/SVG, which is wasted work
36
+ (and the wrong deliverable) when the credential goes to a wallet, an LMS
37
+ API or an email — reaching the raw token previously meant baking an SVG and
38
+ extracting it again. Takes an optional `holder_jwk` to bind the credential
39
+ to a wallet key: for `jwt_vc_json` the subject id becomes that key's
40
+ `did:jwk` and the recipient moves to a salted `credentialSubject.
41
+ identifier` hash, so the badge stays attributable to a person without
42
+ publishing their address; for `vc+sd-jwt` the key goes into `cnf`.
43
+ - feat(ob3): `did_jwk_from_jwk()` derives a did:jwk from a public JWK, and
44
+ `IdentityObject.create()` builds a salted-hash identifier (the OB 3.0
45
+ mirror of the OB 2.0 constructor). Both refuse JWKs carrying private key
46
+ material.
47
+ - feat(ob3): the status registry gains reservations (schema version 2).
48
+ An OID4VCI offer reserves its revocation slot when the offer is built, not
49
+ when the wallet claims — deciding it at claim time would put the registry's
50
+ exclusive lock and full-file rewrite inside a concurrent HTTP path, on a
51
+ lock that is a no-op where fcntl is missing. Reserved entries show as
52
+ `reserved` in `--list`; `openbadges-publish -V 3 --reclaim-unclaimed`
53
+ frees the ones whose offer lapsed, refusing to touch any slot the registry
54
+ and the OID4VCI store do not both agree was never issued against. Schema
55
+ version 1 files load unchanged, and a registry with no reservations
56
+ serialises byte-identically to before.
57
+ - fix(oid4vci): the SQLite store's opportunistic garbage collection takes an
58
+ injectable clock. It ran on wall-clock time while every query takes an
59
+ explicit `now=`, so a write collected the very rows a caller had just
60
+ written against a frozen instant — which made the store's own test suite
61
+ pass on the day it was written and fail a week later on the calendar
62
+ rather than on a code change.
63
+ - security(verifier): `openbadges-verifier -V 1` no longer reports
64
+ `trusted: true` for a badge that FAILED verification (#278). The OB 1.0 path
65
+ set the trust flag before the valid/invalid branch, so a tampered badge
66
+ checked against an operator-supplied key (-k/--local) emitted
67
+ `{valid: false, trusted: true, status: SIGNATURE_ERROR}` — contradicting the
68
+ invariant the OB 2.0 and OB 3.0 paths already honoured (#258) and that a
69
+ consumer reading the JSON object, rather than the exit status, relies on.
70
+ Exit codes are unchanged: such a badge already exited 1.
71
+ - fix(cli): `openbadges-signer` maps a misconfigured badge section to a clean
72
+ `[!]` error instead of a raw traceback (#279). It built the badge model
73
+ without the mapper that turns a missing config key or an unreadable
74
+ key/image into an IssuanceError, so the most likely first-run mistake — a
75
+ section missing `private_key`, or keys not generated yet — ended in a
76
+ KeyError traceback in human mode (`--json` was already covered).
77
+ - fix(errors): the documented exception tree no longer misfiles
78
+ `AmbiguousCredential` (#280). `openbadgeslib.errors` carried a dead
79
+ `AmbiguousCredential(StatusError)` that was never raised, while the one
80
+ actually raised and exported is `ob3.publish.AmbiguousCredential`, a
81
+ `PublishError` — so `except StatusError` around a publish/revoke call, as
82
+ the tree in the docstring and the wiki instructed, did not catch it. The
83
+ dead class is gone and the `PublishError` family (with `CredentialNotFound`)
84
+ is documented in both places.
85
+ - build(deps): the openvc-core floors move to >=1.24 across `[eudi]`,
86
+ `[ldp-sd]` and `[oid4vci]`, which is the release the suite is actually
87
+ tested against — the rule the extras already documented, applied uniformly
88
+ (`[ldp-sd]` had been left behind at >=1.21). Substantively 1.23 still
89
+ suffices for the OpenID4VCI key proof and 1.22 for the Token Status List;
90
+ 1.24 is additive over both (`ResolveProofKey` is unchanged and not
91
+ deprecated), so an install already on 1.24 sees no behaviour change.
92
+
93
+
94
+ * v4.2.0 - 2026-07-29
95
+
96
+ The OpenID4VCI credential configuration an issuer advertises can now declare
97
+ the key attestation it requires from a wallet — opt-in, because the spec makes
98
+ the field's mere presence a demand.
99
+
100
+ - feat(eudi): `badge_credential_configuration()` takes
101
+ `key_attestations_required=`, so an issuer that requires a wallet key
102
+ attestation can advertise it — `{}` for "required, no additional
103
+ constraints", or the OpenID4VCI 1.0 D.2 resistance values it accepts under
104
+ `key_storage` / `user_authentication`. The default stays **omitted**, and
105
+ that default is normative rather than conservative: §12.2.4 says the
106
+ parameter "MUST NOT be present in the metadata" unless the issuer requires an
107
+ attestation, and an empty object is not a neutral placeholder but the
108
+ positive claim that one is needed — emitting it unasked would have every
109
+ badge issuer demand a hardware-backed attestation and lock out the software
110
+ wallets that cannot produce one. Callers who pass nothing get the same bytes
111
+ as before. The shape is validated (`EudiError` on an unknown key, an empty
112
+ array where the spec says non-empty, a bare string where an array belongs, or
113
+ a non-string level) and defensively copied; the resistance values are not
114
+ checked, since D.2 lets each ecosystem define its own (#276).
115
+
116
+
7
117
  * v4.1.0 - 2026-07-27
8
118
 
9
119
  The EUDI SD-JWT VC track becomes revocable (IETF Token Status List) and
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openbadgeslib
3
- Version: 4.1.0
3
+ Version: 4.3.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
@@ -36,9 +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.23; extra == "eudi"
39
+ Requires-Dist: openvc-core<2,>=1.24; extra == "eudi"
40
40
  Provides-Extra: ldp-sd
41
- Requires-Dist: openvc-core[data-integrity]<2,>=1.21; extra == "ldp-sd"
41
+ Requires-Dist: openvc-core[data-integrity]<2,>=1.24; extra == "ldp-sd"
42
+ Provides-Extra: oid4vci
43
+ Requires-Dist: openvc-core<2,>=1.24; extra == "oid4vci"
42
44
  Provides-Extra: dev
43
45
  Requires-Dist: pytest>=8.0; extra == "dev"
44
46
  Requires-Dist: pytest-cov>=5.0; extra == "dev"
@@ -66,7 +66,8 @@ from ._ob1_api import OB1_API as _OB1_API # noqa: F401
66
66
  # (hence cryptography) — inside the run-to-run noise — so the documentation of
67
67
  # the recommended modern API is worth more than it.
68
68
  from .issue import ( # noqa: F401
69
- IssuanceError, SignResult, issue_from_conf,
69
+ CredentialResult, IssuanceError, SignResult, issue_credential_from_conf,
70
+ issue_from_conf,
70
71
  )
71
72
  from .verify import verify_badge # noqa: F401
72
73
 
@@ -113,6 +114,9 @@ __all__ = [
113
114
  'OpenBadgeCredential', 'Achievement', 'Issuer',
114
115
  # Keys (KeyEd25519 is the recommended OB 3.0 / LDP key type).
115
116
  'KeyFactory', 'KeyRSA', 'KeyECC', 'KeyEd25519',
116
- # Programmatic issue / verify facades.
117
- 'issue_from_conf', 'verify_badge', 'IssuanceError', 'SignResult',
117
+ # Programmatic issue / verify facades. issue_credential_from_conf is the
118
+ # token-returning sibling of issue_from_conf, for delivering a credential
119
+ # over a digital channel (a wallet, an API) rather than as a badge image.
120
+ 'issue_from_conf', 'issue_credential_from_conf', 'verify_badge',
121
+ 'IssuanceError', 'SignResult', 'CredentialResult',
118
122
  ]
@@ -52,6 +52,45 @@ revocationList = revoked.json
52
52
  ; imsglobal vct.
53
53
  ;sd_jwt_vct = ${publish_url}vct/openbadge
54
54
 
55
+ ; OpenID for Verifiable Credential Issuance (OID4VCI), issuer side. Optional and
56
+ ; entirely opt-in: without this section nothing changes, and a badge is only
57
+ ; offered over OID4VCI if its own 'oid4vci_formats' key says so.
58
+ ;
59
+ ; This library does NOT run the endpoints. It builds the offers, verifies the
60
+ ; wallet's key proof (via openvc-core, the [oid4vci] extra) and issues the
61
+ ; credential; you mount the routes in your own web framework at the URLs below.
62
+ ;
63
+ ; NOT a HAIP-conformant issuer: no DPoP, no key attestation checking, no client
64
+ ; authentication and no authorization code flow. Pre-authorized code only.
65
+ ;[oid4vci]
66
+ ; The Credential Issuer Identifier: the https base URL where you serve
67
+ ; /.well-known/openid-credential-issuer. Every wallet key proof binds its 'aud'
68
+ ; to this exact string, so it must match what you actually serve. Default:
69
+ ; [issuer] publish_url.
70
+ ;credential_issuer = https://openbadges.issuer.badge/issuer/
71
+ ; The three endpoints you mount. Default: derived from credential_issuer.
72
+ ;credential_endpoint = https://openbadges.issuer.badge/issuer/credential
73
+ ;nonce_endpoint = https://openbadges.issuer.badge/issuer/nonce
74
+ ;token_endpoint = https://openbadges.issuer.badge/issuer/token
75
+ ; Where the flow's short-lived state lives (pre-authorized codes, access tokens,
76
+ ; spent nonces). SQLite, single host: NOT on NFS/SMB, where its locking breaks.
77
+ ; The directory is created 0700 and holds recipient PII — do not back it up.
78
+ ; Default: ${paths:base}/oid4vci.sqlite3
79
+ ;store_path = ${paths:base}/oid4vci.sqlite3
80
+ ; Lifetimes, in seconds. The offer TTL bounds how long a QR code stays
81
+ ; redeemable; keep it short, it is a bearer secret.
82
+ ;offer_ttl_s = 600
83
+ ;nonce_ttl_s = 120
84
+ ;token_ttl_s = 300
85
+ ; How stale a wallet's key proof may be before it is refused.
86
+ ;proof_max_age_s = 300
87
+ ; Credentials one Credential Request may claim. Leave at 1 unless you have a
88
+ ; reason: a batch mints one credential per key proof off a single grant.
89
+ ;batch_size = 1
90
+ ; The out-of-band PIN, when an offer asks for one. 'numeric' or 'text'.
91
+ ;tx_code_length = 6
92
+ ;tx_code_input_mode = numeric
93
+
55
94
  ;Badge configuration sections.
56
95
  [badge_1]
57
96
  name = Badge 1
@@ -98,6 +137,12 @@ key_type = RSA
98
137
  ; lapses; pick a cadence you can sustain (e.g. 7 => weekly). Unset = no bound
99
138
  ; (offline-friendly, but publishes a loud warning).
100
139
  ;status_validity_days = 7
140
+ ; OID4VCI opt-in for this badge (comma-separated subset of: jwt_vc_json,
141
+ ; vc+sd-jwt). Unset means the badge is not published in the issuer metadata and
142
+ ; cannot be offered to a wallet. jwt_vc_json is revocable via the status lists
143
+ ; above; vc+sd-jwt is the EUDI track and is IRREVOCABLE, so it cannot be
144
+ ; combined with status_lists. vc+sd-jwt also needs key_type = ED25519 or ECC.
145
+ ;oid4vci_formats = jwt_vc_json
101
146
  ;mail = ${paths:base}/badge_1_mail.txt
102
147
 
103
148
  [badge_2]
@@ -23,7 +23,7 @@
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, TYPE_CHECKING
26
+ from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
27
27
  import os
28
28
  import sys
29
29
  import logging
@@ -197,6 +197,189 @@ def ob3_status_config(conf: ConfigParser,
197
197
  validity_days=validity_days)
198
198
 
199
199
 
200
+ # ── OID4VCI (OpenID for Verifiable Credential Issuance) ──────────────────────
201
+ # Issuing to a wallet inverts the library's usual direction: instead of the
202
+ # issuer pushing a badge to a known recipient, the wallet claims it against an
203
+ # offer and proves possession of its own key. That needs a handful of
204
+ # deployment values the rest of the config has no reason to carry — the public
205
+ # identifier the wallet's key proofs bind to, and the lifetimes of the
206
+ # short-lived secrets the flow mints.
207
+ #
208
+ # The whole section is optional: without it, nothing else in the library
209
+ # changes behaviour.
210
+
211
+ #: How long a Credential Offer's pre-authorized code stays redeemable. Short by
212
+ #: default: it is a bearer secret that travels in a QR code or a link.
213
+ DEFAULT_OFFER_TTL_S = 600
214
+
215
+ #: Lifetime of a c_nonce handed out by the nonce endpoint.
216
+ DEFAULT_NONCE_TTL_S = 120
217
+
218
+ #: Lifetime of an access token minted at the token endpoint. It only has to
219
+ #: survive the wallet's immediate follow-up call to the credential endpoint.
220
+ DEFAULT_TOKEN_TTL_S = 300
221
+
222
+ #: How stale a wallet key proof's `iat` may be (openvc's max_age_s).
223
+ DEFAULT_PROOF_MAX_AGE_S = 300
224
+
225
+ #: Digits in a numeric tx_code, and the input modes OID4VCI 1.0 defines.
226
+ DEFAULT_TX_CODE_LENGTH = 6
227
+ TX_CODE_INPUT_MODES = ('numeric', 'text')
228
+
229
+
230
+ @dataclass
231
+ class OID4VCIConfig:
232
+ """Resolved [oid4vci] section: the issuer identifier, the endpoint URLs and
233
+ the lifetimes of the flow's short-lived secrets.
234
+
235
+ ``credential_issuer`` is load-bearing beyond being an identifier: it is the
236
+ value every wallet key proof must name in its ``aud``, so it has to match
237
+ byte for byte what is actually served at
238
+ ``<credential_issuer>/.well-known/openid-credential-issuer``."""
239
+
240
+ credential_issuer: str
241
+ credential_endpoint: str
242
+ nonce_endpoint: str
243
+ token_endpoint: str
244
+ store_path: str
245
+ offer_ttl_s: int = DEFAULT_OFFER_TTL_S
246
+ nonce_ttl_s: int = DEFAULT_NONCE_TTL_S
247
+ token_ttl_s: int = DEFAULT_TOKEN_TTL_S
248
+ proof_max_age_s: int = DEFAULT_PROOF_MAX_AGE_S
249
+ batch_size: int = 1
250
+ tx_code_length: int = DEFAULT_TX_CODE_LENGTH
251
+ tx_code_input_mode: str = 'numeric'
252
+
253
+
254
+ def _positive_int(conf: ConfigParser, section: str, key: str,
255
+ default: int) -> int:
256
+ """Read a positive integer config key, or its default when unset."""
257
+ raw = (conf[section].get(key) or '').strip() if section in conf else ''
258
+ if not raw:
259
+ return default
260
+ try:
261
+ value = int(raw)
262
+ except ValueError:
263
+ raise ConfigError("[%s] %s must be an integer, got %r"
264
+ % (section, key, raw)) from None
265
+ if value <= 0:
266
+ raise ConfigError("[%s] %s must be positive" % (section, key))
267
+ return value
268
+
269
+
270
+ def oid4vci_config(conf: ConfigParser) -> OID4VCIConfig:
271
+ """Resolve the optional [oid4vci] section.
272
+
273
+ Every key has a default, so a typical deployment writes none of them: the
274
+ issuer identifier falls back to ``[issuer] publish_url`` and the three
275
+ endpoints are derived from it. Raises :class:`ConfigError` when there is no
276
+ base to derive an issuer identifier from, when it is not https, or when a
277
+ lifetime is not a positive integer.
278
+
279
+ ``https`` is required rather than encouraged. A key proof's ``aud`` binds
280
+ the wallet's signature to this exact string; over plaintext http an attacker
281
+ who can rewrite the metadata response also chooses what the wallet signs
282
+ for, which makes the binding decorative.
283
+ """
284
+ from urllib.parse import urljoin
285
+
286
+ section = conf['oid4vci'] if 'oid4vci' in conf else None
287
+ base = (section.get('credential_issuer') if section else None) or \
288
+ conf['issuer'].get('publish_url') or ''
289
+ base = base.strip()
290
+ if not base:
291
+ raise ConfigError(
292
+ "[oid4vci] needs a credential_issuer (or an [issuer] publish_url "
293
+ "to derive it from): it is the identifier wallet key proofs bind "
294
+ "their 'aud' to")
295
+ if not base.startswith('https://'):
296
+ raise ConfigError(
297
+ "[oid4vci] credential_issuer must be an https URL, got %r — a "
298
+ "wallet key proof's audience binding is meaningless over plaintext"
299
+ % base)
300
+ # A trailing slash makes urljoin append rather than replace the last path
301
+ # segment; without it, an issuer at https://host/issuer would derive
302
+ # https://host/credential.
303
+ endpoint_base = base if base.endswith('/') else base + '/'
304
+
305
+ def endpoint(key: str, default_path: str) -> str:
306
+ explicit = (section.get(key) or '').strip() if section else ''
307
+ return explicit or urljoin(endpoint_base, default_path)
308
+
309
+ store_path = (section.get('store_path') if section else None) or \
310
+ os.path.join(conf['paths']['base'], 'oid4vci.sqlite3')
311
+
312
+ input_mode = ((section.get('tx_code_input_mode') if section else None)
313
+ or 'numeric').strip()
314
+ if input_mode not in TX_CODE_INPUT_MODES:
315
+ raise ConfigError("[oid4vci] tx_code_input_mode must be one of %s, "
316
+ "got %r" % (', '.join(TX_CODE_INPUT_MODES),
317
+ input_mode))
318
+
319
+ return OID4VCIConfig(
320
+ credential_issuer=base,
321
+ credential_endpoint=endpoint('credential_endpoint', 'credential'),
322
+ nonce_endpoint=endpoint('nonce_endpoint', 'nonce'),
323
+ token_endpoint=endpoint('token_endpoint', 'token'),
324
+ store_path=store_path,
325
+ offer_ttl_s=_positive_int(conf, 'oid4vci', 'offer_ttl_s',
326
+ DEFAULT_OFFER_TTL_S),
327
+ nonce_ttl_s=_positive_int(conf, 'oid4vci', 'nonce_ttl_s',
328
+ DEFAULT_NONCE_TTL_S),
329
+ token_ttl_s=_positive_int(conf, 'oid4vci', 'token_ttl_s',
330
+ DEFAULT_TOKEN_TTL_S),
331
+ proof_max_age_s=_positive_int(conf, 'oid4vci', 'proof_max_age_s',
332
+ DEFAULT_PROOF_MAX_AGE_S),
333
+ batch_size=_positive_int(conf, 'oid4vci', 'batch_size', 1),
334
+ tx_code_length=_positive_int(conf, 'oid4vci', 'tx_code_length',
335
+ DEFAULT_TX_CODE_LENGTH),
336
+ tx_code_input_mode=input_mode,
337
+ )
338
+
339
+
340
+ def oid4vci_formats(conf: ConfigParser, badge_section: str) -> Tuple[str, ...]:
341
+ """The OID4VCI credential formats a badge section opts into, in order.
342
+
343
+ Returns an empty tuple when the badge does not opt in (no
344
+ ``oid4vci_formats`` key), which callers treat as "this badge is not offered
345
+ over OID4VCI" — the same contract as :func:`ob3_status_config` returning
346
+ None. Opt-in rather than opt-out because listing a badge in the issuer
347
+ metadata publishes its existence and makes it claimable.
348
+
349
+ Raises :class:`ConfigError` for an unknown format, or for ``vc+sd-jwt`` on a
350
+ section with ``key_type = RSA``: SD-JWT VC has no RSA algorithm profile, so
351
+ that pairing could never issue and the operator should learn about it when
352
+ the config loads, not when a wallet is waiting.
353
+ """
354
+ from .keys import KeyType
355
+ from .oid4vci.formats import EC_ONLY_FORMATS, OID4VCI_FORMATS
356
+
357
+ raw = conf[badge_section].get('oid4vci_formats', '')
358
+ formats: List[str] = []
359
+ for piece in raw.split(','):
360
+ fmt = piece.strip()
361
+ if not fmt:
362
+ continue
363
+ if fmt not in OID4VCI_FORMATS:
364
+ raise ConfigError(
365
+ "[%s] oid4vci_formats: unknown format %r (choose from %s)"
366
+ % (badge_section, fmt, ', '.join(OID4VCI_FORMATS)))
367
+ if fmt not in formats:
368
+ formats.append(fmt)
369
+ if not formats:
370
+ return ()
371
+
372
+ key_type = resolve_key_type(conf[badge_section].get('key_type'))
373
+ if key_type is KeyType.RSA:
374
+ rsa_incapable = [f for f in formats if f in EC_ONLY_FORMATS]
375
+ if rsa_incapable:
376
+ raise ConfigError(
377
+ "[%s] oid4vci_formats includes %s, which cannot be signed with "
378
+ "an RSA key — use key_type = ED25519 or ECC, or drop that "
379
+ "format" % (badge_section, ', '.join(rsa_incapable)))
380
+ return tuple(formats)
381
+
382
+
200
383
  # ── centralized config defaults ──────────────────────────────────────────────
201
384
  # One inventory for the accepted-key defaults, so each lives in a single place
202
385
  # rather than being re-spelled at every read site. (The status-list defaults —
@@ -285,7 +468,9 @@ def badge_section_config(conf: ConfigParser,
285
468
  #: Sections whose values are the *public* identifiers of a deployment, checked
286
469
  #: by :func:`reject_url_userinfo`. [smtp] is deliberately excluded: its
287
470
  #: username/password are credentials that stay on the issuer's machine.
288
- _PUBLIC_URL_SECTIONS = ('issuer',)
471
+ #: [oid4vci] belongs here because its credential_issuer and endpoint URLs are
472
+ #: published in the issuer metadata and handed to every wallet that reads it.
473
+ _PUBLIC_URL_SECTIONS = ('issuer', 'oid4vci')
289
474
  _PUBLIC_URL_SECTION_PREFIX = 'badge_'
290
475
 
291
476
 
@@ -47,9 +47,10 @@ class LibOpenBadgesException(Exception):
47
47
  ├── IssuanceError
48
48
  ├── DecompressionLimitExceeded
49
49
  ├── StatusError (issuer-side revocation)
50
- │ └── StatusListFull / UnknownCredential / AmbiguousCredential
51
- │ / AlreadyRevoked / AlreadySuspended / NotSuspended
52
- │ / RegistryCorrupt
50
+ │ └── StatusListFull / UnknownCredential / AlreadyRevoked
51
+ │ / AlreadySuspended / NotSuspended / RegistryCorrupt
52
+ ├── PublishError (openbadgeslib.ob3.publish)
53
+ │ └── CredentialNotFound / AmbiguousCredential
53
54
  ├── OB2VerificationError (openbadgeslib.ob2.verifier)
54
55
  └── OB3VerificationError (openbadgeslib.ob3.verifier)
55
56
 
@@ -201,10 +202,6 @@ class UnknownCredential(StatusError):
201
202
  pass
202
203
 
203
204
 
204
- class AmbiguousCredential(StatusError):
205
- pass
206
-
207
-
208
205
  class AlreadyRevoked(StatusError):
209
206
  pass
210
207
 
@@ -371,13 +371,19 @@ def _ob3_setup(conf: configparser.ConfigParser, badge: str, badge_obj: Badge,
371
371
 
372
372
  def _build_ob3_credential(ctx: _Ob3Context, recipient: str,
373
373
  evidence: Optional[str],
374
- expires: Optional[int]) -> Any:
375
- """Build one OpenBadgeCredential from the shared context (no status yet)."""
374
+ expires: Optional[int],
375
+ credential_id: Optional[str] = None) -> Any:
376
+ """Build one OpenBadgeCredential from the shared context (no status yet).
377
+
378
+ ``credential_id`` pins the credential's id instead of letting the model
379
+ mint one, for a flow that had to know the id before signing — a reserved
380
+ status-list slot is recorded against it."""
376
381
  from .ob3 import OpenBadgeCredential
377
382
  expiration_date = None
378
383
  if expires:
379
384
  expiration_date = datetime.now(tz=timezone.utc) + timedelta(days=expires)
380
385
  return OpenBadgeCredential(
386
+ id=credential_id,
381
387
  issuer=ctx.issuer,
382
388
  recipient_id=normalize_recipient_id(recipient),
383
389
  achievement=ctx.achievement,
@@ -413,6 +419,185 @@ def _sign_ob3_credential(badge: str, credential: Any, badge_obj: Badge,
413
419
  raise IssuanceError(str(exc)) from exc
414
420
 
415
421
 
422
+ @dataclass
423
+ class CredentialResult:
424
+ """One OB 3.0 credential issued as a TOKEN rather than baked into an image.
425
+
426
+ The counterpart of :class:`SignResult` for callers that deliver a credential
427
+ over a digital channel — a wallet claiming it over OID4VCI, an LMS API, an
428
+ email carrying the JWT — where a PNG is not the deliverable and baking one
429
+ is wasted work.
430
+
431
+ ``credential_format`` is the OID4VCI vocabulary (``jwt_vc_json`` /
432
+ ``vc+sd-jwt``), which is a different axis from the badge section's
433
+ ``proof_format``: that one governs the baked-image path only."""
434
+
435
+ credential_format: str
436
+ token: str
437
+ credential: 'OpenBadgeCredential'
438
+ jti: Optional[str] = None
439
+ status_index: Optional[int] = None
440
+ holder_did: Optional[str] = None # did:jwk of the bound holder, if any
441
+ notices: List[str] = field(default_factory=list)
442
+
443
+
444
+ def _bind_holder(credential: Any, recipient: str,
445
+ holder_jwk: dict[str, Any]) -> str:
446
+ """Rebind a credential's subject to a wallet key, keeping the recipient.
447
+
448
+ ``credentialSubject.id`` becomes the holder's did:jwk — the key that proved
449
+ possession — and the recipient's address moves to
450
+ ``credentialSubject.identifier`` as a salted hash. Both matter: without the
451
+ first the credential is not bound to the wallet at all, and without the
452
+ second the badge stops being attributable to a person, since the holder key
453
+ is an opaque blob nobody can match to a learner.
454
+
455
+ The salt is fresh per credential, so the same address hashed for two badges
456
+ does not correlate them. Returns the did:jwk.
457
+ """
458
+ from .ob3 import IdentityObject
459
+ from .ob3.did import did_jwk_from_jwk
460
+
461
+ try:
462
+ holder_did = did_jwk_from_jwk(holder_jwk)
463
+ except ValueError as exc:
464
+ raise IssuanceError('invalid holder key: %s' % exc) from exc
465
+ credential.recipient_id = holder_did
466
+ credential.identifiers = [
467
+ IdentityObject.create(recipient, salt=os.urandom(16).hex())]
468
+ return holder_did
469
+
470
+
471
+ def _stamp_status(credential: Any, status_conf: Any, index: int) -> None:
472
+ """Attach credentialStatus entries for an ALREADY allocated index.
473
+
474
+ The split from :func:`_allocate_status_batch` exists because OID4VCI
475
+ allocates at offer time and signs later, when the wallet claims: the index
476
+ is decided in the issuer's own control path, and the request path only
477
+ stamps it. Reserving an index inside the credential endpoint instead would
478
+ put a registry write — an exclusive lock plus a full-file rewrite, and a
479
+ no-op lock on platforms without fcntl — into a concurrent HTTP path."""
480
+ from .ob3.status_list import status_entry
481
+ credential.credential_status = [
482
+ status_entry(status_conf.list_urls[p], p, index)
483
+ for p in status_conf.purposes]
484
+
485
+
486
+ def issue_credential_from_conf(conf: configparser.ConfigParser, badge: str,
487
+ recipient: str, *,
488
+ credential_format: str = 'jwt_vc_json',
489
+ holder_jwk: Optional[dict[str, Any]] = None,
490
+ evidence: Optional[str] = None,
491
+ expires: Optional[int] = None,
492
+ status_index: Optional[int] = None,
493
+ credential_id: Optional[str] = None
494
+ ) -> CredentialResult:
495
+ """Issue an OB 3.0 credential as a token, without baking it into an image.
496
+
497
+ ``credential_format`` is ``jwt_vc_json`` (a compact JWT-VC, revocable) or
498
+ ``vc+sd-jwt`` (an SD-JWT VC, the EUDI track). ``ldp`` has no equivalent
499
+ here: a document carrying an embedded proof is not a token.
500
+
501
+ Pass ``holder_jwk`` — the public key a wallet's OID4VCI key proof
502
+ demonstrated possession of — to bind the credential to that holder. For
503
+ ``jwt_vc_json`` the subject id becomes the key's did:jwk and the recipient
504
+ moves to a salted ``identifier`` hash; for ``vc+sd-jwt`` the key goes into
505
+ ``cnf``. Without it the subject is the normalised recipient, as in
506
+ :func:`issue_from_conf`.
507
+
508
+ ``status_index`` uses an index reserved earlier instead of allocating one,
509
+ for a flow that decides revocability before it knows whether the credential
510
+ will ever be claimed. Passing it for a badge with no status list configured
511
+ is an error rather than a silent no-op. Pass ``credential_id`` alongside it
512
+ with the id that reservation was recorded under, or the registry will name
513
+ a credential that was never issued and the delivered one will be
514
+ unrevocable.
515
+
516
+ Returns a :class:`CredentialResult`; raises :class:`IssuanceError`.
517
+ """
518
+ from .oid4vci.formats import (FORMAT_JWT_VC_JSON, FORMAT_SD_JWT_VC,
519
+ OID4VCI_FORMATS, REVOCABLE_FORMATS)
520
+
521
+ if credential_format not in OID4VCI_FORMATS:
522
+ raise IssuanceError(
523
+ "credential_format must be one of %s, got %r"
524
+ % (', '.join(OID4VCI_FORMATS), credential_format))
525
+
526
+ badge_obj = _badge_from_conf(conf, badge)
527
+ # Pin the proof format to vc-jwt: the badge section's own setting governs
528
+ # the baked-image path, and a section configured for ldp must not divert
529
+ # this one into a Data Integrity document.
530
+ ctx = _ob3_setup(conf, badge, badge_obj, 'vc-jwt')
531
+
532
+ if ctx.status_conf is not None and credential_format not in REVOCABLE_FORMATS:
533
+ # Refuse rather than drop the status: issuing a credential the issuer
534
+ # believes is revocable, and which silently is not, is the failure mode
535
+ # #226 already closed on the SD-JWT path. Catch it here so the caller
536
+ # learns before a wallet is waiting.
537
+ raise IssuanceError(
538
+ "[%s] configures status_lists, but %s cannot carry credential "
539
+ "status, so the badge would be irrevocable. Issue it as %s, or "
540
+ "drop status_lists from that section."
541
+ % (badge, credential_format, FORMAT_JWT_VC_JSON))
542
+ if status_index is not None and ctx.status_conf is None:
543
+ raise IssuanceError(
544
+ "a status_index was reserved for [%s], but that section configures "
545
+ "no status_lists" % badge)
546
+
547
+ credential = _build_ob3_credential(ctx, recipient, evidence, expires,
548
+ credential_id)
549
+ holder_did = None
550
+ if holder_jwk is not None:
551
+ holder_did = _bind_holder(credential, recipient, holder_jwk)
552
+
553
+ if status_index is not None:
554
+ _stamp_status(credential, ctx.status_conf, status_index)
555
+ else:
556
+ status_index = _allocate_status_batch([credential], ctx.status_conf)[0]
557
+
558
+ assert badge_obj.privkey_pem is not None
559
+ notices: List[str] = []
560
+ if credential_format == FORMAT_SD_JWT_VC:
561
+ token = _issue_sd_jwt_token(conf, credential, badge_obj, holder_jwk)
562
+ else:
563
+ token = _issue_jwt_vc_token(credential, badge_obj, ctx)
564
+
565
+ return CredentialResult(
566
+ credential_format=credential_format, token=token,
567
+ credential=credential, jti=credential.id, status_index=status_index,
568
+ holder_did=holder_did, notices=notices)
569
+
570
+
571
+ def _issue_jwt_vc_token(credential: Any, badge_obj: Badge,
572
+ ctx: _Ob3Context) -> str:
573
+ """Sign a credential into a compact JWT-VC (OID4VCI ``jwt_vc_json``)."""
574
+ from .ob3 import OB3Signer
575
+ algorithm = alg_for_key_type(ctx.key_type)
576
+ signer = OB3Signer(privkey_pem=badge_obj.privkey_pem, algorithm=algorithm)
577
+ try:
578
+ return signer.sign(credential)
579
+ except ErrorSigningFile as exc:
580
+ raise IssuanceError(str(exc)) from exc
581
+
582
+
583
+ def _issue_sd_jwt_token(conf: configparser.ConfigParser, credential: Any,
584
+ badge_obj: Badge,
585
+ holder_jwk: Optional[dict[str, Any]]) -> str:
586
+ """Issue a credential as an SD-JWT VC (OID4VCI ``vc+sd-jwt``).
587
+
588
+ Uses the issuer's own ``[issuer] sd_jwt_vct`` when configured — the vct
589
+ whose Type Metadata openbadges-publish writes — so a wallet resolving the
590
+ type reaches this issuer's document rather than the generic imsglobal one.
591
+ """
592
+ from .ob3.eudi import OB3_SD_JWT_VCT, EudiError, issue_badge_sd_jwt
593
+ vct = (conf['issuer'].get('sd_jwt_vct') or '').strip() or OB3_SD_JWT_VCT
594
+ try:
595
+ return issue_badge_sd_jwt(credential, privkey_pem=badge_obj.privkey_pem,
596
+ holder_jwk=holder_jwk, vct=vct)
597
+ except EudiError as exc:
598
+ raise IssuanceError(str(exc)) from exc
599
+
600
+
416
601
  def _issue_ob3(conf: configparser.ConfigParser, badge: str, recipient: str,
417
602
  badge_obj: Badge, evidence: Optional[str], expires: Optional[int],
418
603
  proof_format: Optional[str]) -> SignResult: