openbadgeslib 3.7.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.7.0 → openbadgeslib-3.9.0}/Changelog.txt +45 -0
  2. {openbadgeslib-3.7.0/openbadgeslib.egg-info → openbadgeslib-3.9.0}/PKG-INFO +1 -1
  3. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/confparser.py +19 -1
  4. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/__init__.py +7 -2
  5. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/credential.py +161 -6
  6. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/status.py +139 -13
  7. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/status_list.py +12 -2
  8. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/verifier.py +111 -4
  9. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/openbadges_publish.py +7 -2
  10. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/openbadges_verifier.py +8 -0
  11. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/util.py +1 -1
  12. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0/openbadgeslib.egg-info}/PKG-INFO +1 -1
  13. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib.egg-info/SOURCES.txt +1 -0
  14. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_ob3_credential.py +118 -0
  15. openbadgeslib-3.9.0/tests/test_ob3_endorsement.py +188 -0
  16. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_ob3_publish_cli.py +55 -0
  17. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_ob3_status.py +123 -0
  18. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/LICENSE.txt +0 -0
  19. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/MANIFEST.in +0 -0
  20. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/README.md +0 -0
  21. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/SECURITY.md +0 -0
  22. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/docs/README.md +0 -0
  23. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/__init__.py +0 -0
  24. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/_jws/__init__.py +0 -0
  25. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/_jws/exceptions.py +0 -0
  26. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/_jws/utils.py +0 -0
  27. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/badge.py +0 -0
  28. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/baking.py +0 -0
  29. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/config.ini.example +0 -0
  30. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/errors.py +0 -0
  31. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/keys.py +0 -0
  32. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/logs.py +0 -0
  33. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/mail.py +0 -0
  34. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob1/__init__.py +0 -0
  35. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob1/badge.py +0 -0
  36. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob1/signer.py +0 -0
  37. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob1/verifier.py +0 -0
  38. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob2/__init__.py +0 -0
  39. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob2/models.py +0 -0
  40. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob2/signer.py +0 -0
  41. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob2/verifier.py +0 -0
  42. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/contexts/__init__.py +0 -0
  43. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
  44. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
  45. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
  46. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
  47. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
  48. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
  49. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/did.py +0 -0
  50. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/eudi.py +0 -0
  51. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/ldp.py +0 -0
  52. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/signer.py +0 -0
  53. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/status_registry.py +0 -0
  54. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/openbadges_init.py +0 -0
  55. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/openbadges_keygenerator.py +0 -0
  56. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/openbadges_signer.py +0 -0
  57. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/py.typed +0 -0
  58. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/signer.py +0 -0
  59. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib/verifier.py +0 -0
  60. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib.egg-info/dependency_links.txt +0 -0
  61. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib.egg-info/entry_points.txt +0 -0
  62. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib.egg-info/requires.txt +0 -0
  63. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/openbadgeslib.egg-info/top_level.txt +0 -0
  64. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/pyproject.toml +0 -0
  65. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/setup.cfg +0 -0
  66. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/config1.ini +0 -0
  67. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/conformance/Dockerfile.ob3 +0 -0
  68. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/conformance/README.md +0 -0
  69. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/conformance/conftest.py +0 -0
  70. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/conformance/docker-compose.yml +0 -0
  71. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/conformance/test_official_validators.py +0 -0
  72. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/conftest.py +0 -0
  73. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/fixtures/ob_schemas/README.md +0 -0
  74. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
  75. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
  76. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/fixtures/ob_schemas/refresh.sh +0 -0
  77. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/fixtures/vc_di_eddsa/README.md +0 -0
  78. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
  79. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
  80. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
  81. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
  82. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
  83. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/images/sample1.png +0 -0
  84. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/images/sample1.svg +0 -0
  85. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/images/userimage01.svg +0 -0
  86. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/ldp_helpers.py +0 -0
  87. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/logo Python Espan/314/203a.svg" +0 -0
  88. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/logo Python Espa/303/261a.svg" +0 -0
  89. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_cli_json.py +0 -0
  90. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_cli_smoke.py +0 -0
  91. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_confparser.py +0 -0
  92. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_docs.py +0 -0
  93. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_eddsa.py +0 -0
  94. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_jws.py +0 -0
  95. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_key_operation.py +0 -0
  96. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_ob1_badge_io.py +0 -0
  97. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_ob1_deprecation.py +0 -0
  98. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_ob1_signer.py +0 -0
  99. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_ob1_verifier.py +0 -0
  100. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_ob2_cli.py +0 -0
  101. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_ob2_models.py +0 -0
  102. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_ob2_signer.py +0 -0
  103. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_ob2_verifier.py +0 -0
  104. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_ob3_conformance_schema.py +0 -0
  105. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_ob3_contexts.py +0 -0
  106. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_ob3_did.py +0 -0
  107. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_ob3_did_doc.py +0 -0
  108. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_ob3_eudi_sd_jwt.py +0 -0
  109. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_ob3_ldp.py +0 -0
  110. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_ob3_ldp_cli.py +0 -0
  111. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_ob3_ldp_sign.py +0 -0
  112. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_ob3_signer.py +0 -0
  113. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_ob3_signer_cli_ldp.py +0 -0
  114. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_ob3_status_list.py +0 -0
  115. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_ob3_status_registry.py +0 -0
  116. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_ob3_verifier.py +0 -0
  117. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_sign_ecc.pem +0 -0
  118. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_sign_rsa.pem +0 -0
  119. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_util.py +0 -0
  120. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_verify_ecc.pem +0 -0
  121. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/test_verify_rsa.pem +0 -0
  122. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/withoutxmlheader.svg +0 -0
  123. {openbadgeslib-3.7.0 → openbadgeslib-3.9.0}/tests/withxmlheader.svg +0 -0
