openbadgeslib 3.8.0__tar.gz → 3.9.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.8.0 → openbadgeslib-3.9.0}/Changelog.txt +27 -0
  2. {openbadgeslib-3.8.0/openbadgeslib.egg-info → openbadgeslib-3.9.0}/PKG-INFO +1 -1
  3. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/__init__.py +7 -2
  4. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/credential.py +161 -6
  5. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/verifier.py +99 -1
  6. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/openbadges_verifier.py +8 -0
  7. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/util.py +1 -1
  8. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0/openbadgeslib.egg-info}/PKG-INFO +1 -1
  9. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib.egg-info/SOURCES.txt +1 -0
  10. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_credential.py +118 -0
  11. openbadgeslib-3.9.0/tests/test_ob3_endorsement.py +188 -0
  12. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/LICENSE.txt +0 -0
  13. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/MANIFEST.in +0 -0
  14. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/README.md +0 -0
  15. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/SECURITY.md +0 -0
  16. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/docs/README.md +0 -0
  17. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/__init__.py +0 -0
  18. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/_jws/__init__.py +0 -0
  19. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/_jws/exceptions.py +0 -0
  20. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/_jws/utils.py +0 -0
  21. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/badge.py +0 -0
  22. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/baking.py +0 -0
  23. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/config.ini.example +0 -0
  24. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/confparser.py +0 -0
  25. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/errors.py +0 -0
  26. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/keys.py +0 -0
  27. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/logs.py +0 -0
  28. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/mail.py +0 -0
  29. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob1/__init__.py +0 -0
  30. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob1/badge.py +0 -0
  31. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob1/signer.py +0 -0
  32. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob1/verifier.py +0 -0
  33. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob2/__init__.py +0 -0
  34. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob2/models.py +0 -0
  35. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob2/signer.py +0 -0
  36. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob2/verifier.py +0 -0
  37. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/contexts/__init__.py +0 -0
  38. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
  39. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
  40. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
  41. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
  42. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
  43. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
  44. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/did.py +0 -0
  45. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/eudi.py +0 -0
  46. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/ldp.py +0 -0
  47. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/signer.py +0 -0
  48. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/status.py +0 -0
  49. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/status_list.py +0 -0
  50. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/status_registry.py +0 -0
  51. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/openbadges_init.py +0 -0
  52. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/openbadges_keygenerator.py +0 -0
  53. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/openbadges_publish.py +0 -0
  54. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/openbadges_signer.py +0 -0
  55. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/py.typed +0 -0
  56. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/signer.py +0 -0
  57. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/verifier.py +0 -0
  58. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib.egg-info/dependency_links.txt +0 -0
  59. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib.egg-info/entry_points.txt +0 -0
  60. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib.egg-info/requires.txt +0 -0
  61. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib.egg-info/top_level.txt +0 -0
  62. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/pyproject.toml +0 -0
  63. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/setup.cfg +0 -0
  64. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/config1.ini +0 -0
  65. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/conformance/Dockerfile.ob3 +0 -0
  66. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/conformance/README.md +0 -0
  67. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/conformance/conftest.py +0 -0
  68. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/conformance/docker-compose.yml +0 -0
  69. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/conformance/test_official_validators.py +0 -0
  70. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/conftest.py +0 -0
  71. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/fixtures/ob_schemas/README.md +0 -0
  72. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
  73. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
  74. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/fixtures/ob_schemas/refresh.sh +0 -0
  75. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/fixtures/vc_di_eddsa/README.md +0 -0
  76. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
  77. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
  78. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
  79. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
  80. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
  81. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/images/sample1.png +0 -0
  82. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/images/sample1.svg +0 -0
  83. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/images/userimage01.svg +0 -0
  84. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/ldp_helpers.py +0 -0
  85. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/logo Python Espan/314/203a.svg" +0 -0
  86. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/logo Python Espa/303/261a.svg" +0 -0
  87. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_cli_json.py +0 -0
  88. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_cli_smoke.py +0 -0
  89. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_confparser.py +0 -0
  90. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_docs.py +0 -0
  91. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_eddsa.py +0 -0
  92. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_jws.py +0 -0
  93. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_key_operation.py +0 -0
  94. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob1_badge_io.py +0 -0
  95. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob1_deprecation.py +0 -0
  96. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob1_signer.py +0 -0
  97. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob1_verifier.py +0 -0
  98. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob2_cli.py +0 -0
  99. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob2_models.py +0 -0
  100. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob2_signer.py +0 -0
  101. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob2_verifier.py +0 -0
  102. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_conformance_schema.py +0 -0
  103. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_contexts.py +0 -0
  104. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_did.py +0 -0
  105. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_did_doc.py +0 -0
  106. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_eudi_sd_jwt.py +0 -0
  107. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_ldp.py +0 -0
  108. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_ldp_cli.py +0 -0
  109. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_ldp_sign.py +0 -0
  110. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_publish_cli.py +0 -0
  111. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_signer.py +0 -0
  112. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_signer_cli_ldp.py +0 -0
  113. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_status.py +0 -0
  114. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_status_list.py +0 -0
  115. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_status_registry.py +0 -0
  116. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_verifier.py +0 -0
  117. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_sign_ecc.pem +0 -0
  118. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_sign_rsa.pem +0 -0
  119. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_util.py +0 -0
  120. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_verify_ecc.pem +0 -0
  121. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_verify_rsa.pem +0 -0
  122. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/withoutxmlheader.svg +0 -0
  123. {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/withxmlheader.svg +0 -0
@@ -4,6 +4,33 @@ OpenBadgesLib - Changelog
4
4
  Newest first. Dates are ISO 8601 (YYYY-MM-DD).
5
5
 
6
6
 
7
+ * v3.9.0 - 2026-07-08
8
+
9
+ - feat(ob3): broaden the credential data model toward the OB 3.0 spec — the
10
+ gap versus the spec was the model, not the crypto. New `Evidence` (the full
11
+ `evidence` array: narrative, name, description, genre, audience — beyond the
12
+ single `evidence_url`) and `Alignment` (competency-framework mappings, what
13
+ LMSes read to map a badge to a competency) dataclasses, plus
14
+ `achievementType`, `creditsAvailable` and an `alignments` list on
15
+ `Achievement`. All are validated in the house style (dataclasses + mypy, no
16
+ Pydantic), parse from a verified credential, round-trip through `to_vc`, and
17
+ still satisfy the offline conformance schema. (Refs #162 —
18
+ Result/ResultDescription, IdentityObject and creditsEarned follow.)
19
+
20
+ - feat(ob3): support OpenBadges 3.0 `endorsementJwt` (spec errata v1.6, added
21
+ to context 3.0.3). Third-party endorsement JWTs were passing through as
22
+ opaque, unmapped fields; the credential model now exposes them at the three
23
+ levels the spec allows — `credential.endorsement_jwts`,
24
+ `issuer.endorsement_jwts`, `achievement.endorsement_jwts`, gathered by
25
+ `credential.all_endorsement_jwts()` — and round-trips them through `to_vc`.
26
+ A new `ob3.verify_endorsement_jwt(token, download=None,
27
+ endorser_pubkey_pem=None)` verifies an EndorsementCredential JWT under the
28
+ *endorser's* key (resolved from its own issuer did:web/did:key, or an
29
+ explicit key when the endorser is not a DID), checking the
30
+ EndorsementCredential type and its validFrom/validUntil window.
31
+ `openbadges-verifier --json` now reports the `endorsements` count. (#161)
32
+
33
+
7
34
  * v3.8.0 - 2026-07-08
8
35
 
9
36
  - security(ob3): status lists gain replay protection and opt-in proof
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openbadgeslib
3
- Version: 3.8.0
3
+ Version: 3.9.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
@@ -20,9 +20,11 @@
20
20
  License along with this library.
21
21
  """
22
22
 
23
- from .credential import Achievement, Issuer, OpenBadgeCredential
23
+ from .credential import (Achievement, Alignment, Evidence, Issuer,
24
+ OpenBadgeCredential)
24
25
  from .signer import OB3Signer
25
- from .verifier import OB3VerificationError, OB3Verifier
26
+ from .verifier import (OB3VerificationError, OB3Verifier,
27
+ verify_endorsement_jwt)
26
28
  from .ldp import (OB3LdpSigner, OB3LdpVerifier, add_data_integrity_proof,
27
29
  verify_data_integrity_proof)
28
30
  from .status import check_credential_status
@@ -34,6 +36,8 @@ from .did import (build_did_document, did_key_from_pem, did_web_from_url,
34
36
 
35
37
  __all__ = [
36
38
  'Achievement',
39
+ 'Alignment',
40
+ 'Evidence',
37
41
  'Issuer',
38
42
  'OpenBadgeCredential',
39
43
  'OB3LdpSigner',
@@ -55,4 +59,5 @@ __all__ = [
55
59
  'resolve_verification_method',
56
60
  'sign_status_list_credential',
57
61
  'status_entry',
62
+ 'verify_endorsement_jwt',
58
63
  ]
@@ -86,6 +86,10 @@ class Issuer:
86
86
  url: Optional[str] = None
87
87
  email: Optional[str] = None
88
88
  image_url: Optional[str] = None
89
+ # Compact EndorsementCredential JWTs vouching for this profile (OB 3.0
90
+ # `endorsementJwt`, added to context 3.0.3 by errata v1.6). Verifiable with
91
+ # verify_endorsement_jwt; the endorser is a third party, not this issuer.
92
+ endorsement_jwts: List[str] = field(default_factory=list)
89
93
 
90
94
  def to_dict(self) -> dict[str, Any]:
91
95
  d: dict[str, Any] = {"id": self.id, "type": ["Profile"], "name": self.name}
@@ -95,9 +99,82 @@ class Issuer:
95
99
  d["email"] = self.email
96
100
  if self.image_url:
97
101
  d["image"] = {"id": self.image_url, "type": "Image"}
102
+ if self.endorsement_jwts:
103
+ d["endorsementJwt"] = list(self.endorsement_jwts)
98
104
  return d
99
105
 
100
106
 
107
+ @dataclass
108
+ class Alignment:
109
+ """Alignment of an achievement to a node in an educational framework
110
+ (OB 3.0 `alignment`) — what an LMS reads to map a badge to a competency."""
111
+
112
+ target_name: str
113
+ target_url: str
114
+ target_framework: Optional[str] = None
115
+ target_code: Optional[str] = None
116
+ target_description: Optional[str] = None
117
+
118
+ def to_dict(self) -> dict[str, Any]:
119
+ d: dict[str, Any] = {
120
+ "type": ["Alignment"],
121
+ "targetName": self.target_name,
122
+ "targetUrl": self.target_url,
123
+ }
124
+ if self.target_framework:
125
+ d["targetFramework"] = self.target_framework
126
+ if self.target_code:
127
+ d["targetCode"] = self.target_code
128
+ if self.target_description:
129
+ d["targetDescription"] = self.target_description
130
+ return d
131
+
132
+ @classmethod
133
+ def from_dict(cls, data: dict[str, Any]) -> "Alignment":
134
+ return cls(
135
+ target_name=_require(data, "targetName", "alignment"),
136
+ target_url=_require(data, "targetUrl", "alignment"),
137
+ target_framework=data.get("targetFramework"),
138
+ target_code=data.get("targetCode"),
139
+ target_description=data.get("targetDescription"),
140
+ )
141
+
142
+
143
+ @dataclass
144
+ class Evidence:
145
+ """A piece of evidence supporting a credential (OB 3.0 `evidence`), richer
146
+ than a bare URL: an optional narrative and descriptive metadata."""
147
+
148
+ id: Optional[str] = None # dereferenceable URL of the evidence
149
+ narrative: Optional[str] = None
150
+ name: Optional[str] = None
151
+ description: Optional[str] = None
152
+ genre: Optional[str] = None
153
+ audience: Optional[str] = None
154
+
155
+ def to_dict(self) -> dict[str, Any]:
156
+ d: dict[str, Any] = {"type": ["Evidence"]}
157
+ if self.id:
158
+ d["id"] = self.id
159
+ for key, value in (("narrative", self.narrative), ("name", self.name),
160
+ ("description", self.description),
161
+ ("genre", self.genre), ("audience", self.audience)):
162
+ if value:
163
+ d[key] = value
164
+ return d
165
+
166
+ @classmethod
167
+ def from_dict(cls, data: dict[str, Any]) -> "Evidence":
168
+ return cls(
169
+ id=data.get("id"),
170
+ narrative=data.get("narrative"),
171
+ name=data.get("name"),
172
+ description=data.get("description"),
173
+ genre=data.get("genre"),
174
+ audience=data.get("audience"),
175
+ )
176
+
177
+
101
178
  @dataclass
102
179
  class Achievement:
103
180
  """A badge class / achievement definition."""
@@ -108,6 +185,16 @@ class Achievement:
108
185
  criteria_narrative: str
109
186
  image_url: Optional[str] = None
110
187
  tags: List[str] = field(default_factory=list)
188
+ # Compact EndorsementCredential JWTs vouching for this achievement
189
+ # (OB 3.0 `endorsementJwt`). See Issuer.endorsement_jwts.
190
+ endorsement_jwts: List[str] = field(default_factory=list)
191
+ # Kind of achievement (OB 3.0 `achievementType`, e.g. 'Badge',
192
+ # 'Certificate', 'Competency', …) and any academic credit it carries.
193
+ achievement_type: Optional[str] = None
194
+ credits_available: Optional[float] = None
195
+ # Framework alignments (OB 3.0 `alignment`): the competency mappings LMSes
196
+ # consume. Empty when the achievement declares none.
197
+ alignments: List[Alignment] = field(default_factory=list)
111
198
 
112
199
  def to_dict(self) -> dict[str, Any]:
113
200
  d: dict[str, Any] = {
@@ -117,10 +204,18 @@ class Achievement:
117
204
  "description": self.description,
118
205
  "criteria": {"narrative": self.criteria_narrative},
119
206
  }
207
+ if self.achievement_type:
208
+ d["achievementType"] = self.achievement_type
209
+ if self.credits_available is not None:
210
+ d["creditsAvailable"] = self.credits_available
120
211
  if self.image_url:
121
212
  d["image"] = {"id": self.image_url, "type": "Image"}
122
213
  if self.tags:
123
214
  d["tag"] = self.tags
215
+ if self.alignments:
216
+ d["alignment"] = [a.to_dict() for a in self.alignments]
217
+ if self.endorsement_jwts:
218
+ d["endorsementJwt"] = list(self.endorsement_jwts)
124
219
  return d
125
220
 
126
221
 
@@ -137,11 +232,20 @@ class OpenBadgeCredential:
137
232
  name: Optional[str] = None # defaults to achievement.name
138
233
  issuance_date: Optional[datetime] = None # defaults to now (UTC)
139
234
  expiration_date: Optional[datetime] = None
235
+ # Backward-compatible single-URL evidence convenience. For the full OB 3.0
236
+ # evidence list (narrative, name, …) use `evidence`; when both are set the
237
+ # list wins. Parsing populates both (evidence_url = evidence[0].id).
140
238
  evidence_url: Optional[str] = None
239
+ evidence: List["Evidence"] = field(default_factory=list)
141
240
  # Raw credentialStatus entries (Bitstring Status List / StatusList2021),
142
241
  # normalised to a list of objects. Consumed by ob3.status to check
143
242
  # revocation; empty when the credential carries no status.
144
243
  credential_status: List[dict[str, Any]] = field(default_factory=list)
244
+ # Compact EndorsementCredential JWTs vouching for the credential itself
245
+ # (top-level OB 3.0 `endorsementJwt`). Endorsements attached to the issuer
246
+ # or the achievement live on those objects; all_endorsement_jwts() gathers
247
+ # the three. Empty when the credential carries none.
248
+ endorsement_jwts: List[str] = field(default_factory=list)
145
249
  # A read-only view of the already-validated document this credential was
146
250
  # parsed from (set by the verifiers via _from_vc); None when built in-memory
147
251
  # to issue. Lets a caller read spec fields the model does not map —
@@ -195,14 +299,27 @@ class OpenBadgeCredential:
195
299
  vc["credentialSubject"]["id"] = self.recipient_id
196
300
  if self.expiration_date:
197
301
  vc["validUntil"] = _iso(self.expiration_date)
198
- if self.evidence_url:
199
- vc["evidence"] = [{"id": self.evidence_url, "type": ["Evidence"]}]
302
+ evidence_items = self.evidence or (
303
+ [Evidence(id=self.evidence_url)] if self.evidence_url else [])
304
+ if evidence_items:
305
+ vc["evidence"] = [e.to_dict() for e in evidence_items]
200
306
  if self.credential_status:
201
307
  vc["credentialStatus"] = (
202
308
  self.credential_status[0] if len(self.credential_status) == 1
203
309
  else self.credential_status)
310
+ if self.endorsement_jwts:
311
+ vc["endorsementJwt"] = list(self.endorsement_jwts)
204
312
  return vc
205
313
 
314
+ def all_endorsement_jwts(self) -> List[str]:
315
+ """Every compact EndorsementCredential JWT attached to this credential,
316
+ gathered from the credential itself and from its issuer and achievement
317
+ (OB 3.0 allows `endorsementJwt` at all three levels). Verify each with
318
+ :func:`openbadgeslib.ob3.verify_endorsement_jwt`."""
319
+ return [*self.endorsement_jwts,
320
+ *self.issuer.endorsement_jwts,
321
+ *self.achievement.endorsement_jwts]
322
+
206
323
  def to_jwt_payload(self) -> dict[str, Any]:
207
324
  """Return the JWT payload for a JWT-VC signed credential.
208
325
 
@@ -276,6 +393,7 @@ class OpenBadgeCredential:
276
393
  url=issuer_data.get("url"),
277
394
  email=issuer_data.get("email"),
278
395
  image_url=_as_dict_or_empty(issuer_data.get("image")).get("id"),
396
+ endorsement_jwts=_string_list(issuer_data.get("endorsementJwt")),
279
397
  )
