openbadgeslib 3.7.0__tar.gz → 3.8.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 (122) hide show
  1. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/Changelog.txt +18 -0
  2. {openbadgeslib-3.7.0/openbadgeslib.egg-info → openbadgeslib-3.8.0}/PKG-INFO +1 -1
  3. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/confparser.py +19 -1
  4. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/status.py +139 -13
  5. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/status_list.py +12 -2
  6. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/verifier.py +12 -3
  7. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/openbadges_publish.py +7 -2
  8. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/util.py +1 -1
  9. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0/openbadgeslib.egg-info}/PKG-INFO +1 -1
  10. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_publish_cli.py +55 -0
  11. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_status.py +123 -0
  12. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/LICENSE.txt +0 -0
  13. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/MANIFEST.in +0 -0
  14. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/README.md +0 -0
  15. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/SECURITY.md +0 -0
  16. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/docs/README.md +0 -0
  17. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/__init__.py +0 -0
  18. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/_jws/__init__.py +0 -0
  19. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/_jws/exceptions.py +0 -0
  20. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/_jws/utils.py +0 -0
  21. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/badge.py +0 -0
  22. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/baking.py +0 -0
  23. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/config.ini.example +0 -0
  24. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/errors.py +0 -0
  25. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/keys.py +0 -0
  26. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/logs.py +0 -0
  27. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/mail.py +0 -0
  28. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob1/__init__.py +0 -0
  29. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob1/badge.py +0 -0
  30. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob1/signer.py +0 -0
  31. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob1/verifier.py +0 -0
  32. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob2/__init__.py +0 -0
  33. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob2/models.py +0 -0
  34. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob2/signer.py +0 -0
  35. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob2/verifier.py +0 -0
  36. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/__init__.py +0 -0
  37. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/contexts/__init__.py +0 -0
  38. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
  39. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
  40. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
  41. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
  42. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
  43. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
  44. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/credential.py +0 -0
  45. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/did.py +0 -0
  46. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/eudi.py +0 -0
  47. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/ldp.py +0 -0
  48. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/signer.py +0 -0
  49. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/status_registry.py +0 -0
  50. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/openbadges_init.py +0 -0
  51. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/openbadges_keygenerator.py +0 -0
  52. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/openbadges_signer.py +0 -0
  53. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/openbadges_verifier.py +0 -0
  54. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/py.typed +0 -0
  55. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/signer.py +0 -0
  56. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/verifier.py +0 -0
  57. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib.egg-info/SOURCES.txt +0 -0
  58. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib.egg-info/dependency_links.txt +0 -0
  59. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib.egg-info/entry_points.txt +0 -0
  60. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib.egg-info/requires.txt +0 -0
  61. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib.egg-info/top_level.txt +0 -0
  62. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/pyproject.toml +0 -0
  63. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/setup.cfg +0 -0
  64. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/config1.ini +0 -0
  65. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/conformance/Dockerfile.ob3 +0 -0
  66. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/conformance/README.md +0 -0
  67. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/conformance/conftest.py +0 -0
  68. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/conformance/docker-compose.yml +0 -0
  69. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/conformance/test_official_validators.py +0 -0
  70. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/conftest.py +0 -0
  71. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/fixtures/ob_schemas/README.md +0 -0
  72. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
  73. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
  74. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/fixtures/ob_schemas/refresh.sh +0 -0
  75. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/fixtures/vc_di_eddsa/README.md +0 -0
  76. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
  77. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
  78. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
  79. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
  80. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
  81. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/images/sample1.png +0 -0
  82. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/images/sample1.svg +0 -0
  83. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/images/userimage01.svg +0 -0
  84. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/ldp_helpers.py +0 -0
  85. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/logo Python Espan/314/203a.svg" +0 -0
  86. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/logo Python Espa/303/261a.svg" +0 -0
  87. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_cli_json.py +0 -0
  88. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_cli_smoke.py +0 -0
  89. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_confparser.py +0 -0
  90. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_docs.py +0 -0
  91. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_eddsa.py +0 -0
  92. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_jws.py +0 -0
  93. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_key_operation.py +0 -0
  94. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob1_badge_io.py +0 -0
  95. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob1_deprecation.py +0 -0
  96. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob1_signer.py +0 -0
  97. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob1_verifier.py +0 -0
  98. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob2_cli.py +0 -0
  99. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob2_models.py +0 -0
  100. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob2_signer.py +0 -0
  101. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob2_verifier.py +0 -0
  102. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_conformance_schema.py +0 -0
  103. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_contexts.py +0 -0
  104. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_credential.py +0 -0
  105. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_did.py +0 -0
  106. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_did_doc.py +0 -0
  107. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_eudi_sd_jwt.py +0 -0
  108. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_ldp.py +0 -0
  109. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_ldp_cli.py +0 -0
  110. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_ldp_sign.py +0 -0
  111. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_signer.py +0 -0
  112. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_signer_cli_ldp.py +0 -0
  113. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_status_list.py +0 -0
  114. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_status_registry.py +0 -0
  115. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_verifier.py +0 -0
  116. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_sign_ecc.pem +0 -0
  117. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_sign_rsa.pem +0 -0
  118. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_util.py +0 -0
  119. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_verify_ecc.pem +0 -0
  120. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_verify_rsa.pem +0 -0
  121. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/withoutxmlheader.svg +0 -0
  122. {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/withxmlheader.svg +0 -0
@@ -4,6 +4,24 @@ OpenBadgesLib - Changelog
4
4
  Newest first. Dates are ISO 8601 (YYYY-MM-DD).
5
5
 
6
6
 
7
+ * v3.8.0 - 2026-07-08
8
+
9
+ - security(ob3): status lists gain replay protection and opt-in proof
10
+ verification, closing two documented trust gaps. (1) A per-badge
11
+ `status_validity_days` makes published lists carry a `validUntil`, and the
12
+ reader now enforces the list's validFrom/validUntil window — a lapsed
13
+ (replayed, or no-longer-republished) `revocation.jwt` is rejected instead
14
+ of silently resurrecting a revoked badge. (2)
15
+ `check_credential_status(..., verify_list=True)` (and
16
+ `OB3Verifier.check_status(..., verify_list=True)`) additionally verifies
17
+ the status list credential's own JWT-VC proof and binds its issuer to the
18
+ badge's, so a compromised status host cannot serve a validly-shaped list
19
+ that silently un-revokes. The list is signed with the badge key, so that
20
+ same key (reused automatically) verifies it; a did:web/did:key issuer is
21
+ resolved otherwise. Both are opt-in — the default stays a single
22
+ offline-friendly fetch, no behaviour break. (#164)
23
+
24
+
7
25
  * v3.7.0 - 2026-07-08
8
26
 
9
27
  - 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.8.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():
@@ -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,
@@ -245,17 +245,26 @@ class OB3Verifier:
245
245
 
246
246
  return credential
247
247
 
248
- def check_status(self, credential: OpenBadgeCredential) -> None:
248
+ def check_status(self, credential: OpenBadgeCredential,
249
+ *, verify_list: bool = False) -> None:
249
250
  """Check the credential's ``credentialStatus`` (revocation) over the
250
251
  network, raising :class:`OB3VerificationError` if it is revoked/
251
252
  suspended or if the status cannot be determined (fail-closed). A
252
- credential carrying no credentialStatus is a no-op.
253
+ credential carrying no credentialStatus is a no-op. The status list's
254
+ validFrom/validUntil window is always enforced.
255
+
256
+ Pass ``verify_list=True`` to also verify each status list credential's
257
+ own JWT-VC proof and bind its issuer to the badge's — this verifier's
258
+ key is reused, since ``openbadges-publish`` signs the list with the same
259
+ badge key that signed the credential.
253
260
 
254
261
  Imported lazily so the (network-touching) status module is only pulled
255
262
  in when a caller actually opts into status checking.
256
263
  """
257
264
  from .status import check_credential_status
258
- check_credential_status(credential)
265
+ check_credential_status(
266
+ credential, verify_list=verify_list,
267
+ list_pubkey_pem=self.pubkey_pem if verify_list else None)
259
268
 
260
269
  def _key_for_token(
261
270
  self, header: dict[str, Any]) -> tuple[Union[str, bytes], list[str]]:
@@ -322,7 +322,7 @@ def _publish_ob3(args: argparse.Namespace,
322
322
  every status change is the normal workflow, and the managed files are
323
323
  replaced atomically.
324
324
  """
325
- from datetime import datetime, timezone
325
+ from datetime import datetime, timedelta, timezone
326
326
  from .confparser import ob3_issuer_id, ob3_status_config
327
327
  from .errors import LibOpenBadgesException, StatusError
328
328
  from .keys import alg_for_key_type, detect_key_type, public_jwk_from_pem
@@ -481,6 +481,11 @@ def _publish_ob3(args: argparse.Namespace,
481
481
  continue
482
482
  algorithm = alg_for_key_type(detect_key_type(priv_pem))
483
483
 
484
+ valid_until = None
485
+ if status_conf.validity_days is not None:
486
+ valid_until = (datetime.now(tz=timezone.utc)
487
+ + timedelta(days=status_conf.validity_days))
488
+
484
489
  badge_dir = os.path.join(args.output, name)
485
490
  os.makedirs(badge_dir, exist_ok=True)
486
491
  for purpose in status_conf.purposes:
@@ -488,7 +493,7 @@ def _publish_ob3(args: argparse.Namespace,
488
493
  else registry.suspended_indices()
489
494
  vc = build_status_list_credential(
490
495
  issuer_id, status_conf.list_urls[purpose], purpose,
491
- indices, registry.size_bits)
496
+ indices, registry.size_bits, valid_until=valid_until)
492
497
  token = sign_status_list_credential(vc, priv_pem, algorithm)
493
498
  _write_atomic(os.path.join(badge_dir, purpose + '.jwt'), token)
494
499
  files_written.append(os.path.join(name, purpose + '.jwt'))
@@ -21,7 +21,7 @@
21
21
  License along with this library.
22
22
  """
23
23
 
24
- __version__ = '3.7.0'
24
+ __version__ = '3.8.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.7.0
3
+ Version: 3.8.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
@@ -585,3 +585,58 @@ class TestPublishJson:
585
585
  with patch.object(sys, 'argv', argv):
586
586
  with pytest.raises(SystemExit, match='-V 3'):
587
587
  openbadges_publish.main()
588
+
589
+
590
+ # ── #164: status list validUntil + opt-in proof verification ─────────────────
591
+
592
+ class TestPublishValidUntil:
593
+ def test_validuntil_emitted_when_configured(self, tmp_path):
594
+ import jwt as pyjwt
595
+ cfg = _write_config(tmp_path, status_lists='revocation')
596
+ with cfg.open('a') as f:
597
+ f.write('status_validity_days = 7\n')
598
+ _sign(tmp_path, cfg)
599
+ pub = _publish(tmp_path, cfg)
600
+ token = (pub / 'badge_1' / 'revocation.jwt').read_text()
601
+ payload = pyjwt.decode(token, options={'verify_signature': False})
602
+ assert 'validUntil' in payload and 'validFrom' in payload
603
+
604
+ def test_no_validuntil_by_default(self, tmp_path):
605
+ import jwt as pyjwt
606
+ cfg = _write_config(tmp_path, status_lists='revocation')
607
+ _sign(tmp_path, cfg)
608
+ pub = _publish(tmp_path, cfg)
609
+ token = (pub / 'badge_1' / 'revocation.jwt').read_text()
610
+ payload = pyjwt.decode(token, options={'verify_signature': False})
611
+ assert 'validUntil' not in payload
612
+
613
+ def test_invalid_validity_days_exits_cleanly(self, tmp_path, capsys):
614
+ cfg = _write_config(tmp_path, status_lists='revocation')
615
+ with cfg.open('a') as f:
616
+ f.write('status_validity_days = soon\n')
617
+ with pytest.raises(SystemExit):
618
+ _publish(tmp_path, cfg)
619
+ assert 'status_validity_days' in capsys.readouterr().out
620
+
621
+
622
+ class TestPublishVerifyList:
623
+ def test_verify_list_proof_end_to_end(self, tmp_path, rsa_pub_pem):
624
+ # The published list is signed with the badge key and its issuer is the
625
+ # badge issuer, so verify_list=True (with that key) passes.
626
+ from openbadgeslib.ob3 import check_credential_status
627
+ cfg = _write_config(tmp_path, status_lists='revocation')
628
+ badge_file = _sign(tmp_path, cfg)
629
+ pub = _publish(tmp_path, cfg)
630
+ credential = _credential_from(badge_file, rsa_pub_pem)
631
+ check_credential_status(credential, download=_served_from(pub),
632
+ verify_list=True, list_pubkey_pem=rsa_pub_pem)
633
+
634
+ def test_verify_list_wrong_key_fails(self, tmp_path, rsa_pub_pem, ecc_pub_pem):
635
+ from openbadgeslib.ob3 import check_credential_status, OB3VerificationError
636
+ cfg = _write_config(tmp_path, status_lists='revocation')
637
+ badge_file = _sign(tmp_path, cfg)
638
+ pub = _publish(tmp_path, cfg)
639
+ credential = _credential_from(badge_file, rsa_pub_pem)
640
+ with pytest.raises(OB3VerificationError, match='proof is invalid'):
641
+ check_credential_status(credential, download=_served_from(pub),
642
+ verify_list=True, list_pubkey_pem=ecc_pub_pem)
@@ -249,3 +249,126 @@ class TestVerifyWithStatus:
249
249
  monkeypatch.setattr(status_mod, 'download_file', _fail)
250
250
  token = self._sign(ob3_rsa_signer, [_status_entry(94)])
251
251
  assert ob3_rsa_verifier.verify(token) is not None
252
+
253
+
254
+ # ── #164: validFrom/validUntil window + opt-in proof verification ────────────
255
+
256
+ def _iso(dt):
257
+ return dt.isoformat(timespec='seconds').replace('+00:00', 'Z')
258
+
259
+
260
+ def _served_token(token):
261
+ data = token.encode('utf-8') if isinstance(token, str) else token
262
+
263
+ def _dl(url):
264
+ assert url == LIST_URL
265
+ return data
266
+ return _dl
267
+
268
+
269
+ class TestStatusListWindow:
270
+ def _doc(self, **extra):
271
+ doc = _status_list_doc([]) # empty list: no bit set
272
+ doc['issuer'] = 'https://issuer.example'
273
+ doc.update(extra)
274
+ return doc
275
+
276
+ def test_no_validuntil_never_expires(self):
277
+ # Backward compatible: a list without validUntil is accepted.
278
+ check_credential_status(_credential([_status_entry(5)]),
279
+ download=_downloader(self._doc()))
280
+
281
+ def test_future_validuntil_accepted(self):
282
+ from datetime import datetime, timedelta, timezone
283
+ doc = self._doc(validUntil=_iso(datetime.now(timezone.utc)
284
+ + timedelta(days=7)))
285
+ check_credential_status(_credential([_status_entry(5)]),
286
+ download=_downloader(doc))
287
+
288
+ def test_expired_validuntil_rejected(self):
289
+ from datetime import datetime, timedelta, timezone
290
+ doc = self._doc(validUntil=_iso(datetime.now(timezone.utc)
291
+ - timedelta(days=1)))
292
+ with pytest.raises(OB3VerificationError, match='expired'):
293
+ check_credential_status(_credential([_status_entry(5)]),
294
+ download=_downloader(doc))
295
+
296
+ def test_not_yet_valid_validfrom_rejected(self):
297
+ from datetime import datetime, timedelta, timezone
298
+ doc = self._doc(validFrom=_iso(datetime.now(timezone.utc)
299
+ + timedelta(days=1)))
300
+ with pytest.raises(OB3VerificationError, match='not yet valid'):
301
+ check_credential_status(_credential([_status_entry(5)]),
302
+ download=_downloader(doc))
303
+
304
+ def test_malformed_validuntil_rejected(self):
305
+ with pytest.raises(OB3VerificationError, match='invalid validUntil'):
306
+ check_credential_status(
307
+ _credential([_status_entry(5)]),
308
+ download=_downloader(self._doc(validUntil='not-a-date')))
309
+
310
+
311
+ class TestVerifyListProof:
312
+ def _signed(self, priv_pem, issuer_id, indices=()):
313
+ from openbadgeslib.ob3.status_list import (
314
+ build_status_list_credential, sign_status_list_credential)
315
+ vc = build_status_list_credential(issuer_id, LIST_URL, 'revocation',
316
+ indices)
317
+ return sign_status_list_credential(vc, priv_pem, 'EdDSA')
318
+
319
+ def _cred(self, issuer_id, entries):
320
+ from openbadgeslib.ob3 import Achievement, Issuer
321
+ return OpenBadgeCredential(
322
+ id='urn:uuid:00000000-0000-0000-0000-0000000000ab',
323
+ issuer=Issuer(id=issuer_id, name='I'),
324
+ recipient_id='mailto:r@example.com',
325
+ achievement=Achievement(id='https://a.example/1', name='A',
326
+ description='d', criteria_narrative='c'),
327
+ credential_status=entries)
328
+
329
+ def test_verify_with_pubkey_passes(self, ed25519_keypair):
330
+ priv, pub = ed25519_keypair
331
+ token = self._signed(priv, 'https://issuer.example')
332
+ check_credential_status(
333
+ self._cred('https://issuer.example', [_status_entry(5)]),
334
+ download=_served_token(token), verify_list=True, list_pubkey_pem=pub)
335
+
336
+ def test_issuer_mismatch_fails(self, ed25519_keypair):
337
+ priv, pub = ed25519_keypair
338
+ token = self._signed(priv, 'https://attacker.example')
339
+ with pytest.raises(OB3VerificationError,
340
+ match='does not match the badge issuer'):
341
+ check_credential_status(
342
+ self._cred('https://issuer.example', [_status_entry(5)]),
343
+ download=_served_token(token), verify_list=True,
344
+ list_pubkey_pem=pub)
345
+
346
+ def test_bad_signature_fails(self, ed25519_keypair):
347
+ from openbadgeslib.keys import KeyEd25519
348
+ priv, _pub = ed25519_keypair
349
+ _priv2, wrong_pub = KeyEd25519().generate_keypair()
350
+ token = self._signed(priv, 'https://issuer.example')
351
+ with pytest.raises(OB3VerificationError, match='proof is invalid'):
352
+ check_credential_status(
353
+ self._cred('https://issuer.example', [_status_entry(5)]),
354
+ download=_served_token(token), verify_list=True,
355
+ list_pubkey_pem=wrong_pub)
356
+
357
+ def test_verify_via_did_key_resolution(self, ed25519_keypair):
358
+ # No list_pubkey_pem: the issuer DID (did:key, offline) is resolved.
359
+ from openbadgeslib.ob3.did import did_key_from_pem
360
+ priv, pub = ed25519_keypair
361
+ did = did_key_from_pem(pub)
362
+ token = self._signed(priv, did)
363
+ check_credential_status(self._cred(did, [_status_entry(5)]),
364
+ download=_served_token(token), verify_list=True)
365
+
366
+ def test_unsigned_list_rejected_when_verify_requested(self):
367
+ # verify_list=True on a plain JSON (unsigned) list fails closed.
368
+ doc = _status_list_doc([])
369
+ doc['issuer'] = 'https://issuer.example'
370
+ with pytest.raises(OB3VerificationError, match='not a signed JWT-VC'):
371
+ check_credential_status(
372
+ self._cred('https://issuer.example', [_status_entry(5)]),
373
+ download=_downloader(doc), verify_list=True,
374
+ list_pubkey_pem=b'unused')
File without changes
File without changes
File without changes
File without changes
File without changes