openbadgeslib 3.5.0__tar.gz → 3.6.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 (123) hide show
  1. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/Changelog.txt +64 -0
  2. {openbadgeslib-3.5.0/openbadgeslib.egg-info → openbadgeslib-3.6.0}/PKG-INFO +1 -1
  3. openbadgeslib-3.6.0/SECURITY.md +54 -0
  4. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/_jws/__init__.py +2 -2
  5. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/baking.py +4 -4
  6. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/keys.py +12 -12
  7. openbadgeslib-3.6.0/openbadgeslib/logs.py +41 -0
  8. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob1/badge.py +5 -5
  9. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob1/verifier.py +2 -2
  10. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob2/models.py +36 -17
  11. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob2/verifier.py +3 -3
  12. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob3/contexts/__init__.py +10 -6
  13. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob3/credential.py +60 -17
  14. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob3/did.py +17 -7
  15. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob3/eudi.py +5 -5
  16. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob3/ldp.py +13 -13
  17. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob3/signer.py +4 -4
  18. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob3/status.py +2 -2
  19. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob3/status_list.py +3 -3
  20. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob3/status_registry.py +6 -6
  21. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob3/verifier.py +12 -3
  22. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/openbadges_keygenerator.py +16 -11
  23. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/openbadges_publish.py +15 -3
  24. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/openbadges_signer.py +18 -15
  25. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/util.py +1 -1
  26. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0/openbadgeslib.egg-info}/PKG-INFO +1 -1
  27. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib.egg-info/SOURCES.txt +1 -0
  28. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/pyproject.toml +8 -6
  29. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_ob2_models.py +13 -0
  30. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_ob3_credential.py +24 -0
  31. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_ob3_did.py +6 -0
  32. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_ob3_ldp_sign.py +3 -0
  33. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_ob3_signer_cli_ldp.py +4 -3
  34. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_ob3_verifier.py +59 -0
  35. openbadgeslib-3.5.0/openbadgeslib/logs.py +0 -85
  36. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/LICENSE.txt +0 -0
  37. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/MANIFEST.in +0 -0
  38. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/README.md +0 -0
  39. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/docs/README.md +0 -0
  40. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/__init__.py +0 -0
  41. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/_jws/exceptions.py +0 -0
  42. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/_jws/utils.py +0 -0
  43. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/badge.py +0 -0
  44. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/config.ini.example +0 -0
  45. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/confparser.py +0 -0
  46. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/errors.py +0 -0
  47. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/mail.py +0 -0
  48. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob1/__init__.py +0 -0
  49. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob1/signer.py +0 -0
  50. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob2/__init__.py +0 -0
  51. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob2/signer.py +0 -0
  52. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob3/__init__.py +0 -0
  53. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
  54. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
  55. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
  56. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
  57. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
  58. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
  59. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/openbadges_init.py +0 -0
  60. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/openbadges_verifier.py +0 -0
  61. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/py.typed +0 -0
  62. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/signer.py +0 -0
  63. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib/verifier.py +0 -0
  64. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib.egg-info/dependency_links.txt +0 -0
  65. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib.egg-info/entry_points.txt +0 -0
  66. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib.egg-info/requires.txt +0 -0
  67. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/openbadgeslib.egg-info/top_level.txt +0 -0
  68. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/setup.cfg +0 -0
  69. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/config1.ini +0 -0
  70. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/conformance/Dockerfile.ob3 +0 -0
  71. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/conformance/README.md +0 -0
  72. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/conformance/conftest.py +0 -0
  73. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/conformance/docker-compose.yml +0 -0
  74. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/conformance/test_official_validators.py +0 -0
  75. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/conftest.py +0 -0
  76. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/fixtures/ob_schemas/README.md +0 -0
  77. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
  78. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
  79. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/fixtures/ob_schemas/refresh.sh +0 -0
  80. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/fixtures/vc_di_eddsa/README.md +0 -0
  81. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
  82. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
  83. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
  84. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
  85. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
  86. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/images/sample1.png +0 -0
  87. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/images/sample1.svg +0 -0
  88. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/images/userimage01.svg +0 -0
  89. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/ldp_helpers.py +0 -0
  90. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/logo Python Espan/314/203a.svg" +0 -0
  91. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/logo Python Espa/303/261a.svg" +0 -0
  92. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/runtests.sh +0 -0
  93. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_cli_json.py +0 -0
  94. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_cli_smoke.py +0 -0
  95. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_confparser.py +0 -0
  96. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_docs.py +0 -0
  97. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_eddsa.py +0 -0
  98. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_jws.py +0 -0
  99. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_key_operation.py +0 -0
  100. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_ob1_badge_io.py +0 -0
  101. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_ob1_signer.py +0 -0
  102. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_ob1_verifier.py +0 -0
  103. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_ob2_cli.py +0 -0
  104. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_ob2_signer.py +0 -0
  105. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_ob2_verifier.py +0 -0
  106. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_ob3_conformance_schema.py +0 -0
  107. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_ob3_contexts.py +0 -0
  108. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_ob3_did_doc.py +0 -0
  109. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_ob3_eudi_sd_jwt.py +0 -0
  110. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_ob3_ldp.py +0 -0
  111. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_ob3_ldp_cli.py +0 -0
  112. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_ob3_publish_cli.py +0 -0
  113. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_ob3_signer.py +0 -0
  114. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_ob3_status.py +0 -0
  115. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_ob3_status_list.py +0 -0
  116. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_ob3_status_registry.py +0 -0
  117. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_sign_ecc.pem +0 -0
  118. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_sign_rsa.pem +0 -0
  119. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_util.py +0 -0
  120. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_verify_ecc.pem +0 -0
  121. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/test_verify_rsa.pem +0 -0
  122. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/withoutxmlheader.svg +0 -0
  123. {openbadgeslib-3.5.0 → openbadgeslib-3.6.0}/tests/withxmlheader.svg +0 -0
