openbadgeslib 3.5.0__tar.gz → 3.5.1__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 (122) hide show
  1. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/Changelog.txt +41 -0
  2. {openbadgeslib-3.5.0/openbadgeslib.egg-info → openbadgeslib-3.5.1}/PKG-INFO +1 -1
  3. openbadgeslib-3.5.1/openbadgeslib/logs.py +41 -0
  4. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob2/models.py +22 -3
  5. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob3/credential.py +16 -1
  6. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob3/did.py +10 -0
  7. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob3/verifier.py +9 -0
  8. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/openbadges_keygenerator.py +16 -11
  9. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/openbadges_publish.py +13 -2
  10. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/openbadges_signer.py +18 -15
  11. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/util.py +1 -1
  12. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1/openbadgeslib.egg-info}/PKG-INFO +1 -1
  13. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_ob2_models.py +13 -0
  14. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_ob3_credential.py +16 -0
  15. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_ob3_did.py +6 -0
  16. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_ob3_signer_cli_ldp.py +4 -3
  17. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_ob3_verifier.py +23 -0
  18. openbadgeslib-3.5.0/openbadgeslib/logs.py +0 -85
  19. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/LICENSE.txt +0 -0
  20. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/MANIFEST.in +0 -0
  21. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/README.md +0 -0
  22. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/docs/README.md +0 -0
  23. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/__init__.py +0 -0
  24. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/_jws/__init__.py +0 -0
  25. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/_jws/exceptions.py +0 -0
  26. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/_jws/utils.py +0 -0
  27. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/badge.py +0 -0
  28. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/baking.py +0 -0
  29. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/config.ini.example +0 -0
  30. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/confparser.py +0 -0
  31. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/errors.py +0 -0
  32. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/keys.py +0 -0
  33. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/mail.py +0 -0
  34. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob1/__init__.py +0 -0
  35. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob1/badge.py +0 -0
  36. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob1/signer.py +0 -0
  37. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob1/verifier.py +0 -0
  38. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob2/__init__.py +0 -0
  39. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob2/signer.py +0 -0
  40. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob2/verifier.py +0 -0
  41. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob3/__init__.py +0 -0
  42. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob3/contexts/__init__.py +0 -0
  43. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
  44. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
  45. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
  46. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
  47. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
  48. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
  49. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob3/eudi.py +0 -0
  50. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob3/ldp.py +0 -0
  51. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob3/signer.py +0 -0
  52. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob3/status.py +0 -0
  53. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob3/status_list.py +0 -0
  54. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/ob3/status_registry.py +0 -0
  55. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/openbadges_init.py +0 -0
  56. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/openbadges_verifier.py +0 -0
  57. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/py.typed +0 -0
  58. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/signer.py +0 -0
  59. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib/verifier.py +0 -0
  60. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib.egg-info/SOURCES.txt +0 -0
  61. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib.egg-info/dependency_links.txt +0 -0
  62. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib.egg-info/entry_points.txt +0 -0
  63. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib.egg-info/requires.txt +0 -0
  64. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/openbadgeslib.egg-info/top_level.txt +0 -0
  65. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/pyproject.toml +0 -0
  66. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/setup.cfg +0 -0
  67. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/config1.ini +0 -0
  68. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/conformance/Dockerfile.ob3 +0 -0
  69. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/conformance/README.md +0 -0
  70. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/conformance/conftest.py +0 -0
  71. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/conformance/docker-compose.yml +0 -0
  72. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/conformance/test_official_validators.py +0 -0
  73. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/conftest.py +0 -0
  74. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/fixtures/ob_schemas/README.md +0 -0
  75. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
  76. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
  77. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/fixtures/ob_schemas/refresh.sh +0 -0
  78. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/fixtures/vc_di_eddsa/README.md +0 -0
  79. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
  80. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
  81. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
  82. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
  83. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
  84. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/images/sample1.png +0 -0
  85. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/images/sample1.svg +0 -0
  86. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/images/userimage01.svg +0 -0
  87. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/ldp_helpers.py +0 -0
  88. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/logo Python Espan/314/203a.svg" +0 -0
  89. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/logo Python Espa/303/261a.svg" +0 -0
  90. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/runtests.sh +0 -0
  91. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_cli_json.py +0 -0
  92. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_cli_smoke.py +0 -0
  93. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_confparser.py +0 -0
  94. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_docs.py +0 -0
  95. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_eddsa.py +0 -0
  96. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_jws.py +0 -0
  97. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_key_operation.py +0 -0
  98. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_ob1_badge_io.py +0 -0
  99. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_ob1_signer.py +0 -0
  100. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_ob1_verifier.py +0 -0
  101. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_ob2_cli.py +0 -0
  102. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_ob2_signer.py +0 -0
  103. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_ob2_verifier.py +0 -0
  104. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_ob3_conformance_schema.py +0 -0
  105. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_ob3_contexts.py +0 -0
  106. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_ob3_did_doc.py +0 -0
  107. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_ob3_eudi_sd_jwt.py +0 -0
  108. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_ob3_ldp.py +0 -0
  109. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_ob3_ldp_cli.py +0 -0
  110. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_ob3_ldp_sign.py +0 -0
  111. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_ob3_publish_cli.py +0 -0
  112. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_ob3_signer.py +0 -0
  113. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_ob3_status.py +0 -0
  114. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_ob3_status_list.py +0 -0
  115. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_ob3_status_registry.py +0 -0
  116. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_sign_ecc.pem +0 -0
  117. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_sign_rsa.pem +0 -0
  118. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_util.py +0 -0
  119. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_verify_ecc.pem +0 -0
  120. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/test_verify_rsa.pem +0 -0
  121. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/withoutxmlheader.svg +0 -0
  122. {openbadgeslib-3.5.0 → openbadgeslib-3.5.1}/tests/withxmlheader.svg +0 -0