@@ -4,6 +4,51 @@ 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
+
34
+ * v3.8.0 - 2026-07-08
35
+
36
+ - security(ob3): status lists gain replay protection and opt-in proof
37
+ verification, closing two documented trust gaps. (1) A per-badge
38
+ `status_validity_days` makes published lists carry a `validUntil`, and the
39
+ reader now enforces the list's validFrom/validUntil window — a lapsed
40
+ (replayed, or no-longer-republished) `revocation.jwt` is rejected instead
41
+ of silently resurrecting a revoked badge. (2)
42
+ `check_credential_status(..., verify_list=True)` (and
43
+ `OB3Verifier.check_status(..., verify_list=True)`) additionally verifies
44
+ the status list credential's own JWT-VC proof and binds its issuer to the
45
+ badge's, so a compromised status host cannot serve a validly-shaped list
46
+ that silently un-revokes. The list is signed with the badge key, so that
47
+ same key (reused automatically) verifies it; a did:web/did:key issuer is
48
+ resolved otherwise. Both are opt-in — the default stays a single
49
+ offline-friendly fetch, no behaviour break. (#164)
50
+
51
+
7
52
  * v3.7.0 - 2026-07-08
8
53
 
9
54
  - security(deps): `keys.py` (and the OB1 key-loading path) are ported to
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openbadgeslib
3
- Version: 3.7.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
@@ -105,6 +105,7 @@ class OB3StatusConfig:
105
105
  size_bits: int
106
106
  registry_path: str # private index registry JSON
107
107
  list_urls: Dict[str, str] # purpose -> public status list URL
108
+ validity_days: Optional[int] = None # validUntil horizon; None => no bound
108
109
 
109
110
 
110
111
  def ob3_status_config(conf: ConfigParser,
@@ -140,6 +141,22 @@ def ob3_status_config(conf: ConfigParser,
140
141
  raise ValueError("[%s] status_size_bits must be an integer"
141
142
  % badge_section) from None
142
143
 
144
+ # Optional validUntil horizon: when set, published status lists carry a
145
+ # validUntil = now + N days, and a verifier rejects a stale copy (replay
146
+ # protection). Unset => no bound (offline-friendly default). The issuer
147
+ # must republish within the window (see the wiki).
148
+ validity_raw = (conf[badge_section].get('status_validity_days') or '').strip()
149
+ validity_days: Optional[int] = None
150
+ if validity_raw:
151
+ try:
152
+ validity_days = int(validity_raw)
153
+ except ValueError:
154
+ raise ValueError("[%s] status_validity_days must be an integer"
155
+ % badge_section) from None
156
+ if validity_days <= 0:
157
+ raise ValueError("[%s] status_validity_days must be positive"
158
+ % badge_section)
159
+
143
160
  base_status = conf['paths'].get('base_status') or \
144
161
  os.path.join(conf['paths']['base'], 'status')
145
162
  registry_path = os.path.join(base_status, badge_section + '.json')
@@ -151,7 +168,8 @@ def ob3_status_config(conf: ConfigParser,
151
168
  list_urls = {p: urljoin(status_base, p + '.jwt') for p in purposes}
152
169
 
153
170
  return OB3StatusConfig(purposes=purposes, size_bits=size_bits,
154
- registry_path=registry_path, list_urls=list_urls)
171
+ registry_path=registry_path, list_urls=list_urls,
172
+ validity_days=validity_days)
155
173
 
156
174
 
157
175
  class ConfParser():
@@ -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
@@ -27,18 +27,22 @@
27
27
  # Credentials data model. This is the OB3 counterpart of OB2's revocation-list
28
28
  # check: without it, an issuer's revocation has no effect on the verdict.
29
29
  #
30
- # Trust note: this checks the *published* status bit only. It does NOT verify
31
- # the status-list credential's own proof/signature that is a separate trust
32
- # chain (often a different key). A caller needing that guarantee must verify
33
- # the status-list credential independently.
30
+ # Trust note: by default this checks the *published* status bit and the list's
31
+ # validFrom/validUntil window (a lapsed list is rejected, so a replayed old
32
+ # copy cannot resurrect a revoked badge). It does NOT, by default, verify the
33
+ # status-list credential's own proof/signature — that is a separate trust
34
+ # chain. Pass verify_list=True (with the issuer's key or a DID issuer) to also
35
+ # verify the list's JWT-VC proof and bind its issuer to the badge's, closing
36
+ # the gap where a compromised status host could silently un-revoke.
34
37
 
35
38
  import base64
36
39
  import json
37
40
  import zlib
38
41
 
39
- from typing import Any, Callable, List, Optional
42
+ from datetime import datetime, timezone
43
+ from typing import Any, Callable, List, Optional, Union
40
44
 
41
- from .credential import OpenBadgeCredential
45
+ from .credential import OpenBadgeCredential, _parse_iso
42
46
  from .verifier import OB3VerificationError
43
47
  from ..util import download_file
44
48
 
@@ -53,20 +57,36 @@ MAX_STATUS_LIST_BYTES = 5 * 1024 * 1024 # 5 MiB
53
57
 
54
58
  def check_credential_status(
55
59
  credential: OpenBadgeCredential,
56
- download: Optional[Callable[[str], bytes]] = None) -> None:
60
+ download: Optional[Callable[[str], bytes]] = None,
61
+ *,
62
+ verify_list: bool = False,
63
+ list_pubkey_pem: Optional[Union[str, bytes]] = None) -> None:
57
64
  """Check every credentialStatus entry, raising OB3VerificationError if the
58
65
  credential is revoked/suspended or if its status cannot be determined.
59
66
 
60
67
  Fail-closed: a fetch or parse failure raises rather than silently passing.
61
68
  A credential with no credentialStatus is a no-op. ``download`` defaults to
62
69
  util.download_file (HTTPS-only, size-capped); it is injectable for testing.
70
+
71
+ The list's ``validFrom``/``validUntil`` window is always enforced (a lapsed
72
+ or not-yet-valid list is rejected). Pass ``verify_list=True`` to also verify
73
+ the status-list credential's own JWT-VC proof and require its issuer to
74
+ equal the badge's issuer — supply ``list_pubkey_pem`` (the issuer's public
75
+ key, e.g. the one that verified the badge), or rely on DID resolution when
76
+ the badge issuer is a ``did:web``/``did:key``. Off by default so a status
77
+ check stays a single offline-friendly fetch.
63
78
  """
64
79
  fetch = download if download is not None else download_file
80
+ badge_issuer = credential.issuer.id if credential.issuer else None
65
81
  for entry in credential.credential_status:
66
- _check_entry(entry, fetch)
82
+ _check_entry(entry, fetch, badge_issuer=badge_issuer,
83
+ verify_list=verify_list, list_pubkey_pem=list_pubkey_pem)
67
84
 
68
85
 
69
- def _check_entry(entry: dict[str, Any], download: Callable[[str], bytes]) -> None:
86
+ def _check_entry(entry: dict[str, Any], download: Callable[[str], bytes],
87
+ *, badge_issuer: Optional[str] = None,
88
+ verify_list: bool = False,
89
+ list_pubkey_pem: Optional[Union[str, bytes]] = None) -> None:
70
90
  types = set(_as_list(entry.get("type")))
71
91
  if not (types & _SUPPORTED_ENTRY_TYPES):
72
92
  raise OB3VerificationError(
@@ -87,7 +107,8 @@ def _check_entry(entry: dict[str, Any], download: Callable[[str], bytes]) -> Non
87
107
  "could not fetch status list %s: %s" % (list_url, exc)) from exc
88
108
 
89
109
  try:
90
- subject = _status_list_subject(raw)
110
+ doc = _status_list_document(raw)
111
+ subject = _subject_of(doc)
91
112
  # Bitstring Status List v1.0 allows statusSize > 1 (multi-bit entries).
92
113
  # _bit_set below assumes exactly one bit per entry, so honouring a
93
114
  # larger statusSize would read the wrong bits and could misreport a
@@ -113,6 +134,16 @@ def _check_entry(entry: dict[str, Any], download: Callable[[str], bytes]) -> Non
113
134
  raise OB3VerificationError(
114
135
  "malformed status list %s: %s" % (list_url, exc)) from exc
115
136
 
137
+ # Reject a stale (or not-yet-valid) list so a replayed old copy cannot
138
+ # resurrect a revoked badge. A list with no validUntil is not expired.
139
+ _check_list_window(doc, list_url)
140
+
141
+ # Opt-in: verify the list credential's OWN proof and bind its issuer to the
142
+ # badge's, so a compromised status host cannot silently un-revoke.
143
+ if verify_list:
144
+ _verify_list_proof(raw, doc, list_url, badge_issuer, download,
145
+ list_pubkey_pem)
146
+
116
147
  # The entry's statusPurpose MUST be one the fetched status list declares,
117
148
  # otherwise the entry points at the wrong list (fail closed).
118
149
  if purpose not in list_purposes:
@@ -145,9 +176,9 @@ def _parse_index(value: Any) -> int:
145
176
  raise OB3VerificationError("invalid statusListIndex: %r" % (value,)) from None
146
177
 
147
178
 
148
- def _status_list_subject(raw: bytes) -> dict[str, Any]:
149
- """Return the credentialSubject of a status-list credential served either as
150
- a JSON-LD VC document or as a compact JWT-VC string."""
179
+ def _status_list_document(raw: bytes) -> dict[str, Any]:
180
+ """Return the status-list credential document (its top-level claims) served
181
+ either as a JSON-LD VC document or as a compact JWT-VC string."""
151
182
  text = raw.decode('utf-8').strip()
152
183
 
153
184
  if text.startswith('{'):
@@ -164,6 +195,10 @@ def _status_list_subject(raw: bytes) -> dict[str, Any]:
164
195
 
165
196
  if not isinstance(doc, dict):
166
197
  raise OB3VerificationError("status list credential is not an object")
198
+ return doc
199
+
200
+
201
+ def _subject_of(doc: dict[str, Any]) -> dict[str, Any]:
167
202
  subject = doc.get("credentialSubject")
168
203
  if isinstance(subject, list):
169
204
  subject = subject[0] if subject else None
@@ -173,6 +208,97 @@ def _status_list_subject(raw: bytes) -> dict[str, Any]:
173
208
  return subject
174
209
 
175
210
 
211
+ def _issuer_id(issuer: Any) -> Optional[str]:
212
+ """The issuer identifier of a VC, whether given as a bare IRI or an object
213
+ with an ``id`` (matches OpenBadgeCredential's issuer handling)."""
214
+ if isinstance(issuer, str):
215
+ return issuer
216
+ if isinstance(issuer, dict):
217
+ value = issuer.get("id")
218
+ return value if isinstance(value, str) else None
219
+ return None
220
+
221
+
222
+ def _check_list_window(doc: dict[str, Any], list_url: str) -> None:
223
+ """Enforce the status list's validFrom/validUntil against wall-clock time.
224
+
225
+ A ``validUntil`` in the past means the served list is stale — an issuer that
226
+ stopped republishing, or a replayed old copy — so it is rejected rather than
227
+ trusted (an old revocation.jwt would otherwise silently un-revoke). A
228
+ ``validFrom`` in the future is likewise rejected. Absent bounds are fine.
229
+ """
230
+ now = datetime.now(timezone.utc)
231
+ valid_until = doc.get("validUntil")
232
+ if isinstance(valid_until, str):
233
+ try:
234
+ expires = _parse_iso(valid_until)
235
+ except Exception as exc:
236
+ raise OB3VerificationError(
237
+ "status list %s has an invalid validUntil %r: %s"
238
+ % (list_url, valid_until, exc)) from exc
239
+ if expires < now:
240
+ raise OB3VerificationError(
241
+ "status list %s expired (validUntil %s); the issuer must "
242
+ "republish it" % (list_url, valid_until))
243
+ valid_from = doc.get("validFrom")
244
+ if isinstance(valid_from, str):
245
+ try:
246
+ starts = _parse_iso(valid_from)
247
+ except Exception as exc:
248
+ raise OB3VerificationError(
249
+ "status list %s has an invalid validFrom %r: %s"
250
+ % (list_url, valid_from, exc)) from exc
251
+ if starts > now:
252
+ raise OB3VerificationError(
253
+ "status list %s is not yet valid (validFrom %s)"
254
+ % (list_url, valid_from))
255
+
256
+
257
+ def _verify_list_proof(raw: bytes, doc: dict[str, Any], list_url: str,
258
+ badge_issuer: Optional[str],
259
+ download: Callable[[str], bytes],
260
+ list_pubkey_pem: Optional[Union[str, bytes]]) -> None:
261
+ """Verify a status list credential's own JWT-VC proof and bind its issuer.
262
+
263
+ Two checks: (1) the list's ``issuer`` must equal the badge's issuer — a
264
+ list published by anyone else is not authoritative for this badge; (2) the
265
+ list's JWT signature must verify under the issuer's key (``list_pubkey_pem``
266
+ if given, else resolved from a ``did:web``/``did:key`` issuer). Fails closed.
267
+ """
268
+ from .verifier import OB3Verifier
269
+
270
+ list_issuer = _issuer_id(doc.get("issuer"))
271
+ if badge_issuer is not None and list_issuer != badge_issuer:
272
+ raise OB3VerificationError(
273
+ "status list %s issuer %r does not match the badge issuer %r"
274
+ % (list_url, list_issuer, badge_issuer))
275
+
276
+ text = raw.decode('utf-8').strip()
277
+ if text.count('.') != 2 or text.startswith('{'):
278
+ raise OB3VerificationError(
279
+ "status list %s is not a signed JWT-VC, so its proof cannot be "
280
+ "verified (verify_list=True requires a signed list)" % list_url)
281
+
282
+ if list_pubkey_pem is not None:
283
+ verifier = OB3Verifier(pubkey_pem=list_pubkey_pem)
284
+ elif isinstance(list_issuer, str) and list_issuer.startswith('did:'):
285
+ verifier = OB3Verifier.for_issuer_did(list_issuer, download=download)
286
+ else:
287
+ raise OB3VerificationError(
288
+ "cannot verify status list %s: its issuer %r is not a DID — pass "
289
+ "list_pubkey_pem" % (list_url, list_issuer))
290
+
291
+ # _decode_payload verifies the signature with the algorithm pinned to the
292
+ # key type; it does not enforce OB3 credential *types* (this is a
293
+ # BitstringStatusListCredential, not an OpenBadgeCredential), which is
294
+ # exactly what we want here. The issuer binding above is the trust anchor.
295
+ try:
296
+ verifier._decode_payload(text)
297
+ except OB3VerificationError as exc:
298
+ raise OB3VerificationError(
299
+ "status list %s proof is invalid: %s" % (list_url, exc)) from exc
300
+
301
+
176
302
  def _b64url_decode(value: str) -> bytes:
177
303
  value += '=' * (-len(value) % 4)
178
304
  return base64.urlsafe_b64decode(value)
@@ -77,18 +77,25 @@ def encode_bitstring(set_indices: Iterable[int],
77
77
  def build_status_list_credential(issuer_id: str, url: str, purpose: str,
78
78
  set_indices: Iterable[int],
79
79
  size_bits: int = DEFAULT_SIZE_BITS,
80
- issued: Optional[datetime] = None) -> dict[str, Any]:
80
+ issued: Optional[datetime] = None,
81
+ valid_until: Optional[datetime] = None
82
+ ) -> dict[str, Any]:
81
83
  """Build an (unsigned) BitstringStatusListCredential document.
82
84
 
83
85
  *url* is the public HTTPS URL the list will be served from — it becomes
84
86
  the credential ``id`` and must match the ``statusListCredential`` of the
85
87
  entries that point at it (see :func:`status_entry`). ``statusSize`` is
86
88
  omitted: entries are single-bit, the only size the verifier accepts.
89
+
90
+ *valid_until*, when given, adds a ``validUntil`` bound: a reader
91
+ (ob3.status) then rejects a stale copy served past that instant, so a
92
+ replayed old ``revocation.jwt`` cannot silently resurrect a revoked badge.
93
+ The issuer must republish the list before it lapses (see the wiki).
87
94
  """
88
95
  if purpose not in STATUS_PURPOSES:
89
96
  raise ValueError("statusPurpose must be one of %r, got %r"
90
97
  % (STATUS_PURPOSES, purpose))
91
- return {
98
+ vc: dict[str, Any] = {
92
99
  "@context": [_VC2_CONTEXT],
93
100
  "id": url,
94
101
  "type": ["VerifiableCredential", "BitstringStatusListCredential"],
@@ -102,6 +109,9 @@ def build_status_list_credential(issuer_id: str, url: str, purpose: str,
102
109
  "encodedList": encode_bitstring(set_indices, size_bits),
103
110
  },
104
111
  }
112
+ if valid_until is not None:
113
+ vc["validUntil"] = _iso(valid_until)
114
+ return vc
105
115
 
106
116
 
107
117
  def sign_status_list_credential(vc: dict[str, Any], privkey_pem: Any,