openbadgeslib 3.14.0__tar.gz → 4.0.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 (155) hide show
  1. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/Changelog.txt +316 -0
  2. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/PKG-INFO +7 -5
  3. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/__init__.py +51 -19
  4. openbadgeslib-4.0.0/openbadgeslib/_jsonmodel.py +86 -0
  5. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/_jws/__init__.py +39 -2
  6. openbadgeslib-4.0.0/openbadgeslib/_ob1_api.py +41 -0
  7. openbadgeslib-4.0.0/openbadgeslib/badge_model.py +199 -0
  8. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/baking.py +17 -9
  9. openbadgeslib-4.0.0/openbadgeslib/cli_common.py +63 -0
  10. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/config.ini.example +15 -0
  11. openbadgeslib-4.0.0/openbadgeslib/confparser.py +414 -0
  12. openbadgeslib-4.0.0/openbadgeslib/errors.py +221 -0
  13. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/issue.py +55 -30
  14. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/keys.py +8 -33
  15. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/mail.py +1 -1
  16. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob1/__init__.py +3 -7
  17. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob1/badge.py +11 -155
  18. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob1/verifier.py +3 -3
  19. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob2/models.py +7 -41
  20. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob2/signer.py +4 -4
  21. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob2/verifier.py +5 -3
  22. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob3/__init__.py +15 -0
  23. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob3/contexts/__init__.py +24 -2
  24. openbadgeslib-4.0.0/openbadgeslib/ob3/contexts/ob-v3p0-extensions.json +9 -0
  25. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob3/credential.py +15 -43
  26. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob3/did.py +40 -11
  27. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob3/eudi.py +69 -5
  28. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob3/ldp.py +51 -13
  29. openbadgeslib-4.0.0/openbadgeslib/ob3/publish.py +459 -0
  30. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob3/signer.py +41 -16
  31. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob3/status.py +3 -3
  32. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob3/status_list.py +5 -0
  33. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob3/verifier.py +47 -18
  34. openbadgeslib-4.0.0/openbadgeslib/openbadges_cli.py +98 -0
  35. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/openbadges_init.py +26 -13
  36. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/openbadges_keygenerator.py +15 -26
  37. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/openbadges_publish.py +143 -329
  38. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/openbadges_signer.py +23 -27
  39. openbadgeslib-4.0.0/openbadgeslib/openbadges_status.py +75 -0
  40. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/openbadges_verifier.py +119 -191
  41. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/util.py +159 -5
  42. openbadgeslib-4.0.0/openbadgeslib/verify.py +295 -0
  43. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib.egg-info/PKG-INFO +7 -5
  44. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib.egg-info/SOURCES.txt +14 -3
  45. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib.egg-info/entry_points.txt +1 -0
  46. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib.egg-info/requires.txt +8 -4
  47. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/pyproject.toml +35 -5
  48. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/conftest.py +9 -9
  49. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_baking.py +56 -0
  50. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_cli_json.py +2 -2
  51. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_cli_smoke.py +93 -16
  52. openbadgeslib-4.0.0/tests/test_confparser.py +492 -0
  53. openbadgeslib-4.0.0/tests/test_errors.py +92 -0
  54. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_issue.py +19 -0
  55. openbadgeslib-4.0.0/tests/test_jsonmodel.py +69 -0
  56. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_jws.py +43 -0
  57. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_keys.py +3 -10
  58. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob1_badge_io.py +1 -1
  59. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob1_deprecation.py +11 -11
  60. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob1_signer.py +11 -12
  61. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob1_verifier.py +2 -2
  62. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob2_cli.py +5 -1
  63. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob2_verifier.py +24 -0
  64. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob3_conformance_schema.py +4 -2
  65. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob3_contexts.py +11 -0
  66. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob3_did_doc.py +50 -0
  67. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob3_endorsement.py +23 -2
  68. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob3_eudi_sd_jwt.py +63 -0
  69. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob3_eudi_x5c.py +61 -5
  70. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob3_ldp.py +61 -5
  71. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob3_ldp_ecdsa_sd.py +31 -4
  72. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob3_publish_cli.py +193 -3
  73. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob3_signer.py +59 -0
  74. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob3_status.py +123 -5
  75. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob3_status_registry.py +30 -0
  76. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob3_verifier.py +48 -2
  77. openbadgeslib-4.0.0/tests/test_openbadges_cli.py +103 -0
  78. openbadgeslib-4.0.0/tests/test_public_api.py +78 -0
  79. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_util.py +141 -4
  80. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_verifier_cli_coverage.py +30 -15
  81. openbadgeslib-4.0.0/tests/test_verify.py +113 -0
  82. openbadgeslib-3.14.0/openbadgeslib/badge.py +0 -21
  83. openbadgeslib-3.14.0/openbadgeslib/confparser.py +0 -246
  84. openbadgeslib-3.14.0/openbadgeslib/errors.py +0 -137
  85. openbadgeslib-3.14.0/openbadgeslib/signer.py +0 -13
  86. openbadgeslib-3.14.0/openbadgeslib/verifier.py +0 -13
  87. openbadgeslib-3.14.0/tests/test_confparser.py +0 -199
  88. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/CONTRIBUTING.md +0 -0
  89. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/GOVERNANCE.md +0 -0
  90. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/LICENSE.txt +0 -0
  91. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/MANIFEST.in +0 -0
  92. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/README.md +0 -0
  93. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/SECURITY.md +0 -0
  94. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/docs/README.md +0 -0
  95. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/_jws/exceptions.py +0 -0
  96. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/_jws/utils.py +0 -0
  97. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/logs.py +0 -0
  98. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob1/signer.py +0 -0
  99. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob2/__init__.py +0 -0
  100. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
  101. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
  102. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
  103. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
  104. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
  105. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
  106. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/ob3/status_registry.py +0 -0
  107. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib/py.typed +0 -0
  108. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib.egg-info/dependency_links.txt +0 -0
  109. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/openbadgeslib.egg-info/top_level.txt +0 -0
  110. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/setup.cfg +0 -0
  111. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/config1.ini +0 -0
  112. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/conformance/Dockerfile.ob3 +0 -0
  113. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/conformance/README.md +0 -0
  114. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/conformance/conftest.py +0 -0
  115. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/conformance/docker-compose.yml +0 -0
  116. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/conformance/test_official_validators.py +0 -0
  117. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/fixtures/ob_schemas/README.md +0 -0
  118. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
  119. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
  120. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/fixtures/ob_schemas/refresh.sh +0 -0
  121. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/fixtures/vc_di_eddsa/README.md +0 -0
  122. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
  123. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
  124. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
  125. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
  126. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
  127. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/images/sample1.png +0 -0
  128. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/images/sample1.svg +0 -0
  129. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/images/userimage01.svg +0 -0
  130. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/ldp_helpers.py +0 -0
  131. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/logo Python Espan/314/203a.svg" +0 -0
  132. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/logo Python Espa/303/261a.svg" +0 -0
  133. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_docs.py +0 -0
  134. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_eddsa.py +0 -0
  135. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_examples.py +0 -0
  136. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_key_operation.py +0 -0
  137. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_mail.py +0 -0
  138. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob2_error_branches.py +0 -0
  139. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob2_models.py +0 -0
  140. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob2_signer.py +0 -0
  141. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob3_credential.py +0 -0
  142. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob3_did.py +0 -0
  143. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob3_eudi_type_metadata.py +0 -0
  144. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob3_ldp_cli.py +0 -0
  145. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob3_ldp_errors.py +0 -0
  146. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob3_ldp_sign.py +0 -0
  147. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob3_signer_cli_ldp.py +0 -0
  148. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_ob3_status_list.py +0 -0
  149. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_sign_ecc.pem +0 -0
  150. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_sign_rsa.pem +0 -0
  151. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_signer_batch.py +0 -0
  152. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_verify_ecc.pem +0 -0
  153. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/test_verify_rsa.pem +0 -0
  154. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/withoutxmlheader.svg +0 -0
  155. {openbadgeslib-3.14.0 → openbadgeslib-4.0.0}/tests/withxmlheader.svg +0 -0