@@ -4,6 +4,47 @@ OpenBadgesLib - Changelog
4
4
  Newest first. Dates are ISO 8601 (YYYY-MM-DD).
5
5
 
6
6
 
7
+ * v3.5.1 - 2026-07-07
8
+
9
+ - fix(ob3): a naive datetime passed as the issuance/expiration date is now
10
+ assumed UTC (matching status_registry._iso_z), not local time — so
11
+ validFrom/validUntil no longer silently shift by the host's offset and
12
+ stay consistent with the nbf/exp JWT claims.
13
+
14
+ - fix(ob3): the JWT verifier now cross-checks the `jti` claim against the
15
+ credential `id` (OB3 §8.2 binds them). A validly signed token whose jti
16
+ disagrees with — or omits — the credential id is rejected, alongside the
17
+ existing iss/nbf/sub checks.
18
+
19
+ - fix(ob3): `_b58btc_decode` bounds its input to 128 characters before the
20
+ O(n^2) base58 decode, so a hostile did:key / publicKeyMultibase in a
21
+ fetched did:web or Data Integrity document (up to 5 MiB / 256 KiB) can no
22
+ longer burn CPU. No real multikey exceeds ~70 characters.
23
+
24
+ - fix(ob2): an `evidence` array (which OB 2.0 permits) is preserved when
25
+ parsing an Assertion instead of being silently dropped — `_iri_or_none`
26
+ returned None for a list. A single value still collapses to its IRI.
27
+
28
+ - fix(cli): `openbadges-signer -M/--mail-badge` now prints a clear notice
29
+ on the default OB3 path (it was a silent no-op there); the stale
30
+ `openbadges-keygenerator -t ED25519` hint on the LDP path is corrected to
31
+ the real `key_type = ED25519` config key; and the OB1 mail path reads its
32
+ `[smtp]` config inside the try, so a missing `[smtp]` section is reported
33
+ cleanly after signing instead of raising a traceback.
34
+
35
+ - fix(publish): `openbadges-publish -V 3` no longer aborts mid-loop when a
36
+ badge has an unreadable key or corrupt registry — it skips that badge
37
+ with a notice (like the did.json path already did), regenerates the
38
+ others, and exits non-zero with a summary. An urgent revocation is no
39
+ longer masked by an unrelated half-configured badge.
40
+
41
+ - refactor(keygen): `openbadges-keygenerator` uses `enable_debug_logging`
42
+ instead of the legacy `Logger` class (now removed from logs.py), so key
43
+ generation no longer requires the `[paths]`/`[logs]` config sections just
44
+ to emit console lines (they raised a raw KeyError/FileNotFoundError), and
45
+ a missing `[issuer] name` degrades gracefully.
46
+
47
+
7
48
  * v3.5.0 - 2026-07-07