280
398
 
281
399
  # credentialSubject may be a single object or a (non-empty) array.
@@ -290,6 +408,10 @@ class OpenBadgeCredential:
290
408
  "vc.credentialSubject.achievement")
291
409
  criteria = _as_dict_or_empty(ach_data.get("criteria"))
292
410
  image = _as_dict_or_empty(ach_data.get("image"))
411
+ alignment_raw = ach_data.get("alignment")
412
+ alignments = [Alignment.from_dict(a) for a in alignment_raw
413
+ if isinstance(a, dict)] \
414
+ if isinstance(alignment_raw, list) else []
293
415
  achievement = Achievement(
294
416
  id=_require(ach_data, "id", "vc.credentialSubject.achievement"),
295
417
  name=_require(ach_data, "name", "vc.credentialSubject.achievement"),
@@ -297,6 +419,10 @@ class OpenBadgeCredential:
297
419
  criteria_narrative=criteria.get("narrative", ""),
298
420
  image_url=image.get("id"),
299
421
  tags=ach_data.get("tag", []),
422
+ endorsement_jwts=_string_list(ach_data.get("endorsementJwt")),
423
+ achievement_type=ach_data.get("achievementType"),
424
+ credits_available=_float_or_none(ach_data.get("creditsAvailable")),
425
+ alignments=alignments,
300
426
  )
