openbadgeslib 4.0.0__tar.gz → 4.1.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 (154) hide show
  1. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/Changelog.txt +128 -0
  2. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/PKG-INFO +4 -4
  3. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/README.md +1 -1
  4. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/__init__.py +24 -23
  5. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/config.ini.example +10 -4
  6. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/keys.py +50 -24
  7. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob2/verifier.py +42 -9
  8. openbadgeslib-4.1.0/openbadgeslib/ob3/eudi.py +707 -0
  9. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/status_registry.py +17 -2
  10. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/openbadges_publish.py +5 -13
  11. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/openbadges_verifier.py +18 -9
  12. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/util.py +1 -1
  13. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib.egg-info/PKG-INFO +4 -4
  14. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib.egg-info/SOURCES.txt +1 -0
  15. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib.egg-info/requires.txt +2 -2
  16. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/pyproject.toml +12 -2
  17. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_cli_json.py +17 -0
  18. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_docs.py +55 -1
  19. openbadgeslib-4.1.0/tests/test_keys.py +189 -0
  20. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob2_verifier.py +39 -0
  21. openbadgeslib-4.1.0/tests/test_ob3_contexts.py +162 -0
  22. openbadgeslib-4.1.0/tests/test_ob3_eudi_oid4vci.py +133 -0
  23. openbadgeslib-4.1.0/tests/test_ob3_eudi_sd_jwt.py +466 -0
  24. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_eudi_x5c.py +52 -1
  25. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_status_registry.py +21 -0
  26. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_public_api.py +18 -2
  27. openbadgeslib-4.0.0/openbadgeslib/ob3/eudi.py +0 -398
  28. openbadgeslib-4.0.0/tests/test_keys.py +0 -63
  29. openbadgeslib-4.0.0/tests/test_ob3_contexts.py +0 -87
  30. openbadgeslib-4.0.0/tests/test_ob3_eudi_sd_jwt.py +0 -206
  31. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/CONTRIBUTING.md +0 -0
  32. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/GOVERNANCE.md +0 -0
  33. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/LICENSE.txt +0 -0
  34. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/MANIFEST.in +0 -0
  35. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/SECURITY.md +0 -0
  36. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/docs/README.md +0 -0
  37. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/_jsonmodel.py +0 -0
  38. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/_jws/__init__.py +0 -0
  39. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/_jws/exceptions.py +0 -0
  40. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/_jws/utils.py +0 -0
  41. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/_ob1_api.py +0 -0
  42. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/badge_model.py +0 -0
  43. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/baking.py +0 -0
  44. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/cli_common.py +0 -0
  45. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/confparser.py +0 -0
  46. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/errors.py +0 -0
  47. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/issue.py +0 -0
  48. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/logs.py +0 -0
  49. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/mail.py +0 -0
  50. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob1/__init__.py +0 -0
  51. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob1/badge.py +0 -0
  52. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob1/signer.py +0 -0
  53. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob1/verifier.py +0 -0
  54. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob2/__init__.py +0 -0
  55. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob2/models.py +0 -0
  56. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob2/signer.py +0 -0
  57. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/__init__.py +0 -0
  58. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/contexts/__init__.py +0 -0
  59. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
  60. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
  61. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
  62. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
  63. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/contexts/ob-v3p0-extensions.json +0 -0
  64. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
  65. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
  66. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/credential.py +0 -0
  67. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/did.py +0 -0
  68. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/ldp.py +0 -0
  69. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/publish.py +0 -0
  70. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/signer.py +0 -0
  71. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/status.py +0 -0
  72. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/status_list.py +0 -0
  73. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/verifier.py +0 -0
  74. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/openbadges_cli.py +0 -0
  75. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/openbadges_init.py +0 -0
  76. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/openbadges_keygenerator.py +0 -0
  77. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/openbadges_signer.py +0 -0
  78. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/openbadges_status.py +0 -0
  79. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/py.typed +0 -0
  80. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/verify.py +0 -0
  81. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib.egg-info/dependency_links.txt +0 -0
  82. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib.egg-info/entry_points.txt +0 -0
  83. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib.egg-info/top_level.txt +0 -0
  84. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/setup.cfg +0 -0
  85. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/config1.ini +0 -0
  86. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/conformance/Dockerfile.ob3 +0 -0
  87. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/conformance/README.md +0 -0
  88. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/conformance/conftest.py +0 -0
  89. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/conformance/docker-compose.yml +0 -0
  90. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/conformance/test_official_validators.py +0 -0
  91. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/conftest.py +0 -0
  92. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/fixtures/ob_schemas/README.md +0 -0
  93. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
  94. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
  95. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/fixtures/ob_schemas/refresh.sh +0 -0
  96. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/fixtures/vc_di_eddsa/README.md +0 -0
  97. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
  98. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
  99. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
  100. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
  101. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
  102. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/images/sample1.png +0 -0
  103. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/images/sample1.svg +0 -0
  104. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/images/userimage01.svg +0 -0
  105. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/ldp_helpers.py +0 -0
  106. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/logo Python Espan/314/203a.svg" +0 -0
  107. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/logo Python Espa/303/261a.svg" +0 -0
  108. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_baking.py +0 -0
  109. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_cli_smoke.py +0 -0
  110. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_confparser.py +0 -0
  111. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_eddsa.py +0 -0
  112. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_errors.py +0 -0
  113. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_examples.py +0 -0
  114. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_issue.py +0 -0
  115. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_jsonmodel.py +0 -0
  116. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_jws.py +0 -0
  117. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_key_operation.py +0 -0
  118. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_mail.py +0 -0
  119. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob1_badge_io.py +0 -0
  120. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob1_deprecation.py +0 -0
  121. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob1_signer.py +0 -0
  122. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob1_verifier.py +0 -0
  123. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob2_cli.py +0 -0
  124. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob2_error_branches.py +0 -0
  125. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob2_models.py +0 -0
  126. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob2_signer.py +0 -0
  127. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_conformance_schema.py +0 -0
  128. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_credential.py +0 -0
  129. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_did.py +0 -0
  130. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_did_doc.py +0 -0
  131. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_endorsement.py +0 -0
  132. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_eudi_type_metadata.py +0 -0
  133. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_ldp.py +0 -0
  134. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_ldp_cli.py +0 -0
  135. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_ldp_ecdsa_sd.py +0 -0
  136. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_ldp_errors.py +0 -0
  137. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_ldp_sign.py +0 -0
  138. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_publish_cli.py +0 -0
  139. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_signer.py +0 -0
  140. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_signer_cli_ldp.py +0 -0
  141. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_status.py +0 -0
  142. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_status_list.py +0 -0
  143. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_verifier.py +0 -0
  144. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_openbadges_cli.py +0 -0
  145. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_sign_ecc.pem +0 -0
  146. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_sign_rsa.pem +0 -0
  147. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_signer_batch.py +0 -0
  148. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_util.py +0 -0
  149. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_verifier_cli_coverage.py +0 -0
  150. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_verify.py +0 -0
  151. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_verify_ecc.pem +0 -0
  152. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_verify_rsa.pem +0 -0
  153. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/withoutxmlheader.svg +0 -0
  154. {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/withxmlheader.svg +0 -0
@@ -4,6 +4,134 @@ OpenBadgesLib - Changelog
4
4
  Newest first. Dates are ISO 8601 (YYYY-MM-DD).
5
5
 
6
6
 
7
+ * v4.1.0 - 2026-07-27
8
+
9
+ The EUDI SD-JWT VC track becomes revocable (IETF Token Status List) and
10
+ advertisable over OpenID4VCI, on top of a security & correctness hardening
11
+ pass (audit-bot).
12
+
13
+ - fix(build): stop git's EOL conversion from defeating the bundled contexts'
14
+ recorded SHA-256 provenance on Windows. Without a `.gitattributes` entry git
15
+ checks the JSON-LD contexts out with CRLF on a Windows clone, so the bytes on
16
+ disk are not the bytes the digests in ob3/contexts/__init__.py cover — the
17
+ control that makes the fail-closed context allowlist meaningful could not be
18
+ evaluated on that platform at all, and the `test (windows)` CI leg had been
19
+ red since the gate landed (which also blocks releases: `publish` is
20
+ `needs: test`). The contexts are now marked `-text`, and a gate asserts that
21
+ attribute stays. Wheel/sdist installs were never affected (#274).
22
+ - fix(keys): a failed PEM load lands in the library's exception taxonomy on
23
+ every supported `cryptography`, and the floor moves to >=45. cryptography
24
+ v47 changed the contract — loading a key of an unsupported algorithm now
25
+ raises `UnsupportedAlgorithm`, which is NOT a ValueError — while
26
+ `KeyRSA`/`KeyECC.read_private_key`/`read_public_key` and the Ed25519 loaders
27
+ caught nothing at all, so a corrupt or unsupported key PEM escaped as a raw
28
+ crypto traceback (the `Badge` entry point was already guarded; these direct
29
+ callers were not). All PEM loading now goes through one guarded helper that
30
+ maps both contracts onto `PrivateKeyReadError` / `PublicKeyReadError`,
31
+ keeping the original exception as `__cause__`; `detect_key_type` /
32
+ `ec_curve_from_pem` stay total. `KeyRSA.generate_keypair` maps the v48
33
+ 1024-bit floor to `GenPrivateKeyError` instead of a raw ValueError. The
34
+ floor is 45 (openvc-core's own pin, so the extras resolve one range) rather
35
+ than 47: both contracts are handled and tested, so requiring an April-2026
36
+ release would buy nothing (#244).
37
+ - feat(eudi): build the OpenID4VCI credential configuration entry for an Open
38
+ Badge issued as SD-JWT VC. `badge_credential_configuration()` returns the
39
+ `credential_configurations_supported` entry a wallet reads to request the
40
+ badge — `format: dc+sd-jwt`, the `vct`, signing/proof algorithms (ES256, the
41
+ HAIP floor), `cryptographic_binding_methods_supported`, `claims` and
42
+ `display` — derived from the same `badge_type_metadata()` document the
43
+ issuer serves at that `vct`, so the advertised claim set and the served Type
44
+ Metadata cannot drift apart (the drift makes a wallet reject the credential
45
+ or display nothing). Pure-Python, no `[eudi]` extra. The enclosing
46
+ `/.well-known/openid-credential-issuer` document, the Credential Offer and
47
+ the endpoints stay the application's: they are deployment policy, one per
48
+ tenant (#271).
49
+ - feat(eudi): SD-JWT VC badges are revocable, via the IETF OAuth Token Status
50
+ List. `issue_badge_sd_jwt(..., status=...)` takes the reference
51
+ `openvc.status.issue.build_token_status_reference(uri=…, index=…)` builds and
52
+ emits it as a `status` claim that is **always disclosed** — never selectively
53
+ disclosable, whatever `disclosable=` says, since a holder able to withhold
54
+ the pointer could present a revoked badge as an unrevokable one. On the
55
+ verify side both trust paths (pinned key and x5c) now take
56
+ `require_status` / `resolve_status_list_token`: a badge whose bit says
57
+ revoked or suspended raises `EudiError`, and every way of not knowing the
58
+ status fails closed — an unresolvable, wrongly-signed, expired or
59
+ URL-swapped list raises rather than reading as "not revoked". The new
60
+ `status_list_token_resolver(pubkey_pem=…)` builds the safe default resolver
61
+ (HTTPS fetch + openvc-core's `verify_status_list_token`, including the IETF
62
+ anti-swap `sub` check). Encoding, signing and packing stay openvc-core's;
63
+ the `[eudi]` floor moves to >=1.23. This closes the #226 irrevocability
64
+ boundary for this track. Two formats, two lists: a credential carrying a
65
+ W3C `credentialStatus` (Bitstring) is still refused at SD-JWT issuance
66
+ rather than silently mapped onto a different wire format — a badge issued
67
+ on both tracks needs an index in each list (#245, #270).
68
+ - security(ob2): make the fetched copy of a HostedBadge authoritative for the
69
+ whole document. `_verify_hosted` reconciled only `id`, `recipient`, `badge`,
70
+ `issuedOn` and `expires` and then kept the copy baked into the image, so
71
+ `image`, `evidence` and `narrative` stayed holder-controlled on a badge
72
+ reported valid AND trusted — and the baked JWS is deliberately non-gating for
73
+ a hosted badge, so re-baking costs the holder nothing. `verify()` now returns
74
+ the assertion parsed from the HTTPS fetch, and the non-gating JWS check
75
+ resolves `verification.creator` from that copy instead of a holder-chosen
76
+ URL. The equality checks stay (they still catch a local copy claiming an `id`
77
+ that hosts something else). Narrow behaviour change: the served document must
78
+ itself parse as a strict OB 2.0 Assertion — it is the assertion now (#257).
79
+ - fix(cli): never report `"trusted": true` for a badge that failed to verify.
80
+ `openbadges-verifier --json` seeded the OB2/OB3 result with `trusted` True
81
+ before verifying anything, so every failure emitted it next to
82
+ `"valid": false`; the OB1 path seeded no `trusted` at all and set it only on
83
+ a successful parse, so a malformed OB1 badge emitted a payload missing the
84
+ documented common field entirely. All three paths (and the missing-file
85
+ early exit) now seed both booleans False and raise them only on a verdict,
86
+ so `trusted` is always present and `trusted => valid` holds. Exit statuses
87
+ are unchanged — they already keyed on `valid` first (#258).
88
+ - ci: derive the openvc-core floor the `eudi` job tests from pyproject.toml
89
+ instead of restating it. The pin drifted: 677f4ed raised the [eudi]/[ldp-sd]
90
+ floors to >=1.21 and touched only pyproject.toml, so the job's authoritative
91
+ (non-continue-on-error) `floor` leg kept installing openvc-core 1.18 —
92
+ downgrading below the package's own constraint and leaving the version users
93
+ actually resolve at the bottom of the range tested by nothing. A gate in
94
+ tests/test_docs.py now fails if a literal pin comes back (#264).
95
+ - fix(build): stop the documented conda setup from reinstating the dropped
96
+ pycryptodome / python-ecdsa. `environment.yml` still listed both as runtime
97
+ dependencies although the #167 port moved every key path to `cryptography`
98
+ and v4.0.0 removed the last soft-import shim — so `conda env create` brought
99
+ back a package the project dropped partly for its permanent pip-audit flag
100
+ (CVE-2024-23342, "Minerva"), one the weekly Audit job believes is gone. Its
101
+ `pyjwt` floor was also stale at >=2.8, below the >=2.13 security floor. Both
102
+ corrected, and a gate in tests/test_docs.py now fails if environment.yml's
103
+ runtime block and the pyproject dependencies ever disagree again (#265).
104
+ - security(ob3): enforce the bundled JSON-LD contexts against their recorded
105
+ SHA-256 provenance. The module header of ob3/contexts/__init__.py records a
106
+ digest per pinned @context — the control that makes the fail-closed
107
+ allowlist meaningful, since RDF canonicalization resolves each context and
108
+ the bundled document therefore decides what a Data Integrity proof covers —
109
+ but nothing checked it: the scheduled context-drift job compares against
110
+ UPSTREAM over the network, a different property that cannot run on a PR. Two
111
+ offline gates now assert every bundled file matches its digest and that no
112
+ context ships without one (#266).
113
+ - fix(ob3): raise RegistryCorrupt, not a raw AttributeError, for a status
114
+ registry whose `entries` is not a JSON object. `StatusRegistry.load` guarded
115
+ (KeyError, TypeError, ValueError) but called `.items()` on the value
116
+ unchecked, so `"entries": []` (or a string) escaped as an AttributeError
117
+ past every caller — openbadges-publish, `openbadges status` and the signer
118
+ all trap StatusError/LibOpenBadgesException — turning a corrupt-registry
119
+ message into a traceback. The registry object and its `entries` are now
120
+ shape-checked with named messages, and AttributeError joined the caught
121
+ tuple as belt and braces (#259).
122
+ - docs(api): publish the issuance/verification facades in the API reference.
123
+ `issue_from_conf`, `verify_badge`, `IssuanceError` and `SignResult` were
124
+ listed in the top-level `__all__` "for pdoc" but resolved lazily through
125
+ `__getattr__`, and pdoc reads the module dict — so it warned that each was
126
+ unresolvable and published a content-free stub with no signature, type or
127
+ docstring, while `openbadgeslib.issue` / `.verify` got no page at all. The
128
+ recommended modern API was therefore absent from the canonical reference. All
129
+ four are now imported eagerly and both modules are exported (additions to
130
+ `__all__`, so no contract is broken), which costs ~1.7 ms on an ~80 ms import
131
+ that already pulls ob2/ob3/keys. A gate in tests/test_public_api.py fails if
132
+ an `__all__` name becomes lazily-resolved again (#267).
133
+
134
+
7
135
  * v4.0.0 - 2026-07-22
8
136
 
9
137
  Grouped breaking release (#170) — freeze the declared public contract and
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openbadgeslib
3
- Version: 4.0.0
3
+ Version: 4.1.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
@@ -31,12 +31,12 @@ Description-Content-Type: text/markdown
31
31
  License-File: LICENSE.txt
32
32
  Requires-Dist: pypng>=0.20220715.0
33
33
  Requires-Dist: PyJWT[crypto]>=2.13
34
- Requires-Dist: cryptography>=42
34
+ Requires-Dist: cryptography>=45
35
35
  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.21; extra == "eudi"
39
+ Requires-Dist: openvc-core<2,>=1.23; extra == "eudi"
40
40
  Provides-Extra: ldp-sd
41
41
  Requires-Dist: openvc-core[data-integrity]<2,>=1.21; extra == "ldp-sd"
42
42
  Provides-Extra: dev
@@ -66,7 +66,7 @@ with W3C Bitstring Status Lists and `did:web`. It also ships strict
66
66
  **OpenBadges 2.0** (JWS / hosted assertions) and a frozen **OpenBadges 1.0**
67
67
  legacy format, selected with `-V {1,2,3}` (default `3`).
68
68
 
69
- > **Heads-up — 4.0.0 is coming.** Timed to Python 3.10's end of life (2026-10), the next major groups a few breaking changes: Python 3.10 is dropped (`requires-python >= 3.11`), the CLIs unify on one `0/1/2` exit-code contract, and the legacy pycryptodome/python-ecdsa compat shim is removed. OpenBadges 1.0 and the `openbadges-<command>` scripts stay. See **[Upgrading to 4.0](https://github.com/luisgf/openbadgeslib/wiki/Upgrading-to-4.0)** before pinning a major.
69
+ > **4.0.0 is released** (2026-07-22). It groups the breaking changes so upgrades stay rare: every CLI unifies on one `0/1/2` exit-code contract (a valid-but-untrusted badge now exits `2`, and the 255s are gone), and the legacy pycryptodome/python-ecdsa compat shim is removed — a live key **object** from either is no longer accepted, though key *files* are plain PEM and round-trip unchanged. OpenBadges 1.0 and the `openbadges-<command>` scripts stay, and a new unified `openbadges <command>` front-end joins them. **Python 3.10 is still supported** (`requires-python >= 3.10`, tested in CI on 3.10–3.14); dropping it is deferred to its end of life. See **[Upgrading to 4.0](https://github.com/luisgf/openbadgeslib/wiki/Upgrading-to-4.0)** before pinning a major.
70
70
 
71
71
  ## Features
72
72
 
@@ -13,7 +13,7 @@ with W3C Bitstring Status Lists and `did:web`. It also ships strict
13
13
  **OpenBadges 2.0** (JWS / hosted assertions) and a frozen **OpenBadges 1.0**
14
14
  legacy format, selected with `-V {1,2,3}` (default `3`).
15
15
 
16
- > **Heads-up — 4.0.0 is coming.** Timed to Python 3.10's end of life (2026-10), the next major groups a few breaking changes: Python 3.10 is dropped (`requires-python >= 3.11`), the CLIs unify on one `0/1/2` exit-code contract, and the legacy pycryptodome/python-ecdsa compat shim is removed. OpenBadges 1.0 and the `openbadges-<command>` scripts stay. See **[Upgrading to 4.0](https://github.com/luisgf/openbadgeslib/wiki/Upgrading-to-4.0)** before pinning a major.
16
+ > **4.0.0 is released** (2026-07-22). It groups the breaking changes so upgrades stay rare: every CLI unifies on one `0/1/2` exit-code contract (a valid-but-untrusted badge now exits `2`, and the 255s are gone), and the legacy pycryptodome/python-ecdsa compat shim is removed — a live key **object** from either is no longer accepted, though key *files* are plain PEM and round-trip unchanged. OpenBadges 1.0 and the `openbadges-<command>` scripts stay, and a new unified `openbadges <command>` front-end joins them. **Python 3.10 is still supported** (`requires-python >= 3.10`, tested in CI on 3.10–3.14); dropping it is deferred to its end of life. See **[Upgrading to 4.0](https://github.com/luisgf/openbadgeslib/wiki/Upgrading-to-4.0)** before pinning a major.
17
17
 
18
18
  ## Features
19
19
 
@@ -24,11 +24,11 @@
24
24
  from typing import Any
25
25
 
26
26
  # ── Submodule entry points ───────────────────────────────────────────────────
27
- # Bind ob2 / ob3 / errors as attributes so they are part of the explicit public
28
- # surface (and `from openbadgeslib import *`): ob2 (strict OB 2.0), ob3 (OB 3.0,
29
- # incl. ob3.eudi and ob3.OB3Ldp*), and the errors hierarchy rooted at
30
- # LibOpenBadgesException.
31
- from . import ob2, ob3, errors # noqa: F401
27
+ # Bind ob2 / ob3 / errors / issue / verify as attributes so they are part of the
28
+ # explicit public surface (and `from openbadgeslib import *`): ob2 (strict
29
+ # OB 2.0), ob3 (OB 3.0, incl. ob3.eudi and ob3.OB3Ldp*), the errors hierarchy
30
+ # rooted at LibOpenBadgesException, and the issuance/verification facades.
31
+ from . import ob2, ob3, errors, issue, verify # noqa: F401
32
32
 
33
33
  # ── OpenBadges 2.0 (strict) ──────────────────────────────────────────────────
34
34
  from .ob2 import ( # noqa: F401
@@ -55,13 +55,20 @@ from ._ob1_api import OB1_API as _OB1_API # noqa: F401
55
55
  # ── Issuance / verification API ──────────────────────────────────────────────
56
56
  # "Issue badge X to Y per config" / "verify this badge" as library calls — the
57
57
  # orchestration the CLIs wrap, returning a SignResult / VerifyResult instead of
58
- # doing I/O (openbadgeslib.issue, openbadgeslib.verify). Resolved lazily (PEP
59
- # 562, below) so a bare `import openbadgeslib` stays lightweight;
60
- # `from openbadgeslib.issue import ...` / `.verify import ...` work directly.
61
- _ISSUE_API = {
62
- 'IssuanceError': 'issue', 'SignResult': 'issue', 'issue_from_conf': 'issue',
63
- 'verify_badge': 'verify',
64
- }
58
+ # doing I/O (openbadgeslib.issue, openbadgeslib.verify).
59
+ #
60
+ # Imported eagerly, unlike the OB1 names below. These were resolved lazily (PEP
61
+ # 562) to keep a bare `import openbadgeslib` light, and listed in __all__ "for
62
+ # pdoc" but pdoc reads the module dict, so it could not resolve them and
63
+ # published each as a content-free stub with no signature, type or docstring,
64
+ # while openbadgeslib.issue / .verify got no page at all (#267). The laziness
65
+ # bought ~1.7 ms against an ~80 ms import that already pulls ob2, ob3 and keys
66
+ # (hence cryptography) — inside the run-to-run noise — so the documentation of
67
+ # the recommended modern API is worth more than it.
68
+ from .issue import ( # noqa: F401
69
+ IssuanceError, SignResult, issue_from_conf,
70
+ )
71
+ from .verify import verify_badge # noqa: F401
65
72
 
66
73
 
67
74
  # ── OpenBadges 1.0 (legacy) ──────────────────────────────────────────────────
@@ -75,12 +82,6 @@ _ISSUE_API = {
75
82
 
76
83
 
77
84
  def __getattr__(name: str) -> Any:
78
- if name in _ISSUE_API:
79
- # The modern issuance / verification API — lazy so a bare import stays
80
- # lightweight, but warning-free (unlike the OB1 names below).
81
- import importlib
82
- return getattr(
83
- importlib.import_module('.' + _ISSUE_API[name], __name__), name)
84
85
  module = _OB1_API.get(name)
85
86
  if module is None:
86
87
  raise AttributeError(
@@ -99,12 +100,12 @@ def __getattr__(name: str) -> Any:
99
100
  # The stable, supported top-level surface (the "public contract" — see the
100
101
  # "Stable API vs internals" wiki/doc page). Everything else (the ob1.* legacy
101
102
  # names resolved lazily above, and any underscore-prefixed name) is internal.
102
- # The issuance/verification facades are resolved lazily by __getattr__ but are
103
- # part of the contract, so they are listed here for pdoc and `import *`.
103
+ # Every name here is a real module attribute, so pdoc documents all of them —
104
+ # see tests/test_public_api.py, which fails if one becomes unresolvable again.
104
105
  __all__ = [
105
106
  '__version__',
106
- # Subpackage entry points.
107
- 'ob2', 'ob3', 'errors',
107
+ # Subpackage / module entry points.
108
+ 'ob2', 'ob3', 'errors', 'issue', 'verify',
108
109
  # OpenBadges 2.0 (strict).
109
110
  'OB2Signer', 'OB2Verifier', 'OB2VerificationError',
110
111
  # OpenBadges 3.0.
@@ -112,6 +113,6 @@ __all__ = [
112
113
  'OpenBadgeCredential', 'Achievement', 'Issuer',
113
114
  # Keys (KeyEd25519 is the recommended OB 3.0 / LDP key type).
114
115
  'KeyFactory', 'KeyRSA', 'KeyECC', 'KeyEd25519',
115
- # Programmatic issue / verify facades (resolved lazily).
116
+ # Programmatic issue / verify facades.
116
117
  'issue_from_conf', 'verify_badge', 'IssuanceError', 'SignResult',
117
118
  ]
@@ -45,6 +45,12 @@ revocationList = revoked.json
45
45
  ; issuer. 'auto': derive a did:web DID from publish_url (openbadges-publish
46
46
  ; -V 3 generates the matching did.json). An explicit did:web:... is used as-is.
47
47
  ;did = auto
48
+ ; EUDI SD-JWT VC track (optional): an issuer-hosted vct URL, which must live
49
+ ; under publish_url. openbadges-publish -V 3 then writes its SD-JWT VC Type
50
+ ; Metadata into the published folder so wallets can resolve and validate
51
+ ; SD-JWT badges. Unset: nothing is published and badges use the default
52
+ ; imsglobal vct.
53
+ ;sd_jwt_vct = ${publish_url}vct/openbadge
48
54
 
49
55
  ;Badge configuration sections.
50
56
  [badge_1]
@@ -53,6 +59,10 @@ description = Given to any user that install this library
53
59
  local_image = image_badge1.svg
54
60
  image = https://www.issuer.badge/issuer/badge_1/badge1.svg
55
61
  criteria = https://www.issuer.badge/issuer/badge_1/criteria.html
62
+ ; OpenBadges 3.0 achievement criteria narrative (free text). Falls back to the
63
+ ; 'criteria' URL above when unset, which is rarely what you want in a
64
+ ; credential a human reads.
65
+ ;criteria_narrative = Awarded to anyone who installs this library
56
66
  verify_key = https://www.issuer.badge/issuer/badge_1/verify_rsa_key.pem
57
67
  badge = https://www.issuer.badge/issuer/badge_1/badge.json
58
68
  ; OpenBadges 2.0 (-V 2): URL of this badge's CryptographicKey JSON-LD document
@@ -88,8 +98,6 @@ key_type = RSA
88
98
  ; lapses; pick a cadence you can sustain (e.g. 7 => weekly). Unset = no bound
89
99
  ; (offline-friendly, but publishes a loud warning).
90
100
  ;status_validity_days = 7
91
- ;alignement =
92
- ;tags =
93
101
  ;mail = ${paths:base}/badge_1_mail.txt
94
102
 
95
103
  [badge_2]
@@ -105,6 +113,4 @@ hosted_assertions_base = https://www.issuer.badge/issuer/badge_2/assertions/
105
113
  private_key = ${paths:base_key}/sign_rsa_key_2.pem
106
114
  public_key = ${paths:base_key}/verify_rsa_key_2.pem
107
115
  key_type = RSA
108
- ;alignement =
109
- ;tags =
110
116
  ;mail = ${paths:base}/badge_2_mail.txt
@@ -29,7 +29,9 @@
29
29
  # ``key_to_pem`` now takes only ``cryptography`` key objects and PEM bytes/str.
30
30
 
31
31
  from typing import Any, Optional, Tuple, Union, cast
32
- from .errors import PublicKeyReadError, UnknownKeyType
32
+ from .errors import (
33
+ GenPrivateKeyError, PrivateKeyReadError, PublicKeyReadError, UnknownKeyType)
34
+ from cryptography.exceptions import UnsupportedAlgorithm
33
35
  from cryptography.hazmat.primitives import serialization as _crypto_serialization
34
36
  from cryptography.hazmat.primitives.asymmetric import ec, rsa
35
37
  from cryptography.hazmat.primitives.asymmetric.ed25519 import (
@@ -38,6 +40,32 @@ from enum import Enum
38
40
  import logging
39
41
  logger = logging.getLogger(__name__)
40
42
 
43
+ # What a failed PEM load raises depends on the `cryptography` version: up to v46
44
+ # an unsupported key algorithm came back as ValueError; **v47 changed the
45
+ # contract** to UnsupportedAlgorithm (which is NOT a ValueError). Our floor
46
+ # spans that change, so both are caught in one place and mapped onto the
47
+ # library's own taxonomy — a caller must never have to know which cryptography
48
+ # it resolved, and a raw crypto exception must never escape as a traceback.
49
+ _PEM_LOAD_ERRORS = (ValueError, TypeError, UnsupportedAlgorithm)
50
+
51
+
52
+ def _load_private_pem(data: bytes) -> Any:
53
+ """Load a private key PEM, mapping any load failure to PrivateKeyReadError."""
54
+ try:
55
+ return _crypto_serialization.load_pem_private_key(data, password=None)
56
+ except _PEM_LOAD_ERRORS as exc:
57
+ raise PrivateKeyReadError(
58
+ 'Unable to read private key PEM: %s' % exc) from exc
59
+
60
+
61
+ def _load_public_pem(data: bytes) -> Any:
62
+ """Load a public key PEM, mapping any load failure to PublicKeyReadError."""
63
+ try:
64
+ return _crypto_serialization.load_pem_public_key(data)
65
+ except _PEM_LOAD_ERRORS as exc:
66
+ raise PublicKeyReadError(
67
+ 'Unable to read public key PEM: %s' % exc) from exc
68
+
41
69
 
42
70
  class KeyType(Enum):
43
71
  RSA = 'RSA 2048'
@@ -100,20 +128,26 @@ class KeyRSA(KeyBase):
100
128
 
101
129
  def generate_keypair(self) -> Tuple[bytes, bytes]:
102
130
  """ Generate a RSA Key, returning in PEM Format """
103
- self.priv_key = rsa.generate_private_key(
104
- public_exponent=65537, key_size=self._key_size)
131
+ try:
132
+ self.priv_key = rsa.generate_private_key(
133
+ public_exponent=65537, key_size=self._key_size)
134
+ except (ValueError, UnsupportedAlgorithm) as exc:
135
+ # cryptography v48 enforces a 1024-bit floor on key_size; below it
136
+ # (and on any other refused parameter) this must be a library error,
137
+ # not a raw traceback out of the CLI.
138
+ raise GenPrivateKeyError(
139
+ 'Unable to generate a %d-bit RSA key: %s'
140
+ % (self._key_size, exc)) from exc
105
141
  self.pub_key = self.priv_key.public_key()
106
142
  return self.get_priv_key_pem(), self.get_pub_key_pem()
107
143
 
108
144
  def read_private_key(self, key_pem: Any = None) -> None:
109
145
  """ Read the private key from param in PEM format """
110
- self.priv_key = _crypto_serialization.load_pem_private_key(
111
- _pem_bytes(key_pem), password=None)
146
+ self.priv_key = _load_private_pem(_pem_bytes(key_pem))
112
147
 
113
148
  def read_public_key(self, key_pem: Any = None) -> None:
114
149
  """ Read the public key from param in PEM format """
115
- self.pub_key = _crypto_serialization.load_pem_public_key(
116
- _pem_bytes(key_pem))
150
+ self.pub_key = _load_public_pem(_pem_bytes(key_pem))
117
151
 
118
152
  def get_priv_key_pem(self) -> bytes:
119
153
  return _private_pem(self.priv_key)
@@ -137,13 +171,11 @@ class KeyECC(KeyBase):
137
171
 
138
172
  def read_private_key(self, key_pem: Any = None) -> None:
139
173
  """ Read the private key from param in PEM format """
140
- self.priv_key = _crypto_serialization.load_pem_private_key(
141
- _pem_bytes(key_pem), password=None)
174
+ self.priv_key = _load_private_pem(_pem_bytes(key_pem))
142
175
 
143
176
  def read_public_key(self, key_pem: Any = None) -> None:
144
177
  """ Read the public key from param in PEM format """
145
- self.pub_key = _crypto_serialization.load_pem_public_key(
146
- _pem_bytes(key_pem))
178
+ self.pub_key = _load_public_pem(_pem_bytes(key_pem))
147
179
 
148
180
  def get_priv_key_pem(self) -> bytes:
149
181
  return _private_pem(self.priv_key)
@@ -155,7 +187,7 @@ class KeyECC(KeyBase):
155
187
  def _load_ed25519_private_key(key_pem: Optional[Union[str, bytes]]) -> Ed25519PrivateKey:
156
188
  if key_pem is None:
157
189
  raise UnknownKeyType('No Ed25519 private key PEM provided')
158
- key = _crypto_serialization.load_pem_private_key(_pem_bytes(key_pem), password=None)
190
+ key = _load_private_pem(_pem_bytes(key_pem))
159
191
  if not isinstance(key, Ed25519PrivateKey):
160
192
  raise UnknownKeyType('PEM is not an Ed25519 private key')
161
193
  return key
@@ -164,7 +196,7 @@ def _load_ed25519_private_key(key_pem: Optional[Union[str, bytes]]) -> Ed25519Pr
164
196
  def _load_ed25519_public_key(key_pem: Optional[Union[str, bytes]]) -> Ed25519PublicKey:
165
197
  if key_pem is None:
166
198
  raise UnknownKeyType('No Ed25519 public key PEM provided')
167
- key = _crypto_serialization.load_pem_public_key(_pem_bytes(key_pem))
199
+ key = _load_public_pem(_pem_bytes(key_pem))
168
200
  if not isinstance(key, Ed25519PublicKey):
169
201
  raise UnknownKeyType('PEM is not an Ed25519 public key')
170
202
  return key
@@ -249,7 +281,7 @@ def public_jwk_from_pem(pubkey_pem: Union[str, bytes]) -> dict[str, Any]:
249
281
  import json
250
282
  from jwt.algorithms import ECAlgorithm, OKPAlgorithm, RSAAlgorithm
251
283
  try:
252
- pub = _crypto_serialization.load_pem_public_key(_pem_bytes(pubkey_pem))
284
+ pub = _load_public_pem(_pem_bytes(pubkey_pem))
253
285
  if isinstance(pub, rsa.RSAPublicKey):
254
286
  jwk_json = RSAAlgorithm.to_jwk(pub)
255
287
  elif isinstance(pub, ec.EllipticCurvePublicKey):
@@ -277,12 +309,9 @@ def ec_curve_from_pem(pem_data: Union[str, bytes]) -> Optional[str]:
277
309
  fixes the JOSE algorithm.
278
310
  """
279
311
  data = _pem_bytes(pem_data)
280
- for load in (
281
- lambda: _crypto_serialization.load_pem_public_key(data),
282
- lambda: _crypto_serialization.load_pem_private_key(data, password=None),
283
- ):
312
+ for load in (_load_public_pem, _load_private_pem):
284
313
  try:
285
- key = load()
314
+ key = load(data)
286
315
  except Exception:
287
316
  continue
288
317
  if isinstance(key, (ec.EllipticCurvePublicKey,
@@ -302,12 +331,9 @@ def detect_key_type(pem_data: Union[str, bytes]) -> 'KeyType':
302
331
  """
303
332
  data = _pem_bytes(pem_data)
304
333
  key: Any = None
305
- for load in (
306
- lambda: _crypto_serialization.load_pem_public_key(data),
307
- lambda: _crypto_serialization.load_pem_private_key(data, password=None),
308
- ):
334
+ for load in (_load_public_pem, _load_private_pem):
309
335
  try:
310
- key = load()
336
+ key = load(data)
311
337
  break
312
338
  except Exception:
313
339
  continue
@@ -60,7 +60,8 @@ class OB2Verifier:
60
60
  * **HostedBadge** — the assertion is fetched over HTTPS from its own ``id``;
61
61
  that retrieval (scoped to the issuer's origin) is the trust anchor, per
62
62
  the OB 2.0 hosted model. The baked JWS is verified as non-gating
63
- defence-in-depth only.
63
+ defence-in-depth only, so the fetched document — not the baked one — is
64
+ what every check runs on and what ``verify()`` returns.
64
65
 
65
66
  Args:
66
67
  pubkey_pem: Optional PEM-encoded trusted public key. When supplied it is
@@ -86,7 +87,11 @@ class OB2Verifier:
86
87
  """Verify a compact-JWS OB 2.0 assertion token.
87
88
 
88
89
  Returns the decoded :class:`~openbadgeslib.ob2.models.Assertion` on
89
- success; raises :class:`OB2VerificationError` on any failure.
90
+ success; raises :class:`OB2VerificationError` on any failure. For a
91
+ HostedBadge the returned assertion is the one **fetched from its id**
92
+ (the trust anchor), not the copy baked into the image — so a member the
93
+ baked copy alone carried, such as ``evidence`` or ``narrative``, never
94
+ reaches the caller.
90
95
 
91
96
  ``expected_recipient`` (an email) additionally binds the assertion to a
92
97
  recipient by re-hashing it with the embedded salt. ``check_revocation``
@@ -100,7 +105,10 @@ class OB2Verifier:
100
105
  raise OB2VerificationError("Malformed OB 2.0 assertion: %s" % exc) from exc
101
106
 
102
107
  if assertion.verification.type == "HostedBadge":
103
- self._verify_hosted(assertion, token)
108
+ # The fetched copy is the trust anchor, so it replaces the local one:
109
+ # every check below (and the returned object) must speak for the
110
+ # document the issuer serves, not the one baked into the image.
111
+ assertion = self._verify_hosted(assertion, token)
104
112
  else:
105
113
  self._verify_signed(assertion, token)
106
114
 
@@ -164,14 +172,25 @@ class OB2Verifier:
164
172
 
165
173
  # ── hosted path ──────────────────────────────────────────────────────────────
166
174
 
167
- def _verify_hosted(self, assertion: Assertion, token: str) -> None:
168
- """Verify a HostedBadge assertion by fetching its ``id`` over HTTPS.
175
+ def _verify_hosted(self, assertion: Assertion, token: str) -> Assertion:
176
+ """Verify a HostedBadge assertion by fetching its ``id`` over HTTPS and
177
+ return the AUTHORITATIVE assertion parsed from that fetch.
169
178
 
170
179
  The fetched document is the authoritative copy; its origin must fall
171
180
  within the issuer's scope (default: same origin as the issuer Profile
172
181
  ``id``; or the issuer's ``verification.startsWith`` / ``allowedOrigins``
173
182
  when declared). The baked JWS is verified only as non-gating
174
183
  defence-in-depth.
184
+
185
+ Returning the fetched assertion — rather than reconciling a fixed list
186
+ of members and keeping the local one — is what makes "the hosted copy is
187
+ the trust anchor" true for the *whole* document. Only id/recipient/badge/
188
+ issuedOn/expires used to be compared, so ``image``, ``evidence`` and
189
+ ``narrative`` stayed holder-controlled on a badge reported valid and
190
+ trusted (the baked JWS is non-gating here, so a re-baked local copy costs
191
+ the holder nothing). The equality checks below stay: they still catch a
192
+ local copy claiming an ``id`` that legitimately hosts something else.
193
+ Consequence: the served document must itself be a valid OB 2.0 Assertion.
175
194
  """
176
195
  assert assertion.id is not None
177
196
  fetched = self._fetch_json(assertion.id, "hosted assertion")
@@ -217,22 +236,36 @@ class OB2Verifier:
217
236
  "Hosted assertion at %s does not match the badge's local "
218
237
  "claims (field 'expires' differs)" % (assertion.id,))
219
238
 
220
- issuer = self._fetch_issuer(assertion)
239
+ # Everything reconciled: adopt the served document as the assertion this
240
+ # verification speaks for. It must parse as a strict OB 2.0 Assertion —
241
+ # it IS the assertion, not a copy of it.
242
+ try:
243
+ authoritative = Assertion.from_dict(fetched)
244
+ except ValueError as exc:
245
+ raise OB2VerificationError(
246
+ "Hosted assertion at %s is not a valid OpenBadges 2.0 Assertion: %s"
247
+ % (assertion.id, exc)) from exc
248
+
249
+ issuer = self._fetch_issuer(authoritative)
250
+ # assertion.id is the fetched id (proven equal above) and is known
251
+ # non-None, which authoritative.id is only by from_dict's contract.
221
252
  self._check_hosted_scope(assertion.id, issuer)
222
253
 
223
254
  # Defence-in-depth: if the baked token also carries a resolvable key,
224
255
  # verify it, but never fail the hosted verdict on it (a hosted badge is
225
- # not required to be signed at all).
256
+ # not required to be signed at all). The creator comes from the
257
+ # authoritative copy, so this never dereferences a holder-chosen URL.
226
258
  try:
227
259
  if self.trusted_pubkey_pem is not None:
228
260
  self._verify_jws(token, self.trusted_pubkey_pem)
229
- elif assertion.verification.creator:
230
- key = self._resolve_creator(assertion.verification.creator)
261
+ elif authoritative.verification.creator:
262
+ key = self._resolve_creator(authoritative.verification.creator)
231
263
  self._verify_jws(token, key.public_key_pem.encode('utf-8'))
232
264
  except OB2VerificationError as exc:
233
265
  logger.debug("Hosted assertion %s: baked JWS did not verify (%s); "
234
266
  "hosted trust comes from the HTTPS fetch, not the signature.",
235
267
  assertion.id, exc)
268
+ return authoritative
236
269
 
237
270
  def _check_hosted_scope(self, assertion_id: str, issuer: dict[str, Any]) -> None:
238
271
  """Enforce the OB 2.0 hosted-verification scope for ``assertion_id``."""