@@ -4,6 +4,322 @@ OpenBadgesLib - Changelog
4
4
  Newest first. Dates are ISO 8601 (YYYY-MM-DD).
5
5
 
6
6
 
7
+ * v4.0.0 - 2026-07-22
8
+
9
+ Grouped breaking release (#170) — freeze the declared public contract and
10
+ unify the CLIs — shipped together with the Round-2 roadmap work: close the
11
+ revocation/trust gaps the deep-logic analysis surfaced, test the fail-closed
12
+ guards, and speed up batch issuance and verification. Breaking changes first,
13
+ then the rest of the cycle.
14
+
15
+ - fix(cli)!: unify every console tool on the 0/1/2 exit-code contract (#233).
16
+ The 19 `sys.exit(-1)` sites (which exited 255) now exit 1, and the verifier
17
+ applies the same status in human mode as under --json: 1 on any failure, 2
18
+ when the signature is valid but the issuer is not anchored (an OB2
19
+ badge-embedded key or a self-asserted did:key), and 0 only when the badge is
20
+ valid AND trusted. A partial batch sign or publish, and a stale/missing
21
+ artifact under `publish --check-live`, now exit 2 in human mode too (they
22
+ exited 1 before). BREAKING: a wrapper that keyed on 255, or that treated a
23
+ valid-but-untrusted badge's exit 0 as success, must be updated.
24
+ - feat(cli): add a unified `openbadges <command>` front-end and centralise the
25
+ shared flags (#234). A new `openbadgeslib/cli_common.py` provides argparse
26
+ parent parsers for -c/--config, -d/--debug, --json and -v/--version, which
27
+ all five build_parser()s now compose — so the flags are defined once with
28
+ consistent help. openbadges-publish gains -d/--debug (it had none) and
29
+ openbadges-init gains argparse (hence -v/--version, and -h now exits 0 via
30
+ argparse). The new `openbadges` entry point dispatches to init/keygen/sign/
31
+ verify/publish, each of which stays installed under its own openbadges-<name>
32
+ script; a command owns everything after its name, so `openbadges sign ...`
33
+ behaves exactly like `openbadges-signer ...`. Minor breaking: openbadges-init
34
+ with a bad/missing argument now exits 2 (argparse), not 1.
35
+ - feat(cli): add `openbadges status [ID]`, the sixth subcommand promised by
36
+ the unified front-end (#170). It is the read-only registry query that until
37
+ now was reachable only as `openbadges-publish -V 3 --list/--status`: with no
38
+ ID it tabulates every issued OB3 credential and its state, with one (a jti
39
+ or a recipient email) it prints that credential's full record including the
40
+ revocation reason. Auditing a credential is a read, so it no longer requires
41
+ typing a publish verb, nor `-V 3` — status lists are OB3-only by
42
+ construction. The query itself is not duplicated: the command and publish's
43
+ flags both call the shared `query_ob3()`, which now takes plain arguments
44
+ instead of a parsed argparse Namespace. `--list`/`--status` keep working
45
+ unchanged. Unlike the other five commands this one has no standalone
46
+ `openbadges-status` script — those aliases exist only because they predate
47
+ the front-end.
48
+ - refactor(ob1)!: consolidate the OB1 compatibility surface (#235). The OB1
49
+ legacy name table (11 names -> leaf module) is single-sourced in
50
+ openbadgeslib/_ob1_api.py instead of being duplicated between the top-level
51
+ package and openbadgeslib.ob1. The three top-level shim modules —
52
+ openbadgeslib.badge, openbadgeslib.signer and openbadgeslib.verifier — are
53
+ removed. OpenBadges 1.0 stays fully supported and reachable via
54
+ openbadgeslib.ob1.* (e.g. `from openbadgeslib.ob1 import Signer`) and the
55
+ unprefixed openbadgeslib.Signer / .Badge / … re-exports, both of which still
56
+ emit the steering DeprecationWarning. BREAKING: `from openbadgeslib.badge
57
+ import ...` (and .signer/.verifier) no longer resolve — repoint them to
58
+ `openbadgeslib.ob1.<module>`.
59
+ - refactor(keys)!: drop the last pycryptodome / python-ecdsa soft-import
60
+ (#170). Both libraries stopped being dependencies in the 3.7 port (#167),
61
+ but `key_to_pem` kept a soft-import shim that still accepted a *live*
62
+ pycryptodome RSA or python-ecdsa key object. That shim (`_legacy_key_to_pem`)
63
+ is removed, along with the `Crypto.*`/`ecdsa.*` mypy overrides: `key_to_pem`
64
+ now takes only `cryptography` key objects and PEM bytes/str, raising
65
+ `UnknownKeyType` otherwise. Key *files* are unaffected — they were already
66
+ plain PEM and round-trip unchanged. BREAKING: pass a `cryptography` key
67
+ object or PEM instead of a live pycryptodome/python-ecdsa object.
68
+
69
+ - fix(ob3): bundle and reference the OB v3p0 extensions context so the
70
+ `credentialSchema` type (`1EdTechJsonSchemaValidator2019`, emitted since
71
+ v3.6.0) resolves to a defined JSON-LD term. Without it the term was
72
+ undefined: the official 1EdTech OB3 validator rejected every issued
73
+ credential ("Undefined JSON-LD term") and canonicalization mapped the type
74
+ to a base-relative IRI, so the Data Integrity proof no longer verified. The
75
+ offline JSON-Schema gate did not catch this (it checks the string, not
76
+ JSON-LD term resolution). Issued credentials now carry a third `@context`
77
+ entry (the official schema pins only the first two and allows extras); the
78
+ context is pinned in the fail-closed allowlist and covered by the drift
79
+ watchdog (#239).
80
+ - security(config): close the bypass in the userinfo guard below: checking
81
+ `urlsplit(...).netloc` only catches a credential whose `@` lands inside the
82
+ authority, and a password holding a `/`, `?` or `#` pushes it out. Both
83
+ layers accepted `https://user:12345/x@host/` — which `did_web_from_url`
84
+ then turned into the issuer id `did:web:user%3A12345:x%40host`, carried by
85
+ every signed credential — and `https://user:secret/x@host/`, which surfaced
86
+ the password through urllib's own `Port could not be cast to integer value
87
+ as 'secret'` (a message the redaction added below could not reach, since
88
+ urllib raises it). Both layers now refuse an `@` or `%40` *anywhere* in a
89
+ URL-shaped value, and `did_web_from_url` wraps the port parse and quotes
90
+ nothing at all. Values with no authority — `[issuer] email`, local paths —
91
+ still accept an `@`. BREAKING (narrow): a `publish_url` whose path contains
92
+ an `@`, e.g. `https://host/@name/`, is now refused; once parsed it is
93
+ indistinguishable from one hiding a credential (#255).
94
+ - security(config): reject a `[issuer]` / `[badge_*]` URL that embeds
95
+ userinfo (`https://user:password@host/`) when the config is loaded. Those
96
+ URLs are public identifiers: `publish_url` is printed by every publish
97
+ command, `urljoin`ed into the ids written to the publicly served
98
+ organization.json / badge.json / key.json (urljoin preserves the
99
+ `user:pass@`), turned into the issuer's did:web, and used to build the
100
+ `credentialStatus` list URLs signed into every OB3 credential — so a
101
+ password there leaked into user-visible output, files served on the open
102
+ web and the badges delivered to recipients alike. Only one narrow path
103
+ guarded against it (did:web derivation, reached solely by
104
+ `[issuer] did = auto`), and that guard echoed the password back in its own
105
+ error for a non-https URL. The check now lives once in
106
+ `ConfParser.read_conf()` — after `${...}` interpolation, so a reference
107
+ cannot smuggle a credential past it — and every consumer (OB1/OB2/OB3
108
+ publishing, DID derivation, status lists) inherits it; the ConfigError
109
+ names the offending section and key but never the value. `did_web_from_url`
110
+ keeps its own guard as defence in depth, with the userinfo check moved
111
+ ahead of the scheme and host checks and their messages redacted. Behaviour
112
+ change: a config that protected a staging host with credentials in a
113
+ published URL no longer loads — drop the userinfo and use an allow-list or
114
+ a token the library never sees. `[smtp] username` / `password` are
115
+ untouched; they never leave the issuer's machine (#252).
116
+ - security(eudi): document and pin the X.509 / eIDAS trust boundary for
117
+ SD-JWT VC verification. All x5c chain validation is delegated to openvc-core
118
+ (path building, temporal validity, SAN<->iss binding); openbadgeslib only
119
+ guarantees the envelope (the x5c header is present, so the anchors are never
120
+ bypassed by a DID fallback — fail-closed). Crucially, certificate revocation
121
+ (CRL/OCSP) is NOT checked by either layer — a revoked-but-unexpired leaf
122
+ still verifies — so a deployment must apply revocation out of band. The
123
+ division of responsibility is spelled out on `verify_badge_sd_jwt` and the
124
+ Security Model wiki page; new boundary tests assert openvc-core rejects an
125
+ expired/not-yet-valid leaf and pin the revocation gap, and the eudi
126
+ floor/latest CI job now runs them so a drift in the delegate's behaviour
127
+ turns the build red (#236).
128
+ - security(ob3): with `--check-status`, verify the status list's OWN JWT-VC
129
+ signature by default and bind its issuer to the badge's, so a compromised
130
+ status host can no longer silently un-revoke a credential. The badge's
131
+ verification key is reused (publish signs the list with it), or the issuer
132
+ DID is resolved. `verify(check_status=True)` gains `verify_status_list=True`
133
+ by default; the verifier CLI gains `--no-verify-status-list` to opt out for
134
+ issuers that serve an unsigned list (#213).
135
+ - security(ob3): `OB3LdpVerifier` fails closed when it has no pinned key and
136
+ the credential's issuer is not a DID — the proof's `verificationMethod`
137
+ cannot be bound to a non-DID issuer, so resolving and trusting it would
138
+ prove internal consistency, not identity (#214).
139
+ - security(ob3): the endorsement validity window fails closed on a malformed
140
+ `validFrom`/`validUntil` (it previously swallowed the parse error and
141
+ accepted the endorsement, unlike every other window) (#218).
142
+ - fix(ob2, ob3): apply a 60-second clock-skew leeway to every validity window
143
+ (vc `validFrom`/`validUntil`, the status list window, OB2 `issuedOn`/
144
+ `expires`), so a slightly-fast issuer clock no longer false-rejects a
145
+ freshly issued credential (#217).
146
+ - perf(ob3, ob1): load the private-key object once per signer instead of
147
+ re-parsing the PEM on every signature (twice per badge on the JWT-VC path).
148
+ An RSA batch of 1000 badges drops from ~86 s to ~1.5 s; the OB1/OB2 JWS
149
+ path reuses the already-loaded key too (#215).
150
+ - perf(baking): build the baked PNG with a single `b''.join` instead of
151
+ quadratic `bytes` concatenation — ~200x faster on images written with many
152
+ small IDAT chunks (Photoshop/libpng), e.g. ~100 ms -> ~0.5 ms for 6.6 MB
153
+ (#216).
154
+ - test: add negative tests for the fail-closed rejection guards that had none
155
+ — OB2 hosted `startsWith`/`allowedOrigins` reject sides, the OB3 status
156
+ anti-gzip-bomb cap and negative index, the LDP verificationMethod binding,
157
+ and a future JWT `nbf` (#219).
158
+ - ci: the nightly conformance job now fails if a 1EdTech validator does not
159
+ come up or if no conformance test actually ran (all-skipped), instead of
160
+ reporting green, and files (or updates) an issue on a scheduled failure
161
+ (#220).
162
+ - ci: install the `[ldp-sd]` extra explicitly and turn the ecdsa-sd-2023
163
+ selective-disclosure test skip into a hard failure under CI, so an
164
+ openvc-core packaging change can no longer silently disable that suite
165
+ (#221).
166
+ - perf(ob3): add an opt-in `CachingDownloader` so verifying a batch of
167
+ credentials from one issuer fetches each did:web document and status list
168
+ once rather than per credential. `OB3LdpVerifier` also memoizes each
169
+ resolved `verificationMethod` per instance, and `OB3Verifier.verify` /
170
+ `check_status` accept a `download=` fetcher to thread it through (#228).
171
+ - perf(ob3): tag the bundled JSON-LD `@context`s `static` in the pyld
172
+ document loader, priming pyld's process-global resolved-context cache so
173
+ term definitions are not rebuilt on every canonicalization (~1.6x faster
174
+ Data Integrity sign/verify); per-call `extra_contexts` stay untagged so a
175
+ changing context is never served stale (#229).
176
+ - feat: extract a programmatic verification facade — `openbadgeslib.verify`
177
+ with `verify_badge(data, ob_version, *, pubkey_pem | resolve_did,
178
+ expected_recipient, check_status, …) -> VerifyResult` (fields `valid`,
179
+ `trusted`, and the decoded `credential`/`assertion`). It runs the whole
180
+ verification — token extraction, JWT-VC/LDP auto-detection, issuer-DID
181
+ resolution, trust classification, signature/status — doing no I/O; an
182
+ invalid badge is a result, not an exception. `openbadges-verifier` is now a
183
+ thin presenter over it (byte-identical output). Part of #222.
184
+ - feat: extract a programmatic publication facade —
185
+ `openbadgeslib.ob3.publish_ob3(conf, output, *, revoke | suspend |
186
+ unsuspend, reason, badge, check_live) -> PublishResult`. It runs the OB3
187
+ issuer-artefact regeneration (the did:web document, the signed Bitstring
188
+ Status List credentials, the opt-in SD-JWT VC Type Metadata) and the
189
+ status-registry revoke/suspend/unsuspend transaction — writing the
190
+ artefacts but doing no printing — and raises `PublishError` /
191
+ `CredentialNotFound` / `AmbiguousCredential` instead of exiting.
192
+ `openbadges-publish -V 3` is now a thin presenter over it (byte-identical
193
+ output). Completes #222.
194
+ - fix(issue): move the version-neutral badge config model (`Badge`,
195
+ `BadgeImgType`) out of the legacy `ob1` package into
196
+ `openbadgeslib.badge_model` (re-exported from `ob1.badge` for
197
+ compatibility). `create_from_conf` now reads the OpenBadges 1.0-only
198
+ `verify_key`/`criteria` keys optionally, so issuing an OB3 badge that omits
199
+ them no longer blows up with a raw `KeyError('verify_key')`; a missing
200
+ required key surfaces as `IssuanceError` through the `issue_*` entry points,
201
+ and a missing image raises a typed error instead of `print` + bare
202
+ `IOError` (#223).
203
+ - refactor(errors): consolidate the exception hierarchy so a single
204
+ `except LibOpenBadgesException` traps every library error. `IssuanceError`
205
+ and `DecompressionLimitExceeded` are re-anchored under the root (still
206
+ importable from `openbadgeslib.issue` / `openbadgeslib.baking`); the config
207
+ helpers' bare `ValueError`s become a new `ConfigError` and the signers'
208
+ unsupported-algorithm error becomes `UnsupportedAlgorithm` — both also
209
+ subclass `ValueError` so `except ValueError` callers keep working.
210
+ `_write_badge_and_log` reads the `[logs]` path inside its `try`, so a missing
211
+ `[logs]` section is reported instead of crashing with a traceback after the
212
+ badge is already on disk. Adds singular aliases (`KeyGenException`,
213
+ `SignerException`, `VerifierException`) for the plural base families (#224).
214
+ - refactor(ob2, ob3): deduplicate the shared JSON helpers (`_iso`,
215
+ `_parse_iso`, `_as_dict`, `_require`) into an internal
216
+ `openbadgeslib._jsonmodel`. The two copies had drifted: OB2's `_iso`
217
+ serialised a naive datetime as *local* time (`astimezone` on a naive value)
218
+ while OB3 anchored it to UTC, so the same timestamp emitted a different
219
+ instant in OB2 than in OB3; the single implementation keeps OB3's
220
+ naive=>UTC rule (#225).
221
+ - feat(confparser): resolve config through typed dataclasses with centralized
222
+ defaults. `load_config(path) -> ConfigParser` is the library entry point
223
+ (raises `ConfigError`); `read_config_or_exit` is now a thin CLI wrapper over
224
+ it (and the flagship example uses `load_config`). New `IssuerConfig` /
225
+ `BadgeSectionConfig` dataclasses (extending the `OB3StatusConfig` pattern)
226
+ resolve the `[issuer]` and badge sections and their defaults once (issuer id,
227
+ `criteria_narrative`->`criteria` fallback, hosted-assertions base), and a
228
+ single `DEFAULT_KEY_TYPE` + `resolve_key_type()` centralises the key_type
229
+ name->KeyType mapping the keygenerator used to inline (#225).
230
+ - security(ob3): warn loudly when `openbadges-publish -V 3` regenerates a
231
+ revocable badge's status lists without a `validUntil` bound (no
232
+ `status_validity_days` set). An unbounded list has no anti-replay freshness —
233
+ an authentic older copy with fewer revocations passes the window unchecked,
234
+ so a revoked credential could be silently resurrected. `PublishResult` gains
235
+ a `no_validity_bound` list (surfaced in the CLI's human output and `--json`),
236
+ and the config example documents the republication cadence (#227).
237
+ - security(ob3): SD-JWT VC (EUDI) issuance no longer silently drops a
238
+ credential's `credentialStatus`. Because SD-JWT VC status carriage/checking
239
+ is not wired yet (both verify paths hard-set `require_status=False`), an
240
+ SD-JWT VC badge is irrevocable; `issue_badge_sd_jwt` now rejects a credential
241
+ that carries a `credentialStatus` with a clear `EudiError` (before requiring
242
+ the [eudi] extra) rather than issue a badge the issuer believes is revocable.
243
+ `badge_to_sd_jwt_claims` also carries `credentialSubject.identifier` (the
244
+ hashed recipient identities), so a badge whose subject travelled via
245
+ `identifier` (no `id`) keeps a checkable recipient. The irrevocability is
246
+ documented prominently on the issue/verify docstrings (#226).
247
+ - security(util): pin the SSRF-validated IP on the HTTPS connection to defeat
248
+ DNS rebinding. `_assert_public_host` validated the resolved IPs, but
249
+ `opener.open` then re-resolved on its own, so a hostile DNS (TTL 0) could
250
+ answer public during the check and internal (169.254.169.254 / loopback /
251
+ RFC1918) at the real connect. A new `_PinnedHTTPSConnection` re-resolves,
252
+ re-validates and dials the validated IP in one step (keeping the hostname
253
+ for TLS SNI / certificate verification and the Host header) — for the initial
254
+ request and every redirect. Also adds a wall-clock download budget
255
+ (`MAX_DOWNLOAD_SECONDS`, 60 s), since the per-socket `timeout=30` bounds each
256
+ read but not a slow-drip response's total time (#231).
257
+ - ci: harden the release-critical workflows. SHA-pin the actions that run with
258
+ `id-token: write` — `pypa/gh-action-pypi-publish` (PyPI Trusted Publishing)
259
+ and the GitHub Pages `upload-pages-artifact` / `deploy-pages` — so a
260
+ compromised upstream tag cannot publish or deploy under our OIDC identity
261
+ (Dependabot already tracks github-actions and bumps the pins). Add a
262
+ least-privilege `permissions: contents: read` to commit-lint.yml. Add a
263
+ `windows-latest` CI leg plus a monkeypatched test of the `fcntl=None`
264
+ StatusRegistry lock fallback, so the "OS Independent" classifier is actually
265
+ exercised on the non-POSIX path. The leg immediately caught a real
266
+ portability bug it fixes: `openbadges-publish` now records `files_written`
267
+ with forward slashes (a Windows `\` would have broken the artefact URLs built
268
+ via `urljoin`); the POSIX file-mode assertions skip on Windows and the
269
+ ldp-sd hard-fail guard keys on an explicit `OPENBADGES_REQUIRE_LDP_SD` flag
270
+ (set by the main test job) so the core-only Windows leg skips it (#230).
271
+ - refactor: declare the stable public surface (freeze prep for v4). The root
272
+ package gains an explicit `__all__` (the "public contract"), re-exports
273
+ `KeyEd25519` (the recommended OB 3.0 / LDP key) alongside `KeyRSA`/`KeyECC`,
274
+ and exposes the `ob2`/`ob3`/`errors` subpackages and the
275
+ `issue_from_conf`/`verify_badge` facades by name. `openbadgeslib.ob3`
276
+ re-exports the `eudi` SD-JWT VC track (its heavy imports stay lazy).
277
+ `SignResult.credential`/`.assertion` and the `_Ob3Context` fields are typed
278
+ with `TYPE_CHECKING` forward refs instead of `Any` (zero runtime cost,
279
+ correct autocompletion / pdoc). Adds a "Stable API" wiki page documenting
280
+ the stable-vs-internal boundary (#232).
281
+ - build(deps): the [dev] extra depends on gitlint-core instead of the gitlint
282
+ meta-package, so it stops installing vulnerable pins. `gitlint` is an empty
283
+ shim for `gitlint-core[trusted-deps]`, and that extra hard-pins click==8.1.3
284
+ and sh==1.14.3 — PYSEC-2026-2132 (command injection in `click.edit()`, fixed
285
+ in 8.3.3) and CVE-2026-54552 (incomplete privilege drop in sh's `_uid`, fixed
286
+ in 2.2.4), which had been failing the weekly Audit job. Neither is reachable
287
+ here — gitlint never calls `click.edit()`, and it does not import `sh` at all
288
+ unless GITLINT_USE_SH_LIB=1, which this repo never sets — but upstream has
289
+ been dormant since 2023, so the pins would keep the audit red indefinitely.
290
+ gitlint-core declares the same dependencies loosely and provides the same
291
+ `gitlint` console script; the [dev] extra now floors `click>=8.3.3` and
292
+ `sh>=2.2.4` (keeping gitlint-core's non-Windows marker for sh) and caps both
293
+ below the next major. No pip-audit ignores were added: the advisories are
294
+ resolved by upgrading, not suppressed. Library users are unaffected — click
295
+ and sh were only ever development dependencies.
296
+ - build(deps)!: raise the PyJWT floor to >=2.13 and the openvc-core floors to
297
+ >=1.21. The OB3 JWT-VC verifier decodes an untrusted badge with a
298
+ signature-verifying `jwt.decode()`, so the PyJWT 2.12/2.13 advisory batch is
299
+ on a reachable path: RFC 7515 §4.1.11 `crit` validation (2.12.0,
300
+ CVE-2026-32597) and the b64=false unencoded-payload DoS (2.13.0,
301
+ CVE-2026-48525), plus JWK/alg-binding hardening; 2.13.0 is the minimal floor
302
+ carrying all of them. Installing [eudi]/[ldp-sd] already pulled a safe PyJWT
303
+ transitively via openvc-core (whose own floor is >=2.13), so this closes the
304
+ gap for a base, extras-free install. The native `_jws` engine is structurally
305
+ immune (it never calls `jwt.decode`). The [eudi]/[ldp-sd] openvc-core floors
306
+ move from >=1.18 / >=1.13 to >=1.21 per the pin's stated policy (floor at the
307
+ version the suite is tested against): 1.21 keeps in-floor the delegated
308
+ SD-JWT fixes openbadgeslib relies on — the key-binding replay fix that now
309
+ requires a nonce AND audience (1.19.3) and unknown-`crit` rejection on the
310
+ SD-JWT lane (1.20.4). No openbadgeslib code changed: openvc's new typed
311
+ ecdsa-sd errors (ProofValueMalformed/ProofMalformed) subclass ProofError, so
312
+ the existing `except ProofError` in ob3/ldp.py still catches them; the full
313
+ suite passes against openvc-core 1.21.0. BREAKING only for a caller that
314
+ pinned PyJWT <2.13 or openvc-core <1.21 alongside openbadgeslib.
315
+ - security(cli): `openbadges-init` creates config.ini owner-only (0600). It
316
+ is the file that goes on to hold the SMTP password and the paths to the
317
+ private keys, but the copy ran outside the umask block that hardens the
318
+ directories, so it inherited the caller's umask — 0644 on a default shell.
319
+ The 0700 parent contained it; this is the second lock, matching the one
320
+ the keygenerator puts on the private key.
321
+
322
+
7
323
  * v3.14.0 - 2026-07-10
8
324
 
9
325
  A security & correctness hardening pass (audit-bot). Highlights:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openbadgeslib
3
- Version: 3.14.0
3
+ Version: 4.0.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
@@ -30,22 +30,24 @@ Requires-Python: >=3.10
30
30
  Description-Content-Type: text/markdown
31
31
  License-File: LICENSE.txt
32
32
  Requires-Dist: pypng>=0.20220715.0
33
- Requires-Dist: PyJWT[crypto]>=2.8
33
+ Requires-Dist: PyJWT[crypto]>=2.13
34
34
  Requires-Dist: cryptography>=42
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.18; extra == "eudi"
39
+ Requires-Dist: openvc-core<2,>=1.21; extra == "eudi"
40
40
  Provides-Extra: ldp-sd
41
- Requires-Dist: openvc-core[data-integrity]<2,>=1.13; extra == "ldp-sd"
41
+ Requires-Dist: openvc-core[data-integrity]<2,>=1.21; extra == "ldp-sd"
42
42
  Provides-Extra: dev
43
43
  Requires-Dist: pytest>=8.0; extra == "dev"
44
44
  Requires-Dist: pytest-cov>=5.0; extra == "dev"
45
45
  Requires-Dist: flake8>=7.0; extra == "dev"
46
46
  Requires-Dist: pdoc>=14; extra == "dev"
47
47
  Requires-Dist: mypy>=1.8; extra == "dev"
48
- Requires-Dist: gitlint>=0.19; extra == "dev"
48
+ Requires-Dist: gitlint-core>=0.19; extra == "dev"
49
+ Requires-Dist: click<9,>=8.3.3; extra == "dev"
50
+ Requires-Dist: sh<3,>=2.2.4; sys_platform != "win32" and extra == "dev"
49
51
  Requires-Dist: jsonschema[format-nongpl]>=4.20; extra == "dev"
50
52
  Dynamic: license-file
51
53
 
@@ -23,6 +23,13 @@
23
23
 
24
24
  from typing import Any
25
25
 
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
32
+
26
33
  # ── OpenBadges 2.0 (strict) ──────────────────────────────────────────────────
27
34
  from .ob2 import ( # noqa: F401
28
35
  OB2Signer, OB2Verifier, OB2VerificationError,
@@ -35,17 +42,26 @@ from .ob3 import ( # noqa: F401
35
42
  )
36
43
 
37
44
  # ── Shared utilities ────────────────────────────────────────────────────────────
38
- from .keys import KeyFactory, KeyRSA, KeyECC # noqa: F401
45
+ # KeyEd25519 is the recommended key type for OB 3.0 / LDP (eddsa-rdfc-2022).
46
+ from .keys import KeyFactory, KeyRSA, KeyECC, KeyEd25519 # noqa: F401
39
47
  from .util import __version__ # noqa: F401
40
48
 
49
+ # The OB1 legacy name table, shared with openbadgeslib.ob1 (single source,
50
+ # #235). Importing it is cheap and does not pull in the ob1 subpackage, so a
51
+ # bare `import openbadgeslib` stays ob1-free (see __getattr__ below).
52
+ from ._ob1_api import OB1_API as _OB1_API # noqa: F401
53
+
41
54
 
42
- # ── Issuance API ─────────────────────────────────────────────────────────────
43
- # "Issue badge X to Y per config" as a library call — the orchestration the CLI
44
- # wraps, returning a SignResult instead of writing files (openbadgeslib.issue).
45
- # Resolved lazily (PEP 562, below) because openbadgeslib.issue pulls in the
46
- # shared Badge model from the ob1 leaf module; a bare `import openbadgeslib`
47
- # must not drag that in. `from openbadgeslib.issue import ...` works directly.
48
- _ISSUE_API = ('IssuanceError', 'SignResult', 'issue_from_conf')
55
+ # ── Issuance / verification API ──────────────────────────────────────────────
56
+ # "Issue badge X to Y per config" / "verify this badge" as library calls — the
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
+ }
49
65
 
50
66
 
51
67
  # ── OpenBadges 1.0 (legacy) ──────────────────────────────────────────────────
@@ -54,22 +70,17 @@ _ISSUE_API = ('IssuanceError', 'SignResult', 'issue_from_conf')
54
70
  # OpenBadges 1.0 surface: accessing one emits a DeprecationWarning steering new
55
71
  # work to openbadgeslib.ob2 / openbadgeslib.ob3. OB 1.0 itself remains supported
56
72
  # (no removal planned). They are resolved lazily from the ob1 leaf modules (PEP
57
- # 562), so a bare `import openbadgeslib` neither warns nor drags in the ob1
58
- # package.
59
- _OB1_API = {
60
- 'Signer': 'signer', 'Verifier': 'verifier', 'VerifyInfo': 'verifier',
61
- 'Badge': 'badge', 'BadgeSigned': 'badge', 'Assertion': 'badge',
62
- 'BadgeStatus': 'badge', 'BadgeImgType': 'badge', 'BadgeType': 'badge',
63
- 'extract_svg_assertion': 'badge', 'extract_png_assertion': 'badge',
64
- }
73
+ # 562) using the shared _OB1_API table imported above, so a bare
74
+ # `import openbadgeslib` neither warns nor drags in the ob1 package.
65
75
 
66
76
 
67
77
  def __getattr__(name: str) -> Any:
68
78
  if name in _ISSUE_API:
69
- # The modern issuance API — lazy so a bare import stays ob1-free, but
70
- # warning-free (unlike the OB1 names below).
79
+ # The modern issuance / verification API — lazy so a bare import stays
80
+ # lightweight, but warning-free (unlike the OB1 names below).
71
81
  import importlib
72
- return getattr(importlib.import_module('.issue', __name__), name)
82
+ return getattr(
83
+ importlib.import_module('.' + _ISSUE_API[name], __name__), name)
73
84
  module = _OB1_API.get(name)
74
85
  if module is None:
75
86
  raise AttributeError(
@@ -83,3 +94,24 @@ def __getattr__(name: str) -> Any:
83
94
  'the "OpenBadges 1.0 lifecycle" wiki page.' % name,
84
95
  DeprecationWarning, stacklevel=2)
85
96
  return getattr(importlib.import_module('.ob1.' + module, __name__), name)
97
+
98
+
99
+ # The stable, supported top-level surface (the "public contract" — see the
100
+ # "Stable API vs internals" wiki/doc page). Everything else (the ob1.* legacy
101
+ # 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 *`.
104
+ __all__ = [
105
+ '__version__',
106
+ # Subpackage entry points.
107
+ 'ob2', 'ob3', 'errors',
108
+ # OpenBadges 2.0 (strict).
109
+ 'OB2Signer', 'OB2Verifier', 'OB2VerificationError',
110
+ # OpenBadges 3.0.
111
+ 'OB3Signer', 'OB3Verifier', 'OB3VerificationError',
112
+ 'OpenBadgeCredential', 'Achievement', 'Issuer',
113
+ # Keys (KeyEd25519 is the recommended OB 3.0 / LDP key type).
114
+ 'KeyFactory', 'KeyRSA', 'KeyECC', 'KeyEd25519',
115
+ # Programmatic issue / verify facades (resolved lazily).
116
+ 'issue_from_conf', 'verify_badge', 'IssuanceError', 'SignResult',
117
+ ]
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ OpenBadges Library
4
+
5
+ Copyright (c) 2014-2026, Luis González Fernández, luisgf@luisgf.es
6
+ Copyright (c) 2014-2026, Jesús Cea Avión, jcea@jcea.es
7
+
8
+ All rights reserved.
9
+
10
+ This library is free software; you can redistribute it and/or
11
+ modify it under the terms of the GNU Lesser General Public
12
+ License as published by the Free Software Foundation; either
13
+ version 3.0 of the License, or (at your option) any later version.
14
+
15
+ This library is distributed in the hope that it will be useful,
16
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
17
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
18
+ Lesser General Public License for more details.
19
+
20
+ You should have received a copy of the GNU Lesser General Public
21
+ License along with this library.
22
+ """
23
+
24
+ # Shared JSON <-> dataclass helpers for the OB2 and OB3 models. These lived in
25
+ # duplicate in openbadgeslib.ob2.models and openbadgeslib.ob3.credential and had
26
+ # drifted: the OB2 copy of ``_iso`` ran ``astimezone`` on a *naive* datetime,
27
+ # which Python interprets as **local** time — so the same value serialised to a
28
+ # different instant in OB2 than in OB3 (which anchors naive datetimes to UTC).
29
+ # This module keeps the OB3 semantics (naive => UTC) as the single source of
30
+ # truth, matching ob3.status_registry._iso_z and the JWT nbf/exp claims.
31
+
32
+ from datetime import datetime, timezone
33
+ from typing import Any
34
+
35
+
36
+ def _iso(dt: datetime) -> str:
37
+ """Return a datetime as an ISO 8601 string with a ``Z`` (UTC) suffix.
38
+
39
+ A naive datetime is assumed to be UTC (not local time), so validFrom/
40
+ validUntil and OB2 issuedOn/expires never silently shift by the host's
41
+ offset and stay consistent with the JWT nbf/exp claims.
42
+ """
43
+ if dt.tzinfo is None:
44
+ dt = dt.replace(tzinfo=timezone.utc)
45
+ return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
46
+
47
+
48
+ def _parse_iso(value: Any, where: str = 'date') -> datetime:
49
+ """Parse an ISO 8601 timestamp (with a UTC offset or trailing ``Z``).
50
+
51
+ A non-string (e.g. a bare Unix timestamp, the legacy OB 1.0 shape) is
52
+ rejected, as is a naive timestamp with no offset — verify() always compares
53
+ against an aware ``datetime.now(timezone.utc)``, so only unambiguously
54
+ anchored timestamps are accepted. Raises ValueError otherwise, naming
55
+ *where* the bad value came from.
56
+ """
57
+ if not isinstance(value, str):
58
+ raise ValueError("%s must be an ISO 8601 string, got %r" % (where, value))
59
+ try:
60
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
61
+ except ValueError as exc:
62
+ raise ValueError("invalid ISO 8601 date in %s: %r" % (where, value)) from exc
63
+ if dt.tzinfo is None:
64
+ raise ValueError("%s is missing a UTC offset: %r" % (where, value))
65
+ return dt
66
+
67
+
68
+ def _as_dict(value: Any, where: str) -> dict[str, Any]:
69
+ """Return *value* if it is a dict, else raise a clear ValueError."""
70
+ if not isinstance(value, dict):
71
+ raise ValueError("%s must be a JSON object" % where)
72
+ return value
73
+
74
+
75
+ def _require(data: dict[str, Any], key: str, where: str) -> str:
76
+ """Return ``data[key]`` as a non-empty string, raising a clear ValueError if
77
+ it is missing, empty, or not a string. All fields validated here are
78
+ identifiers consumed as strings downstream (e.g. recipient binding calls
79
+ ``.lower()``), so a non-string value must be rejected rather than crash
80
+ later."""
81
+ value = data.get(key)
82
+ if value is None or value == "":
83
+ raise ValueError("missing required field %s.%s" % (where, key))
84
+ if not isinstance(value, str):
85
+ raise ValueError("field %s.%s must be a string" % (where, key))
86
+ return value
@@ -8,9 +8,46 @@ from .exceptions import SignatureError, MissingKey, MissingSigner, MissingVerifi
8
8
  from jwt.algorithms import RSAAlgorithm, ECAlgorithm, OKPAlgorithm
9
9
  from jwt.exceptions import InvalidKeyError
10
10
 
11
+ from cryptography.hazmat.primitives.asymmetric import ec, rsa
12
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
13
+ Ed25519PrivateKey, Ed25519PublicKey)
14
+
11
15
  from ..keys import KeyType, detect_key_type, key_to_pem
12
16
  from ..errors import UnknownKeyType
13
17
 
18
+ # Key objects PyJWT's ``prepare_key`` accepts as-is (its own supported backends).
19
+ # A live one of these can be handed straight to prepare_key, skipping a PEM
20
+ # round-trip; a PEM str/bytes passes through too. Anything else falls to
21
+ # key_to_pem, which since 4.0.0 accepts only cryptography objects and PEM and so
22
+ # rejects it (UnknownKeyType) — including the pycryptodome / python-ecdsa
23
+ # objects PyJWT never recognised.
24
+ _CRYPTOGRAPHY_KEY_TYPES = (
25
+ rsa.RSAPrivateKey, rsa.RSAPublicKey,
26
+ ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey,
27
+ Ed25519PrivateKey, Ed25519PublicKey,
28
+ )
29
+
30
+
31
+ def _prepare_key_arg(key: Any) -> Any:
32
+ """Return the value to feed a PyJWT algorithm's ``prepare_key``.
33
+
34
+ ``prepare_key`` accepts a PEM (str/bytes) *and* a live ``cryptography`` key
35
+ object directly, returning the latter untouched. Passing an already-loaded
36
+ object straight through skips a ``key_to_pem`` re-serialisation plus a
37
+ ``load_pem_private_key`` re-parse on every sign: the OB1 path hands in
38
+ ``Badge.priv_key`` — a cryptography object loaded once at construction — so
39
+ round-tripping it back to a PEM here only to re-parse it wasted ~45 ms per
40
+ RSA badge (#215). A PEM str/bytes passes through unchanged; anything else
41
+ falls to ``key_to_pem``, which since 4.0.0 accepts only cryptography objects
42
+ and PEM (a live pycryptodome/python-ecdsa object now raises UnknownKeyType).
43
+ """
44
+ if isinstance(key, (bytes, str)):
45
+ return key
46
+ if isinstance(key, _CRYPTOGRAPHY_KEY_TYPES):
47
+ return key
48
+ return key_to_pem(key)
49
+
50
+
14
51
  # Each entry is (algorithm class, hash id). EdDSA's OKPAlgorithm takes no hash
15
52
  # argument — its hash id is None and _algo_for constructs it with no args.
16
53
  _ALGORITHMS = {
@@ -65,7 +102,7 @@ def sign(header_dict: Dict[str, Any], payload_dict: Dict[str, Any], key: Any) ->
65
102
  signing_input = utils.encode(header_dict) + b'.' + utils.encode(payload_dict)
66
103
  algo = _algo_for(alg_name)
67
104
  try:
68
- prepared = algo.prepare_key(key_to_pem(key))
105
+ prepared = algo.prepare_key(_prepare_key_arg(key))
69
106
  return cast(bytes, algo.sign(signing_input, prepared))
70
107
  except (InvalidKeyError, ValueError) as exc:
71
108
  raise SignatureError(str(exc)) from exc
@@ -121,7 +158,7 @@ def verify_block(msg: Union[str, bytes], key: Optional[Any] = None) -> bool:
121
158
 
122
159
  algo = _algo_for(alg_name)
123
160
  try:
124
- prepared = algo.prepare_key(key_to_pem(key))
161
+ prepared = algo.prepare_key(_prepare_key_arg(key))
125
162
  valid = algo.verify(signing_input, prepared, raw_sig)
126
163
  except (InvalidKeyError, ValueError, AttributeError, TypeError) as exc:
127
164
  # A private key served where a public key is expected reaches