301
427
 
302
428
  # Accept both VC 2.0 (validFrom/validUntil) and VC 1.1
@@ -306,10 +432,16 @@ class OpenBadgeCredential:
306
432
  expires = vc.get("validUntil") or vc.get("expirationDate")
307
433
  expiration_date = _parse_date(expires, "vc.validUntil") if expires else None
308
434
 
309
- evidence_url = None
310
- evidence = vc.get("evidence")
311
- if isinstance(evidence, list) and evidence and isinstance(evidence[0], dict):
312
- evidence_url = evidence[0].get("id")
435
+ evidence_raw = vc.get("evidence")
436
+ if isinstance(evidence_raw, list):
437
+ evidence_list = [Evidence.from_dict(e) for e in evidence_raw
438
+ if isinstance(e, dict)]
439
+ elif isinstance(evidence_raw, dict):
440
+ evidence_list = [Evidence.from_dict(evidence_raw)]
441
+ else:
442
+ evidence_list = []
443
+ # Backward-compatible convenience: the first evidence's URL.
444
+ evidence_url = evidence_list[0].id if evidence_list else None
313
445
 
314
446
  # credentialStatus may be a single object or an array; keep only object
315
447
  # entries so the status checker can rely on .get() without crashing.
@@ -343,7 +475,9 @@ class OpenBadgeCredential:
343
475
  issuance_date=issuance_date,