8
49
 
9
50
  - 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.5.1
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,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)
@@ -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
 
@@ -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")
@@ -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:
@@ -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
 
@@ -56,7 +56,14 @@ def _validate_context(ctx: Any) -> None:
56
56
 
57
57
 
58
58
  def _iso(dt: datetime) -> str:
59
- """Return a datetime as an ISO 8601 string with Z suffix."""
59
+ """Return a datetime as an ISO 8601 string with Z suffix.
60
+
61
+ A naive datetime is assumed to be UTC (matching status_registry._iso_z),
62
+ not local time — so validFrom/validUntil never silently shift by the host's
63
+ offset and stay consistent with the nbf/exp JWT claims.
64
+ """
65
+ if dt.tzinfo is None:
66
+ dt = dt.replace(tzinfo=timezone.utc)
60
67
  return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
61
68
 
62
69
 
@@ -131,6 +138,14 @@ class OpenBadgeCredential:
131
138
  self.id = f"urn:uuid:{uuid.uuid4()}"
132
139
  if self.issuance_date is None:
133
140
  self.issuance_date = datetime.now(timezone.utc)
141
+ elif self.issuance_date.tzinfo is None:
142
+ # A naive datetime is assumed UTC, so validFrom (string) and nbf
143
+ # (epoch int) agree instead of diverging by the host's offset.
144
+ self.issuance_date = self.issuance_date.replace(tzinfo=timezone.utc)
145
+ if self.expiration_date is not None \
146
+ and self.expiration_date.tzinfo is None:
147
+ self.expiration_date = self.expiration_date.replace(
148
+ tzinfo=timezone.utc)
134
149
  if self.name is None:
135
150
  self.name = self.achievement.name
136
151
 
@@ -95,7 +95,17 @@ def _multicodec_pubkey_to_pem(data: bytes) -> bytes:
95
95
  raise OB3VerificationError("unsupported did:key multicodec 0x%x" % code)
96
96
 
97
97
 
98
+ #: Bound on base58btc input length. Decoding is O(n^2) in the length (big-int
99
+ #: accumulation), and a hostile did:key / publicKeyMultibase in a fetched
100
+ #: document could be hundreds of KB; no real multikey exceeds ~70 characters.
101
+ _MAX_B58_LEN = 128
102
+
103
+
98
104
  def _b58btc_decode(value: str) -> bytes:
105
+ if len(value) > _MAX_B58_LEN:
106
+ raise OB3VerificationError(
107
+ "base58btc value too long: %d characters (max %d)"
108
+ % (len(value), _MAX_B58_LEN))
99
109
  num = 0
100
110
  for ch in value:
101
111
  idx = _B58_INDEX.get(ch)
@@ -311,6 +311,15 @@ class OB3Verifier:
311
311
  raise OB3VerificationError("JWT payload is missing the required 'sub' claim")
312
312
  if sub is not None and sub != subject_id:
313
313
  raise OB3VerificationError("JWT 'sub' does not match the credentialSubject id")
314
+ # jti is the registered claim bound to the credential id (OB3 §8.2); the
315
+ # signer always emits it. Require it whenever the body carries an id, and
316
+ # reject any mismatch, so a token cannot claim one id and carry another.
317
+ jti = payload.get("jti")
318
+ vc_id = vc.get("id")
319
+ if vc_id is not None and jti is None:
320
+ raise OB3VerificationError("JWT payload is missing the required 'jti' claim")
321
+ if jti is not None and jti != vc_id:
322
+ raise OB3VerificationError("JWT 'jti' does not match the credential id")
314
323
 
315
324
  return credential
316
325
 