@@ -4,6 +4,70 @@ OpenBadgesLib - Changelog
4
4
  Newest first. Dates are ISO 8601 (YYYY-MM-DD).
5
5
 
6
6
 
7
+ * v3.6.0 - 2026-07-08
8
+
9
+ - feat(ob3): the verifier now exposes the raw, already-validated VC document
10
+ on the returned credential (`credential.raw`) — on both the JWT-VC and the
11
+ Data Integrity path — so an integrator can read spec fields the dataclass
12
+ does not map (alignment, results, multiple evidence, endorsements, …)
13
+ without re-parsing the token. It is None for an in-memory credential built
14
+ to issue, and excluded from equality/repr so it never affects comparisons.
15
+
16
+ - feat(ob3): issued OB 3.0 credentials now carry a `credentialSchema` entry
17
+ (`1EdTechJsonSchemaValidator2019`) pointing at 1EdTech's published
18
+ AchievementCredential JSON Schema, so validators and wallets can
19
+ self-check them. The offline conformance gate proves our issued
20
+ credentials satisfy that exact schema.
21
+
22
+ - refactor(typing): the library now type-checks clean under `mypy --strict`
23
+ (73 bare `dict` annotations parameterised as `dict[str, Any]`, 20 Any
24
+ returns narrowed with `typing.cast`), and `strict = true` is enabled in
25
+ the mypy config so CI enforces it. This makes the README's long-standing
26
+ `mypy --strict` claim true and improves the shipped `py.typed` annotations
27
+ for downstream type-checkers. Annotations only — no runtime change.
28
+
29
+
30
+ * v3.5.1 - 2026-07-07
31
+
32
+ - fix(ob3): a naive datetime passed as the issuance/expiration date is now
33
+ assumed UTC (matching status_registry._iso_z), not local time — so
34
+ validFrom/validUntil no longer silently shift by the host's offset and
35
+ stay consistent with the nbf/exp JWT claims.
36
+
37
+ - fix(ob3): the JWT verifier now cross-checks the `jti` claim against the
38
+ credential `id` (OB3 §8.2 binds them). A validly signed token whose jti
39
+ disagrees with — or omits — the credential id is rejected, alongside the
40
+ existing iss/nbf/sub checks.
41
+
42
+ - fix(ob3): `_b58btc_decode` bounds its input to 128 characters before the
43
+ O(n^2) base58 decode, so a hostile did:key / publicKeyMultibase in a
44
+ fetched did:web or Data Integrity document (up to 5 MiB / 256 KiB) can no
45
+ longer burn CPU. No real multikey exceeds ~70 characters.
46
+
47
+ - fix(ob2): an `evidence` array (which OB 2.0 permits) is preserved when
48
+ parsing an Assertion instead of being silently dropped — `_iri_or_none`
49
+ returned None for a list. A single value still collapses to its IRI.
50
+
51
+ - fix(cli): `openbadges-signer -M/--mail-badge` now prints a clear notice
52
+ on the default OB3 path (it was a silent no-op there); the stale
53
+ `openbadges-keygenerator -t ED25519` hint on the LDP path is corrected to
54
+ the real `key_type = ED25519` config key; and the OB1 mail path reads its
55
+ `[smtp]` config inside the try, so a missing `[smtp]` section is reported
56
+ cleanly after signing instead of raising a traceback.
57
+
58
+ - fix(publish): `openbadges-publish -V 3` no longer aborts mid-loop when a
59
+ badge has an unreadable key or corrupt registry — it skips that badge
60
+ with a notice (like the did.json path already did), regenerates the
61
+ others, and exits non-zero with a summary. An urgent revocation is no
62
+ longer masked by an unrelated half-configured badge.
63
+
64
+ - refactor(keygen): `openbadges-keygenerator` uses `enable_debug_logging`
65
+ instead of the legacy `Logger` class (now removed from logs.py), so key
66
+ generation no longer requires the `[paths]`/`[logs]` config sections just
67
+ to emit console lines (they raised a raw KeyError/FileNotFoundError), and
68
+ a missing `[issuer] name` degrades gracefully.
69
+
70
+
7
71
  * v3.5.0 - 2026-07-07