344
476
  expiration_date=expiration_date,
345
477
  evidence_url=evidence_url,
478
+ evidence=evidence_list,
346
479
  credential_status=credential_status,
480
+ endorsement_jwts=_string_list(vc.get("endorsementJwt")),
347
481
  )
348
482
  # Keep the validated document so the caller can read fields the model
349
483
  # does not map (see the ``raw`` field). This is the JWT-VC payload or
@@ -364,6 +498,27 @@ def _as_dict_or_empty(value: Any) -> dict[str, Any]:
364
498
  return value if isinstance(value, dict) else {}
365
499
 
366
500
 
501
+ def _float_or_none(value: Any) -> Optional[float]:
502
+ """Coerce a JSON number to float, else None (for optional numeric fields
503
+ like creditsAvailable). A bool is not a number here."""
504
+ if isinstance(value, bool):
505
+ return None
506
+ if isinstance(value, (int, float)):
507
+ return float(value)
508
+ return None
509
+
510
+
511
+ def _string_list(value: Any) -> List[str]:
512
+ """Return the string members of *value* when it is a list, else an empty
513
+ list. `endorsementJwt` is an array of compact JWT strings; a single string
514
+ is also accepted (tolerant read of a producer that omitted the array)."""
515
+ if isinstance(value, str):
516
+ return [value]
517
+ if isinstance(value, list):
518
+ return [item for item in value if isinstance(item, str)]
519
+ return []
520
+
521
+
367
522
  def _require(data: dict[str, Any], key: str, where: str) -> str:
368
523
  """Return data[key] as a string, raising a clear ValueError if missing,
369
524
  empty, or not a string. All fields validated here (id/name) are identifiers
@@ -26,7 +26,7 @@ from typing import Any, Optional, Union
26
26
  import jwt
27
27
  import jwt.exceptions
28
28
 
29
- from .credential import OpenBadgeCredential
29
+ from .credential import OpenBadgeCredential, _parse_iso
30
30
  from ..errors import ErrorParsingFile, UnknownKeyType, LibOpenBadgesException
31
31
  from ..keys import KeyType, detect_key_type, key_to_pem
32
32
  from ..util import normalize_recipient_id, recipient_ids_match
@@ -382,6 +382,19 @@ class OB3Verifier:
382
382
 
383
383
  # ── token extraction ───────────────────────────────────────────────────────
384
384
 
385
+ @staticmethod
386
+ def _decode_unverified(token: str) -> dict[str, Any]:
387
+ """Decode a JWT payload without checking the signature (to read the
388
+ issuer before the key is resolved). Raises OB3VerificationError on a
389
+ malformed token."""
390
+ try:
391
+ payload = jwt.decode(token, options={"verify_signature": False})
392
+ except jwt.exceptions.DecodeError as exc:
393
+ raise OB3VerificationError("malformed JWT: %s" % exc) from exc
394
+ if not isinstance(payload, dict):
395
+ raise OB3VerificationError("JWT payload is not an object")
396
+ return payload
397
+
385
398
  @staticmethod
386
399
  def extract_token_from_svg(svg_bytes: bytes) -> str:
387
400
  """Extract the embedded credential from a baked SVG badge.