@@ -30,14 +30,17 @@
30
30
  """
31
31
 
32
32
  import argparse
33
+ import logging
33
34
  import os
34
35
  import sys
35
36
 
36
- from .logs import Logger
37
+ from .logs import enable_debug_logging
37
38
  from .keys import KeyFactory, KeyType
38
39
  from .confparser import read_config_or_exit, resolve_badge_section
39
40
  from .util import __version__
40
41
 
42
+ logger = logging.getLogger(__name__)
43
+
41
44
  # Entry Point
42
45
 
43
46
 
@@ -100,15 +103,17 @@ def main() -> None:
100
103
  print('[!] Key file is present at %s' % i)
101
104
  sys.exit(1)
102
105
 
103
- log = Logger(base_log=conf['paths']['base_log'],
104
- general=conf['logs']['general'],
105
- signer=conf['logs']['signer'],
106
- show_debug=args.debug)
106
+ # Key generation must not require [paths]/[logs]/[issuer]: the legacy Logger
107
+ # opened general.log/signer.log (KeyError/FileNotFoundError on an incomplete
108
+ # config) just to emit these console lines. The module logger needs none.
109
+ enable_debug_logging(args.debug)
107
110
 
108
- log.console.debug("key_type=%s private=%s public=%s"
109
- % (key_type.name, private_key, public_key))
110
- log.console.info("Generating OpenBadges %s %s key pair for issuer '%s'"
111
- % (args.ob_version, key_type.name, conf['issuer']['name']))
111
+ logger.debug("key_type=%s private=%s public=%s",
112
+ key_type.name, private_key, public_key)
113
+ issuer_name = (conf['issuer'].get('name', '?')
114
+ if conf.has_section('issuer') else '?')
115
+ logger.info("Generating OpenBadges %s %s key pair for issuer '%s'",
116
+ args.ob_version, key_type.name, issuer_name)
112
117
 
113
118
  kf = KeyFactory(key_type)
114
119
  priv_key_pem, pub_key_pem = kf.generate_keypair()
@@ -116,8 +121,8 @@ def main() -> None:
116
121
  _write_pem_file(private_key, priv_key_pem, 0o600)
117
122
  _write_pem_file(public_key, pub_key_pem, 0o644)
118
123
 
119
- log.console.info('Private key saved at: %s' % private_key)
120
- log.console.info('Public key saved at: %s' % public_key)
124
+ logger.info('Private key saved at: %s', private_key)
125
+ logger.info('Public key saved at: %s', public_key)
121
126
 
122
127
 
123
128
  if __name__ == '__main__':
@@ -231,6 +231,7 @@ def _publish_ob3(args: argparse.Namespace, parser: argparse.ArgumentParser) -> N
231
231
  print('[!] Cannot derive a did:web identifier: %s' % exc)
232
232
  sys.exit(-1)
233
233
 
234
+ failures = [] # badges skipped (unreadable key / registry)
234
235
  umask = os.umask(0o077) # rwx------
235
236
  try:
236
237
  os.makedirs(args.output, exist_ok=True)
@@ -264,8 +265,13 @@ def _publish_ob3(args: argparse.Namespace, parser: argparse.ArgumentParser) -> N
264
265
  with open(conf[name]['private_key'], 'rb') as key:
265
266
  priv_pem = key.read()
266
267
  except (StatusError, OSError) as exc:
267
- print('[!] %s' % exc)
268
- sys.exit(-1)
268
+ # Isolate a per-badge failure (unreadable key, corrupt registry)
269
+ # like the did.json skip above: don't abort the whole publish —
270
+ # a badge_2 mid-configuration must not make an urgent badge_1
271
+ # revocation appear to fail after its list was already written.
272
+ print('[!] Skipping [%s] status lists — %s' % (name, exc))
273
+ failures.append(name)
274
+ continue
269
275
  algorithm = alg_for_key_type(detect_key_type(priv_pem))
270
276
 
271
277
  badge_dir = os.path.join(args.output, name)
@@ -285,8 +291,13 @@ def _publish_ob3(args: argparse.Namespace, parser: argparse.ArgumentParser) -> N
285
291
  finally:
286
292
  os.umask(umask)
287
293
 
294
+ if failures:
295
+ print('[!] %d badge(s) skipped (see above): %s — their status lists '
296
+ 'were NOT regenerated' % (len(failures), ', '.join(failures)))
288
297
  print('Please configure your Web server to publish the folder %s as %s' %
289
298
  (args.output, publish_url))
299
+ if failures:
300
+ sys.exit(1)
290
301
  print('[i] Issuer DID: %s' % did)
291
302
  if ':' not in did[len('did:web:'):]:
292
303
  print('[i] A bare-host did:web resolves at '
@@ -268,26 +268,25 @@ def _sign_ob1(args: argparse.Namespace, conf: configparser.ConfigParser, badge:
268
268
  print('[!] Could not write sign log: %s' % err)
269
269
 
270
270
  if bool(args.mail_badge):
271
- server = conf['smtp']['smtp_server']
272
- port = int(conf['smtp']['smtp_port'])
273
- # configparser stores everything as strings; 'False' would be truthy.
274
- use_ssl = conf['smtp'].getboolean('use_ssl', False)
275
- mail_from = conf['smtp']['mail_from']
276
- username = conf['smtp'].get('username')
277
- password = conf['smtp'].get('password')
278
-
279
271
  try:
272
+ server = conf['smtp']['smtp_server']
273
+ port = int(conf['smtp']['smtp_port'])
274
+ # configparser stores everything as strings; 'False' is truthy.
275
+ use_ssl = conf['smtp'].getboolean('use_ssl', False)
276
+ mail_from = conf['smtp']['mail_from']
277
+ username = conf['smtp'].get('username')
278
+ password = conf['smtp'].get('password')
280
279
  mail = BadgeMail(server, port, use_ssl, mail_from, username, password)
281
280
  subject, body = mail.get_mail_content(conf[badge]['mail'])
282
281
  mail.set_subject(subject)
283
282
  mail.set_body(body)
284
283
  mail.send(badge_signed)
285
284
  except (ValueError, OSError, KeyError) as err:
286
- # e.g. a username set without use_ssl=True (ValueError), a
287
- # missing/unreadable mail template file (OSError), or a badge
288
- # section with no 'mail' key (KeyError) — the badge is already
289
- # signed and saved, so report the config error instead of
290
- # crashing with a traceback.
285
+ # e.g. a missing [smtp] section or key (KeyError), a username
286
+ # set without use_ssl=True (ValueError), or a missing/unreadable
287
+ # mail template file (OSError) — the badge is already signed and
288
+ # saved, so report the config error instead of crashing with a
289
+ # traceback. The [smtp] reads are inside the try for that reason.
291
290
  print('[!] Could not send mail: %s' % err)
292
291
 
293
292
  print('%s at: %s' % (msg, badge_file_out))
@@ -401,6 +400,9 @@ def _sign_ob3(args: argparse.Namespace, conf: configparser.ConfigParser, badge:
401
400
  except OSError as err:
402
401
  print('[!] Could not write sign log: %s' % err)
403
402
  print('%s at: %s' % (msg, badge_file_out))
403
+ if args.mail_badge:
404
+ print('[i] --mail-badge is not supported for -V 3; the badge was saved '
405
+ 'but not emailed.')
404
406
 
405
407
 
406
408
  def _sign_ob3_ldp(badge: str, badge_obj: Badge,
@@ -416,8 +418,9 @@ def _sign_ob3_ldp(badge: str, badge_obj: Badge,
416
418
 
417
419
  if key_type is not KeyType.ED25519:
418
420
  print("[!] --proof-format ldp (eddsa-rdfc-2022) requires an Ed25519 "
419
- "key; [%s] uses %s. Generate one with: "
420
- "openbadges-keygenerator -t ED25519" % (badge, key_type.value))
421
+ "key; [%s] uses %s. Set 'key_type = ED25519' in that badge's "
422
+ "config section and regenerate its key with "
423
+ "openbadges-keygenerator -g" % (badge, key_type.value))
421
424
  sys.exit(-1)
422
425
 
423
426
  verification_method = None
@@ -21,7 +21,7 @@
21
21
  License along with this library.
22
22
  """