8
72
 
9
73
  - fix(status): serialise status-registry writes with an exclusive
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openbadgeslib
3
- Version: 3.5.0
3
+ Version: 3.6.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
@@ -0,0 +1,54 @@
1
+ # Security Policy
2
+
3
+ `openbadgeslib` issues and verifies cryptographically-signed credentials, so we
4
+ take reports seriously and aim to respond quickly.
5
+
6
+ ## Supported versions
7
+
8
+ Security fixes land in a new release on the latest minor line and are published
9
+ to [PyPI](https://pypi.org/project/openbadgeslib/). Always run the latest
10
+ release; older versions are not patched in place.
11
+
12
+ | Version | Supported |
13
+ | -------------- | ----------------------------- |
14
+ | Latest release | ✅ security fixes |
15
+ | Older releases | ❌ upgrade to the latest |
16
+
17
+ ## Reporting a vulnerability
18
+
19
+ **Please do not open a public issue for a suspected vulnerability.** Report it
20
+ privately instead:
21
+
22
+ - **Preferred:** GitHub private vulnerability reporting — the **“Report a
23
+ vulnerability”** button under this repository’s
24
+ [Security tab](https://github.com/luisgf/openbadgeslib/security/advisories).
25
+ - **Email:** `luisgf@luisgf.es` (you may encrypt to the same address’s public
26
+ key if you have it).
27
+
28
+ Please include enough to reproduce: the affected version, a minimal example or
29
+ proof of concept, and the impact you observed. If it touches signature
30
+ verification, revocation, DID/key resolution, or the handling of untrusted
31
+ input (downloaded documents, baked images, status lists), say so — those are the
32
+ highest-priority areas.
33
+
34
+ ## What to expect
35
+
36
+ - Acknowledgement of your report, normally within a few days.
37
+ - An assessment of severity and scope, and a fix or mitigation plan.
38
+ - Coordinated disclosure: we will agree a timeline with you and credit you in
39
+ the release notes and advisory unless you prefer to stay anonymous.
40
+ - A patched release on PyPI and a published GitHub Security Advisory once the
41
+ fix is available.
42
+
43
+ ## Scope and threat model
44
+
45
+ What the library defends against, and what it delegates to the operator (key
46
+ custody, DNS/TLS for `did:web`, hosting of published documents), is documented
47
+ in the [Security Model](https://github.com/luisgf/openbadgeslib/wiki/Security-Model)
48
+ wiki page. Reports that fall within that model — a bypass of algorithm pinning,
49
+ signature or recipient-binding verification, the SSRF/decompression guards, the
50
+ context allowlist, or the status-list checks — are especially welcome.
51
+
52
+ Out of scope: issues that require the operator’s own private keys or config to
53
+ already be compromised, and hardening we already document as a deliberate,
54
+ human-owned decision (for example, passphrase-encrypted private keys at rest).
@@ -1,6 +1,6 @@
1
1
  """JWS sign/verify backed by PyJWT algorithm implementations (RS256/384/512, ES256/384/512)."""
2
2
 
3
- from typing import Any, Dict, Optional, Set, Union
3
+ from typing import Any, Dict, Optional, Set, Union, cast
4
4
 
5
5
  from . import utils
6
6
  from .exceptions import SignatureError, MissingKey, MissingSigner, MissingVerifier, RouteMissingError
@@ -66,7 +66,7 @@ def sign(header_dict: Dict[str, Any], payload_dict: Dict[str, Any], key: Any) ->
66
66
  algo = _algo_for(alg_name)
67
67
  try:
68
68
  prepared = algo.prepare_key(key_to_pem(key))
69
- return algo.sign(signing_input, prepared)
69
+ return cast(bytes, algo.sign(signing_input, prepared))
70
70
  except (InvalidKeyError, ValueError) as exc:
71
71
  raise SignatureError(str(exc)) from exc
72
72
 
@@ -29,7 +29,7 @@
29
29
  from struct import pack
30
30
  from zlib import crc32
31
31
 
32
- from typing import List, Optional, Tuple, Union
32
+ from typing import List, Optional, Tuple, Union, cast
33
33
 
34
34
  from defusedxml.minidom import parseString
35
35
  from png import Reader, signature as _png_signature
@@ -98,7 +98,7 @@ def bake_svg(image_bytes: bytes, token: str, comment: Optional[str] = None, *,
98
98
  svg_tag.appendChild(node)
99
99
  if comment:
100
100
  svg_tag.appendChild(svg_doc.createComment(comment))
101
- return svg_doc.toxml().encode('utf-8')
101
+ return cast(bytes, svg_doc.toxml().encode('utf-8'))
102
102
  finally:
103
103
  svg_doc.unlink()
104
104
 
@@ -133,7 +133,7 @@ def extract_svg(image_bytes: bytes, *, element: str = SVG_ELEMENT,
133
133
  return None
134
134
  attrs = nodes[0].attributes
135
135
  if 'verify' in attrs:
136
- return attrs['verify'].nodeValue
136
+ return cast(Optional[str], attrs['verify'].nodeValue)
137
137
  if not text_fallback:
138
138
  return None
139
139
  text = ''.join(
@@ -158,7 +158,7 @@ def _serialize_png(chunks: List[Tuple[Union[str, bytes], bytes]]) -> bytes:
158
158
  checksum = crc32(tag)
159
159
  checksum = crc32(data, checksum) & 0xFFFFFFFF
160
160
  out += pack("!I", checksum)
161
- return out
161
+ return cast(bytes, out)
162
162
 
163
163
 
164
164
  def bake_png(image_bytes: bytes, token: str, text_comment: Optional[str] = None, *,
@@ -21,7 +21,7 @@
21
21
  License along with this library.
22
22
  """
23
23
 
24
- from typing import Any, Optional, Tuple, Union
24
+ from typing import Any, Optional, Tuple, Union, cast
25
25
  from .errors import PublicKeyReadError, UnknownKeyType
26
26
  from ecdsa import SigningKey, VerifyingKey, NIST256p
27
27
  from Crypto.PublicKey import RSA
@@ -91,10 +91,10 @@ class KeyRSA(KeyBase):
91
91
  self.pub_key = RSA.import_key(key_pem)
92
92
 
93
93
  def get_priv_key_pem(self) -> bytes:
94
- return self.priv_key.export_key('PEM')
94
+ return cast(bytes, self.priv_key.export_key('PEM'))
95
95
 
96
96
  def get_pub_key_pem(self) -> bytes:
97
- return self.pub_key.export_key('PEM')
97
+ return cast(bytes, self.pub_key.export_key('PEM'))
98
98
 
99
99
 
100
100
  class KeyECC(KeyBase):
@@ -126,10 +126,10 @@ class KeyECC(KeyBase):
126
126
  self.pub_key = VerifyingKey.from_pem(key_pem)
127
127
 
128
128
  def get_priv_key_pem(self) -> bytes:
129
- return self.priv_key.to_pem()
129
+ return cast(bytes, self.priv_key.to_pem())
130
130
 
131
131
  def get_pub_key_pem(self) -> bytes:
132
- return self.pub_key.to_pem()
132
+ return cast(bytes, self.pub_key.to_pem())
133
133
 
134
134
 
135
135
  def _pem_bytes(key_pem: Union[str, bytes]) -> bytes:
@@ -177,15 +177,15 @@ class KeyEd25519(KeyBase):
177
177
  self.pub_key = _load_ed25519_public_key(key_pem)
178
178
 
179
179
  def get_priv_key_pem(self) -> bytes:
180
- return self.priv_key.private_bytes(
180
+ return cast(bytes, self.priv_key.private_bytes(
181
181
  _crypto_serialization.Encoding.PEM,
182
182
  _crypto_serialization.PrivateFormat.PKCS8,
183
- _crypto_serialization.NoEncryption())
183
+ _crypto_serialization.NoEncryption()))
184
184
 
185
185
  def get_pub_key_pem(self) -> bytes:
186
- return self.pub_key.public_bytes(
186
+ return cast(bytes, self.pub_key.public_bytes(
187
187
  _crypto_serialization.Encoding.PEM,
188
- _crypto_serialization.PublicFormat.SubjectPublicKeyInfo)
188
+ _crypto_serialization.PublicFormat.SubjectPublicKeyInfo))
189
189
 
190
190
 
191
191
  def alg_for_key_type(key_type: 'KeyType') -> str:
@@ -209,7 +209,7 @@ def key_to_pem(key: Any) -> Union[str, bytes]:
209
209
  if isinstance(key, RSA.RsaKey):
210
210
  return key.export_key('PEM')
211
211
  if isinstance(key, (SigningKey, VerifyingKey)):
212
- return key.to_pem()
212
+ return cast(Union[str, bytes], key.to_pem())
213
213
  if isinstance(key, Ed25519PrivateKey):
214
214
  return key.private_bytes(
215
215
  _crypto_serialization.Encoding.PEM,
@@ -224,7 +224,7 @@ def key_to_pem(key: Any) -> Union[str, bytes]:
224
224
  raise UnknownKeyType('Unsupported key object type: %r' % type(key))
225
225
 
226
226
 
227
- def public_jwk_from_pem(pubkey_pem: Union[str, bytes]) -> dict:
227
+ def public_jwk_from_pem(pubkey_pem: Union[str, bytes]) -> dict[str, Any]:
228
228
  """Serialise a public key PEM as a public JWK dict.
229
229
 
230
230
  Counterpart of the OB3 signer's private-key-based JWK derivation, for
@@ -250,7 +250,7 @@ def public_jwk_from_pem(pubkey_pem: Union[str, bytes]) -> dict:
250
250
  except Exception as exc:
251
251
  raise PublicKeyReadError(
252
252
  'could not read public key PEM: %s' % exc) from exc
253
- return json.loads(jwk_json)
253
+ return cast(dict[str, Any], json.loads(jwk_json))
254
254
 
255
255
 
256
256
  def ec_curve_from_pem(pem_data: Union[str, bytes]) -> Optional[str]:
@@ -0,0 +1,41 @@
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
+ import logging
25
+ from typing import Any
26
+
27
+
28
+ def enable_debug_logging(debug: bool = False) -> None:
29
+ """Route library log messages to the console for the CLI tools.
30
+
31
+ With ``debug=True`` DEBUG-level messages are shown; otherwise only INFO and
32
+ above. Idempotent — safe to call once per CLI invocation (it will not stack
33
+ duplicate console handlers).
34
+ """
35
+ root = logging.getLogger()
36
+ if not any(getattr(h, '_obl_console', False) for h in root.handlers):
37
+ handler: Any = logging.StreamHandler()
38
+ handler.setFormatter(logging.Formatter("%(levelname)s - %(message)s"))
39
+ handler._obl_console = True
40
+ root.addHandler(handler)
41
+ root.setLevel(logging.DEBUG if debug else logging.INFO)
@@ -23,7 +23,7 @@
23
23
 
24
24
  import os
25
25
  from enum import Enum
26
- from typing import Any, Optional, Union
26
+ from typing import Any, Optional, Union, cast
27
27
 
28
28
  from Crypto.PublicKey import RSA
29
29
  from ecdsa import SigningKey, VerifyingKey
@@ -78,7 +78,7 @@ class Assertion():
78
78
  return jws_utils.decode(self.body)
79
79
 
80
80
  def get_assertion(self) -> bytes:
81
- return self.header + b'.' + self.body + b'.' + self.signature
81
+ return cast(bytes, self.header + b'.' + self.body + b'.' + self.signature)
82
82
 
83
83
  def encode_header(self, header: Any) -> None:
84
84
  self.header = jws_utils.encode(header)
@@ -350,13 +350,13 @@ class BadgeSigned():
350
350
  self.file_out = file_name
351
351
 
352
352
  def get_identity(self) -> str:
353
- return self.identity.decode('utf-8')
353
+ return cast(str, self.identity.decode('utf-8'))
354
354
 
355
355
  def get_identity_hashed(self) -> str:
356
356
  return (b'sha256$' + hash_email(self.identity, self.salt)).decode('utf-8')
357
357
 
358
358
  def get_salt(self) -> str:
359
- return self.salt.decode('utf-8')
359
+ return cast(str, self.salt.decode('utf-8'))
360
360
 
361
361
  def get_assertion(self) -> Optional[str]:
362
362
  if self.assertion:
@@ -380,7 +380,7 @@ class BadgeSigned():
380
380
  def get_signkey_pem(self) -> Union[str, bytes]:
381
381
  """ Return the public key pem used to sign the openbadge """
382
382
 
383
- return self.source.pubkey_pem
383
+ return cast(Union[str, bytes], self.source.pubkey_pem)
384
384
 
385
385
 
386
386
  def extract_svg_assertion(file_data: bytes) -> Assertion:
@@ -21,7 +21,7 @@
21
21
  License along with this library.
22
22
  """
23
23
 
24
- from typing import Any, Optional
24
+ from typing import Any, Optional, cast
25
25
 
26
26
  from .badge import BadgeStatus
27
27
  from ..util import hash_email, download_file, show_ecc_disclaimer
@@ -225,7 +225,7 @@ class Verifier():
225
225
  email_salt = badge.salt if badge.salt else b''
226
226
  email_hashed = b'sha256$' + hash_email(self.identity, email_salt)
227
227
 
228
- return email_hashed == badge.identity
228
+ return cast(bool, email_hashed == badge.identity)
229
229
  except Exception:
230
230
  raise NotIdentityInAssertion('The assertion doesn\'t have an identify ')
231
231
 
@@ -32,7 +32,7 @@
32
32
  import uuid
33
33
  from dataclasses import dataclass, field
34
34
  from datetime import datetime, timezone
35
- from typing import Any, List, Optional
35
+ from typing import Any, List, Optional, Union
36
36
 
37
37
  from ..util import hash_email
38
38
 
@@ -95,13 +95,13 @@ def _validate_type(value: Any, expected: str, where: str) -> None:
95
95
  raise ValueError("%s.type must include %r, got %r" % (where, expected, value))
96
96
 
97
97
 
98
- def _as_dict(value: Any, where: str) -> dict:
98
+ def _as_dict(value: Any, where: str) -> dict[str, Any]:
99
99
  if not isinstance(value, dict):
100
100
  raise ValueError("%s must be a JSON object" % where)
101
101
  return value
102
102
 
103
103
 
104
- def _require(data: dict, key: str, where: str) -> str:
104
+ def _require(data: dict[str, Any], key: str, where: str) -> str:
105
105
  """Return ``data[key]`` as a non-empty string, else raise a clear error."""
106
106
  value = data.get(key)
107
107
  if value is None or value == "":
@@ -122,6 +122,25 @@ def _iri_or_none(value: Any) -> Optional[str]:
122
122
  return None
123
123
 
124
124
 
125
+ def _iri_or_iris(value: Any) -> Optional[Union[str, List[str]]]:
126
+ """Like :func:`_iri_or_none`, but keep an array instead of dropping it.
127
+
128
+ OB 2.0 lets ``evidence`` be a bare IRI, an object with an ``id``, or an
129
+ array of either. A single value collapses to its IRI string (unchanged
130
+ behaviour); an array yields the list of IRIs it carries. None when nothing
131
+ usable is present — so a conformant multi-evidence assertion is no longer
132
+ silently discarded on parse.
133
+ """
134
+ if isinstance(value, list):
135
+ iris = []
136
+ for item in value:
137
+ iri = _iri_or_none(item)
138
+ if iri is not None:
139
+ iris.append(iri)
140
+ return iris or None
141
+ return _iri_or_none(value)
142
+
143
+
125
144
  def hash_identity(email: str, salt: Optional[str]) -> str:
126
145
  """Return the ``sha256$<hex>`` IdentityHash for an email plus optional salt."""
127
146
  return "sha256$" + hash_email(email, salt if salt is not None else "").decode("ascii")
@@ -143,8 +162,8 @@ class IdentityObject:
143
162
  """Build a hashed email IdentityObject from a plaintext email + salt."""
144
163
  return cls(identity=hash_identity(email, salt), hashed=True, salt=salt, type="email")
145
164
 
146
- def to_dict(self) -> dict:
147
- d: dict = {"type": self.type, "hashed": self.hashed, "identity": self.identity}
165
+ def to_dict(self) -> dict[str, Any]:
166
+ d: dict[str, Any] = {"type": self.type, "hashed": self.hashed, "identity": self.identity}
148
167
  if self.salt is not None:
149
168
  d["salt"] = self.salt
150
169
  return d
@@ -174,8 +193,8 @@ class Verification:
174
193
  type: str # "SignedBadge" or "HostedBadge"
175
194
  creator: Optional[str] = None # IRI of the issuer's CryptographicKey (signed)
176
195
 
177
- def to_dict(self) -> dict:
178
- d: dict = {"type": self.type}
196
+ def to_dict(self) -> dict[str, Any]:
197
+ d: dict[str, Any] = {"type": self.type}
179
198
  if self.creator:
180
199
  d["creator"] = self.creator
181
200
  return d
@@ -209,7 +228,7 @@ class Assertion:
209
228
  issued_on: Optional[datetime] = None # defaults to now (UTC)
210
229
  expires: Optional[datetime] = None
211
230
  image: Optional[str] = None
212
- evidence: Optional[str] = None
231
+ evidence: Optional[Union[str, List[str]]] = None
213
232
  narrative: Optional[str] = None
214
233
 
215
234
  def __post_init__(self) -> None:
@@ -218,9 +237,9 @@ class Assertion:
218
237
  if self.issued_on is None:
219
238
  self.issued_on = datetime.now(timezone.utc)
220
239
 
221
- def to_dict(self) -> dict:
240
+ def to_dict(self) -> dict[str, Any]:
222
241
  assert self.issued_on is not None # set by __post_init__
223
- d: dict = {
242
+ d: dict[str, Any] = {
224
243
  "@context": OB2_CONTEXT,
225
244
  "type": "Assertion",
226
245
  "id": self.id,
@@ -265,7 +284,7 @@ class Assertion:
265
284
  issued_on=issued_on,
266
285
  expires=expires,
267
286
  image=_iri_or_none(d.get("image")),
268
- evidence=_iri_or_none(d.get("evidence")),
287
+ evidence=_iri_or_iris(d.get("evidence")),
269
288
  narrative=d.get("narrative") if isinstance(d.get("narrative"), str) else None,
270
289
  )
271
290
 
@@ -278,7 +297,7 @@ class CryptographicKey:
278
297
  owner: str # IRI of the owning issuer Profile (bidirectional)
279
298
  public_key_pem: str
280
299
 
281
- def to_dict(self) -> dict:
300
+ def to_dict(self) -> dict[str, Any]:
282
301
  return {
283
302
  "@context": OB2_CONTEXT,
284
303
  "type": "CryptographicKey",
@@ -310,8 +329,8 @@ class Profile:
310
329
  public_key: List[str] = field(default_factory=list) # CryptographicKey IRIs
311
330
  revocation_list: Optional[str] = None
312
331
 
313
- def to_dict(self) -> dict:
314
- d: dict = {
332
+ def to_dict(self) -> dict[str, Any]:
333
+ d: dict[str, Any] = {
315
334
  "@context": OB2_CONTEXT,
316
335
  "type": "Issuer",
317
336
  "id": self.id,
@@ -342,8 +361,8 @@ class BadgeClass:
342
361
  issuer: str # issuer Profile IRI
343
362
  tags: List[str] = field(default_factory=list)
344
363
 
345
- def to_dict(self) -> dict:
346
- d: dict = {
364
+ def to_dict(self) -> dict[str, Any]:
365
+ d: dict[str, Any] = {
347
366
  "@context": OB2_CONTEXT,
348
367
  "type": "BadgeClass",
349
368
  "id": self.id,
@@ -366,7 +385,7 @@ class RevocationList:
366
385
  issuer: str
367
386
  revoked_assertions: List[Any] = field(default_factory=list)
368
387
 
369
- def to_dict(self) -> dict:
388
+ def to_dict(self) -> dict[str, Any]:
370
389
  return {
371
390
  "@context": OB2_CONTEXT,
372
391
  "type": "RevocationList",
@@ -215,7 +215,7 @@ class OB2Verifier:
215
215
  "hosted trust comes from the HTTPS fetch, not the signature.",
216
216
  assertion.id, exc)
217
217
 
218
- def _check_hosted_scope(self, assertion_id: str, issuer: dict) -> None:
218
+ def _check_hosted_scope(self, assertion_id: str, issuer: dict[str, Any]) -> None:
219
219
  """Enforce the OB 2.0 hosted-verification scope for ``assertion_id``."""
220
220
  verification = issuer.get("verification")
221
221
  if isinstance(verification, dict):
@@ -287,7 +287,7 @@ class OB2Verifier:
287
287
 
288
288
  # ── network / decode helpers ─────────────────────────────────────────────────
289
289
 
290
- def _fetch_issuer(self, assertion: Assertion) -> dict:
290
+ def _fetch_issuer(self, assertion: Assertion) -> dict[str, Any]:
291
291
  """Resolve the issuer Profile via the assertion's BadgeClass ``badge`` URL."""
292
292
  badge = self._fetch_json(assertion.badge, "BadgeClass")
293
293
  issuer_url = badge.get("issuer")
@@ -299,7 +299,7 @@ class OB2Verifier:
299
299
  "BadgeClass at %s has no valid 'issuer'" % assertion.badge)
300
300
  return self._fetch_json(issuer_url, "issuer Profile")
301
301
 
302
- def _fetch_json(self, url: str, where: str) -> dict:
302
+ def _fetch_json(self, url: str, where: str) -> dict[str, Any]:
303
303
  try:
304
304
  raw = download_file(url)
305
305
  except Exception as exc:
@@ -79,7 +79,11 @@ _URL_TO_RESOURCE: Dict[str, str] = {
79
79
  'ob-v3p0-context-3.0.2.json',
80
80
  'https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json':
81
81
  'ob-v3p0-context-3.0.3.json',
82
- # The unversioned OB3 context URL serves the latest published revision.
82
+ # The unversioned OB3 context URL is an internal alias to our latest
83
+ # bundled revision (3.0.3). We deliberately pin it here rather than mirror
84
+ # 1EdTech's moving unversioned endpoint (which currently serves an older
85
+ # revision matching none we ship); the context-drift check skips it for
86
+ # that reason.
83
87
  'https://purl.imsglobal.org/spec/ob/v3p0/context.json':
84
88
  'ob-v3p0-context-3.0.3.json',
85
89
  'https://w3id.org/security/data-integrity/v2':
@@ -89,7 +93,7 @@ _URL_TO_RESOURCE: Dict[str, str] = {
89
93
 
90
94
 
91
95
  @functools.lru_cache(maxsize=None)
92
- def load_context(url: str) -> dict:
96
+ def load_context(url: str) -> dict[str, Any]:
93
97
  """Return the bundled JSON-LD context document for an allowlisted URL.
94
98
 
95
99
  Raises UnknownContextError for any URL outside the allowlist — the match
@@ -109,8 +113,8 @@ def load_context(url: str) -> dict:
109
113
 
110
114
 
111
115
  def document_loader(
112
- extra_contexts: Optional[Mapping[str, dict]] = None,
113
- ) -> Callable[[str, Any], dict]:
116
+ extra_contexts: Optional[Mapping[str, dict[str, Any]]] = None,
117
+ ) -> Callable[[str, Any], dict[str, Any]]:
114
118
  """Build a static documentLoader in the shape pyld expects.
115
119
 
116
120
  *extra_contexts* extends the allowlist for one loader instance (e.g. a
@@ -118,9 +122,9 @@ def document_loader(
118
122
  global allowlist. Every other URL raises UnknownContextError, which the
119
123
  Data Integrity verifier surfaces as a clean verification failure.
120
124
  """
121
- extras: Dict[str, dict] = dict(extra_contexts) if extra_contexts else {}
125
+ extras: Dict[str, dict[str, Any]] = dict(extra_contexts) if extra_contexts else {}
122
126
 
123
- def _loader(url: str, options: Any = None) -> dict:
127
+ def _loader(url: str, options: Any = None) -> dict[str, Any]:
124
128
  document = extras[url] if url in extras else load_context(url)
125
129
  return {'contextUrl': None, 'documentUrl': url, 'document': document}
126
130