@@ -418,3 +431,88 @@ class OB3Verifier:
418
431
  if token is None:
419
432
  raise OB3VerificationError("No openbadgecredential iTXt chunk found in PNG")
420
433
  return token
434
+
435
+
436
+ # ── endorsements (OB 3.0 endorsementJwt, errata v1.6) ────────────────────────
437
+
438
+ def verify_endorsement_jwt(token: str, download: Any = None,
439
+ endorser_pubkey_pem: Any = None) -> dict[str, Any]:
440
+ """Verify a compact EndorsementCredential JWT (OB 3.0 ``endorsementJwt``).
441
+
442
+ An endorsement is a Verifiable Credential a **third party** (the endorser,
443
+ not the badge issuer) signs to vouch for an achievement, issuer or
444
+ credential. This verifies its signature under the endorser's key — resolved
445
+ from the endorsement's own issuer DID (``did:web``/``did:key``), or
446
+ ``endorser_pubkey_pem`` when the endorser is not a DID — checks it is an
447
+ ``EndorsementCredential`` whose validFrom/validUntil window is current, and
448
+ returns ``{id, issuer, endorses, comment}``.
449
+
450
+ Raises :class:`OB3VerificationError` on any failure (malformed token, bad
451
+ signature, wrong type, expired, or an endorser that is neither a DID nor
452
+ covered by ``endorser_pubkey_pem``). The verifier machinery is reused, but
453
+ OB3Verifier.verify() cannot: an EndorsementCredential is not an
454
+ OpenBadgeCredential, so its type check would reject it.
455
+ """
456
+ if not isinstance(token, str) or token.lstrip().startswith('{'):
457
+ raise OB3VerificationError(
458
+ "endorsementJwt must be a compact JWT string")
459
+
460
+ unverified = OB3Verifier._decode_unverified(token)
461
+ endorser = _claim_object_id(unverified.get("issuer")) or unverified.get("iss")
462
+
463
+ if endorser_pubkey_pem is not None:
464
+ verifier = OB3Verifier(pubkey_pem=endorser_pubkey_pem)
465
+ elif isinstance(endorser, str) and endorser.startswith("did:"):
466
+ verifier = OB3Verifier.for_issuer_did(endorser, download=download)
467
+ else:
468
+ raise OB3VerificationError(
469
+ "cannot verify endorsement: its issuer %r is not a DID — pass "
470
+ "endorser_pubkey_pem" % (endorser,))
471
+
472
+ payload = verifier._decode_payload(token) # signature + algorithm pin
473
+
474
+ types = payload.get("type", [])
475
+ if isinstance(types, str):
476
+ types = [types]
477
+ if "EndorsementCredential" not in (types if isinstance(types, list) else []):
478
+ raise OB3VerificationError(
479
+ "not an EndorsementCredential (type=%r)" % (payload.get("type"),))
480
+
481
+ _check_endorsement_window(payload)
482
+
483
+ subject = payload.get("credentialSubject")
484
+ if isinstance(subject, list):
485
+ subject = subject[0] if subject else {}
486
+ if not isinstance(subject, dict):
487
+ subject = {}
488
+ return {
489
+ "id": payload.get("id"),
490
+ "issuer": endorser,
491
+ "endorses": subject.get("id"),
492
+ "comment": subject.get("endorsementComment"),
493
+ }
494
+
495
+
496
+ def _check_endorsement_window(vc: dict[str, Any]) -> None:
497
+ """Reject an endorsement whose validFrom/validUntil window is not current
498
+ (the JWT exp is also checked in _decode_payload, but a producer may set a
499
+ vc-level bound without the registered claim)."""
500
+ now = datetime.now(timezone.utc)
501
+ valid_until = vc.get("validUntil")
502
+ if isinstance(valid_until, str):
503
+ try:
504
+ expires = _parse_iso(valid_until)
505
+ except Exception:
506
+ expires = None
507
+ if expires is not None and expires < now:
508
+ raise OB3VerificationError(
509
+ "endorsement expired (validUntil %s)" % valid_until)
510
+ valid_from = vc.get("validFrom")
511
+ if isinstance(valid_from, str):
512
+ try:
513
+ starts = _parse_iso(valid_from)
514
+ except Exception:
515
+ starts = None
516
+ if starts is not None and starts > now:
517
+ raise OB3VerificationError(
518
+ "endorsement is not yet valid (validFrom %s)" % valid_from)
@@ -435,6 +435,11 @@ def _verify_ob3(args: argparse.Namespace) -> None:
435
435
  result['expires'] = (credential.expiration_date.isoformat()
436
436
  if credential.expiration_date else None)