23
23
 
24
- __version__ = '3.5.0'
24
+ __version__ = '3.5.1'
25
25
 
26
26
  import hashlib
27
27
  import ipaddress
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openbadgeslib
3
- Version: 3.5.0
3
+ Version: 3.5.1
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
@@ -130,6 +130,19 @@ class TestAssertion:
130
130
  d['@context'] = [OB2_CONTEXT, 'https://example.com/extension']
131
131
  assert Assertion.from_dict(d).badge == d['badge']
132
132
 
133
+ def test_from_dict_keeps_an_evidence_array(self):
134
+ # OB 2.0 allows an array of evidence (IRIs or objects); it must not be
135
+ # silently dropped on parse.
136
+ d = _assertion().to_dict()
137
+ d['evidence'] = ['https://e/ev1', {'id': 'https://e/ev2'}]
138
+ assert Assertion.from_dict(d).evidence == ['https://e/ev1',
139
+ 'https://e/ev2']
140
+
141
+ def test_from_dict_keeps_single_evidence_as_string(self):
142
+ d = _assertion().to_dict()
143
+ d['evidence'] = 'https://e/only'
144
+ assert Assertion.from_dict(d).evidence == 'https://e/only'
145
+
133
146
  def test_from_dict_rejects_wrong_type(self):
134
147
  d = _assertion().to_dict()
135
148
  d['type'] = 'BadgeClass'
@@ -306,3 +306,19 @@ class TestHelpers:
306
306
  # calls .lower()); a non-string must be rejected here.
307
307
  with pytest.raises(ValueError, match="must be a string"):
308
308
  _require({'id': bad_value}, 'id', 'vc.credentialSubject')
309
+
310
+
311
+ class TestNaiveDatetimeAssumedUtc:
312
+ def test_naive_issuance_date_coerced_to_utc(self):
313
+ # A naive datetime is assumed UTC, so validFrom (string) and nbf (epoch)
314
+ # agree instead of diverging by the host's local offset.
315
+ cred = _make_credential(issuance_date=datetime(2026, 1, 1, 12, 0, 0))
316
+ assert cred.issuance_date.tzinfo is not None
317
+ assert cred.to_vc()['validFrom'] == '2026-01-01T12:00:00Z'
318
+ aware = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
319
+ assert cred.to_jwt_payload()['nbf'] == int(aware.timestamp())
320
+
321
+ def test_naive_expiration_date_coerced_to_utc(self):
322
+ cred = _make_credential(expiration_date=datetime(2030, 6, 1, 0, 0, 0))
323
+ assert cred.expiration_date.tzinfo is not None
324
+ assert cred.to_vc()['validUntil'] == '2030-06-01T00:00:00Z'
@@ -69,6 +69,12 @@ class TestDidKey:
69
69
  with pytest.raises(OB3VerificationError, match='base58'):
70
70
  resolve_did('did:key:z0OIl') # 0, O, I, l are not in the alphabet
71
71
 
72
+ def test_overlong_base58_rejected(self):
73
+ # A hostile did:key must be length-bounded before the O(n^2) decode; no
74
+ # real multikey exceeds ~70 chars.
75
+ with pytest.raises(OB3VerificationError, match='too long'):
76
+ resolve_did('did:key:z' + 'a' * 200)
77
+
72
78
  def test_non_z_multibase_rejected(self):
73
79
  with pytest.raises(OB3VerificationError, match='base58btc'):
74
80
  resolve_did('did:key:Qabc')
@@ -190,9 +190,10 @@ class TestSignerLdpCli:
190
190
 
191
191
 
192
192
  class TestEd25519CliSupport:
193
- # Badge.create_from_conf rejected key_type = ED25519 outright, so the
194
- # CLI could never sign with the keys openbadges-keygenerator -t ED25519
195
- # produces — a prerequisite for LDP issuance, fixed alongside it.
193
+ # Badge.create_from_conf rejected key_type = ED25519 outright, so the CLI
194
+ # could never sign with the keys openbadges-keygenerator produces from a
195
+ # key_type = ED25519 badge section — a prerequisite for LDP issuance,
196
+ # fixed alongside it.
196
197
 
197
198
  def test_vc_jwt_with_ed25519_key_roundtrips(self, tmp_path,
198
199
  ed25519_key_files, capsys):