437
437
  result['evidence'] = credential.evidence_url
438
+ # Surface how many EndorsementCredential JWTs the credential carries (on
439
+ # itself, its issuer or its achievement) so they are no longer invisible.
440
+ # They are third-party VCs; verify each with ob3.verify_endorsement_jwt.
441
+ endorsement_jwts = credential.all_endorsement_jwts()
442
+ result['endorsements'] = len(endorsement_jwts)
438
443
 
439
444
  if args.show and not args.json:
440
445
  print('[+] Credential issuer : %s' % credential.issuer.name)
@@ -445,6 +450,9 @@ def _verify_ob3(args: argparse.Namespace) -> None:
445
450
  print('[+] Expires : %s' % credential.expiration_date.isoformat())
446
451
  if credential.evidence_url:
447
452
  print('[+] Evidence : %s' % credential.evidence_url)
453
+ if endorsement_jwts:
454
+ print('[+] Endorsements : %d (verify with '
455
+ 'ob3.verify_endorsement_jwt)' % len(endorsement_jwts))
448
456
 
449
457
  if result['trusted']:
450
458
  result['reason'] = None
@@ -21,7 +21,7 @@
21
21
  License along with this library.
22
22
  """
23
23
 
24
- __version__ = '3.8.0'
24
+ __version__ = '3.9.0'
25
25
 
26
26
  import contextlib
27
27
  import hashlib
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openbadgeslib
3
- Version: 3.8.0
3
+ Version: 3.9.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
@@ -83,6 +83,7 @@ tests/test_ob3_contexts.py
83
83
  tests/test_ob3_credential.py
84
84
  tests/test_ob3_did.py
85
85
  tests/test_ob3_did_doc.py
86
+ tests/test_ob3_endorsement.py
86
87
  tests/test_ob3_eudi_sd_jwt.py
87
88
  tests/test_ob3_ldp.py
88
89
  tests/test_ob3_ldp_cli.py
@@ -330,3 +330,121 @@ class TestNaiveDatetimeAssumedUtc:
330
330
  cred = _make_credential(expiration_date=datetime(2030, 6, 1, 0, 0, 0))
331
331
  assert cred.expiration_date.tzinfo is not None
332
332
  assert cred.to_vc()['validUntil'] == '2030-06-01T00:00:00Z'
333
+
334
+
335
+ # ── #162: broadened model — Evidence, Alignment, achievementType, credits ────
336
+
337
+ from openbadgeslib.ob3.credential import Alignment, Evidence # noqa: E402
338
+
339
+
340
+ class TestBroadenedModel:
341
+ def _credential(self, **achievement_kw):
342
+ return OpenBadgeCredential(
343
+ issuer=Issuer(id='https://issuer.example', name='I'),
344
+ recipient_id='mailto:r@example.com',
345
+ achievement=Achievement(id='https://a.example/1', name='A',
346
+ description='d', criteria_narrative='c',
347
+ **achievement_kw))
348
+
349
+ def test_alignment_round_trip(self):
350
+ cred = self._credential(alignments=[
351
+ Alignment(target_name='Competency X',
352
+ target_url='https://framework.example/x',
353
+ target_framework='EU Framework', target_code='X.1'),
354
+ ])
355
+ vc = cred.to_vc()
356
+ assert vc['credentialSubject']['achievement']['alignment'] == [{
357
+ 'type': ['Alignment'],
358
+ 'targetName': 'Competency X',
359
+ 'targetUrl': 'https://framework.example/x',
360
+ 'targetFramework': 'EU Framework',
361
+ 'targetCode': 'X.1',
362
+ }]
363
+ again = OpenBadgeCredential.from_vc_document(vc)
364
+ assert again.achievement.alignments[0].target_name == 'Competency X'
365
+ assert again.achievement.alignments[0].target_code == 'X.1'
366
+
367
+ def test_achievement_type_and_credits_round_trip(self):
368
+ cred = self._credential(achievement_type='Competency',
369
+ credits_available=3.5)
370
+ ach = cred.to_vc()['credentialSubject']['achievement']
371
+ assert ach['achievementType'] == 'Competency'
372
+ assert ach['creditsAvailable'] == 3.5
373
+ again = OpenBadgeCredential.from_vc_document(cred.to_vc())
374
+ assert again.achievement.achievement_type == 'Competency'
375
+ assert again.achievement.credits_available == 3.5
376
+
377
+ def test_evidence_list_round_trip(self):
378
+ cred = OpenBadgeCredential(
379
+ issuer=Issuer(id='https://issuer.example', name='I'),
380
+ recipient_id='mailto:r@example.com',
381
+ achievement=Achievement(id='https://a.example/1', name='A',
382
+ description='d', criteria_narrative='c'),
383
+ evidence=[
384
+ Evidence(id='https://ev.example/1', narrative='Did the thing',
385
+ name='Project'),
386
+ Evidence(narrative='No URL, just a narrative'),
387
+ ])
388
+ vc = cred.to_vc()
389
+ assert len(vc['evidence']) == 2
390
+ assert vc['evidence'][0] == {
391
+ 'type': ['Evidence'], 'id': 'https://ev.example/1',
392
+ 'narrative': 'Did the thing', 'name': 'Project'}
393
+ assert vc['evidence'][1] == {
394
+ 'type': ['Evidence'], 'narrative': 'No URL, just a narrative'}
395
+ again = OpenBadgeCredential.from_vc_document(vc)
396
+ assert [e.narrative for e in again.evidence] == \
397
+ ['Did the thing', 'No URL, just a narrative']
398
+ # backward-compatible convenience: first evidence URL
399
+ assert again.evidence_url == 'https://ev.example/1'
400
+
401
+ def test_evidence_url_still_works(self):
402
+ # The single-URL convenience keeps issuing one Evidence object.
403
+ cred = self._credential()
404
+ cred.evidence_url = 'https://ev.example/only'
405
+ vc = cred.to_vc()
406
+ assert vc['evidence'] == [
407
+ {'type': ['Evidence'], 'id': 'https://ev.example/only'}]
408
+
409
+ def test_parse_from_raw_vc(self):
410
+ vc = {
411
+ '@context': OB3_CONTEXT,
412
+ 'id': 'urn:uuid:00000000-0000-0000-0000-0000000000f1',
413
+ 'type': ['VerifiableCredential', 'OpenBadgeCredential'],
414
+ 'issuer': 'https://issuer.example',
415
+ 'validFrom': '2024-01-01T00:00:00Z',
416
+ 'credentialSubject': {
417
+ 'id': 'mailto:r@example.com',
418
+ 'type': ['AchievementSubject'],
419
+ 'achievement': {
420
+ 'id': 'https://a.example/1', 'type': ['Achievement'],
421
+ 'name': 'A', 'description': 'd',
422
+ 'criteria': {'narrative': 'c'},
423
+ 'achievementType': 'Certificate',
424
+ 'creditsAvailable': 2,
425
+ 'alignment': [{'type': ['Alignment'],
426
+ 'targetName': 'C', 'targetUrl': 'https://f/x'}],
427
+ },
428
+ },
429
+ 'evidence': [{'type': ['Evidence'], 'id': 'https://ev/1',
430
+ 'narrative': 'n'}],
431
+ }
432
+ cred = OpenBadgeCredential.from_vc_document(vc)
433
+ assert cred.achievement.achievement_type == 'Certificate'
434
+ assert cred.achievement.credits_available == 2.0
435
+ assert cred.achievement.alignments[0].target_url == 'https://f/x'
436
+ assert cred.evidence[0].narrative == 'n'
437
+
438
+ def test_signed_credential_preserves_new_fields(self, rsa_priv_pem,
439
+ rsa_pub_pem):
440
+ # End-to-end: the fields survive signing and verification.
441
+ from openbadgeslib.ob3 import OB3Signer, OB3Verifier
442
+ cred = self._credential(achievement_type='Competency',
443
+ credits_available=1.0,
444
+ alignments=[Alignment(
445
+ target_name='Skill',
446
+ target_url='https://f/skill')])
447
+ token = OB3Signer(privkey_pem=rsa_priv_pem, algorithm='RS256').sign(cred)
448
+ verified = OB3Verifier(pubkey_pem=rsa_pub_pem).verify(token)
449
+ assert verified.achievement.achievement_type == 'Competency'
450
+ assert verified.achievement.alignments[0].target_name == 'Skill'