@@ -611,3 +611,26 @@ class TestEndToEndRoundtrip:
611
611
  token = OB3Verifier.extract_token_from_svg(signed_svg)
612
612
  restored = ob3_rsa_verifier.verify(token)
613
613
  assert restored.evidence_url == 'https://example.com/proof/123'
614
+
615
+
616
+ class TestJtiBinding:
617
+ # The jti claim binds the token to the credential id (OB3 §8.2). A validly
618
+ # signed token whose jti disagrees with (or omits) the credential id must be
619
+ # rejected — sign_payload lets us forge exactly that.
620
+ def test_mismatched_jti_raises(
621
+ self, ob3_rsa_signer, ob3_rsa_verifier, ob3_credential
622
+ ):
623
+ payload = ob3_credential.to_jwt_payload()
624
+ payload['jti'] = 'urn:uuid:00000000-0000-0000-0000-000000000000'
625
+ token = ob3_rsa_signer.sign_payload(payload)
626
+ with pytest.raises(OB3VerificationError, match="jti"):
627
+ ob3_rsa_verifier.verify(token)
628
+
629
+ def test_missing_jti_raises(
630
+ self, ob3_rsa_signer, ob3_rsa_verifier, ob3_credential
631
+ ):
632
+ payload = ob3_credential.to_jwt_payload()
633
+ del payload['jti']
634
+ token = ob3_rsa_signer.sign_payload(payload)
635
+ with pytest.raises(OB3VerificationError, match="jti"):
636
+ ob3_rsa_verifier.verify(token)
@@ -1,85 +0,0 @@
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
- import os
26
- from typing import Any
27
-
28
-
29
- def enable_debug_logging(debug: bool = False) -> None:
30
- """Route library log messages to the console for the CLI tools.
31
-
32
- With ``debug=True`` DEBUG-level messages are shown; otherwise only INFO and
33
- above. Idempotent — safe to call once per CLI invocation (it will not stack
34
- duplicate console handlers).
35
- """
36
- root = logging.getLogger()
37
- if not any(getattr(h, '_obl_console', False) for h in root.handlers):
38
- handler: Any = logging.StreamHandler()
39
- handler.setFormatter(logging.Formatter("%(levelname)s - %(message)s"))
40
- handler._obl_console = True
41
- root.addHandler(handler)
42
- root.setLevel(logging.DEBUG if debug else logging.INFO)
43
-
44
-
45
- class Logger():
46
- def __init__(self, *args: Any, **kwargs: Any) -> None:
47
- self.main = self.init_log(logger='general', base_log=kwargs['base_log'],
48
- file=kwargs['general'])
49
- self.signer = self.init_log(logger='signer', base_log=kwargs['base_log'],
50
- file=kwargs['signer'])
51
- try:
52
- self.console = self.init_console(show_debug=kwargs['show_debug'])
53
- except KeyError:
54
- self.console = self.init_console()
55
-
56
- def init_log(self, logger: Any = '', base_log: Any = None,
57
- log_level: int = logging.INFO,
58
- file: Any = None) -> logging.Logger:
59
- logger = logging.getLogger(logger)
60
- logger.setLevel(logging.DEBUG)
61
- file_path = os.path.join(base_log, file)
62
-
63
- """ Create a file handler """
64
- handler = logging.FileHandler(file_path, "a",
65
- encoding='utf-8', delay=False)
66
- handler.setLevel(log_level)
67
- formatter = logging.Formatter("%(asctime)s %(message)s")
68
- handler.setFormatter(formatter)
69
- logger.addHandler(handler)
70
-
71
- return logger
72
-
73
- def init_console(self, show_debug: bool = False) -> logging.Logger:
74
- logger = logging.getLogger()
75
- logger.setLevel(logging.NOTSET)
76
-
77
- """ Console a console handler """
78
- handler = logging.StreamHandler()
79
- if not show_debug:
80
- handler.setLevel(logging.INFO)
81
- formatter = logging.Formatter("%(levelname)s - %(message)s")
82
- handler.setFormatter(formatter)
83
- logger.addHandler(handler)
84
-
85
- return logger
File without changes
File without changes
File without changes
File without changes