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.
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/Changelog.txt +18 -0
- {openbadgeslib-3.7.0/openbadgeslib.egg-info → openbadgeslib-3.8.0}/PKG-INFO +1 -1
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/confparser.py +19 -1
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/status.py +139 -13
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/status_list.py +12 -2
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/verifier.py +12 -3
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/openbadges_publish.py +7 -2
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/util.py +1 -1
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0/openbadgeslib.egg-info}/PKG-INFO +1 -1
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_publish_cli.py +55 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_status.py +123 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/LICENSE.txt +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/MANIFEST.in +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/README.md +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/SECURITY.md +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/docs/README.md +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/__init__.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/_jws/__init__.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/_jws/exceptions.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/_jws/utils.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/badge.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/baking.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/config.ini.example +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/errors.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/keys.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/logs.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/mail.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob1/__init__.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob1/badge.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob1/signer.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob1/verifier.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob2/__init__.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob2/models.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob2/signer.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob2/verifier.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/__init__.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/contexts/__init__.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/credential.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/did.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/eudi.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/ldp.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/signer.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/status_registry.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/openbadges_init.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/openbadges_keygenerator.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/openbadges_signer.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/openbadges_verifier.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/py.typed +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/signer.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/verifier.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib.egg-info/SOURCES.txt +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib.egg-info/dependency_links.txt +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib.egg-info/entry_points.txt +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib.egg-info/requires.txt +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib.egg-info/top_level.txt +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/pyproject.toml +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/setup.cfg +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/config1.ini +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/conformance/Dockerfile.ob3 +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/conformance/README.md +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/conformance/conftest.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/conformance/docker-compose.yml +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/conformance/test_official_validators.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/conftest.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/fixtures/ob_schemas/README.md +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/fixtures/ob_schemas/refresh.sh +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/fixtures/vc_di_eddsa/README.md +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/images/sample1.png +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/images/sample1.svg +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/images/userimage01.svg +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/ldp_helpers.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/logo Python Espan/314/203a.svg" +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/logo Python Espa/303/261a.svg" +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_cli_json.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_cli_smoke.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_confparser.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_docs.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_eddsa.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_jws.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_key_operation.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob1_badge_io.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob1_deprecation.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob1_signer.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob1_verifier.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob2_cli.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob2_models.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob2_signer.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob2_verifier.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_conformance_schema.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_contexts.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_credential.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_did.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_did_doc.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_eudi_sd_jwt.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_ldp.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_ldp_cli.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_ldp_sign.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_signer.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_signer_cli_ldp.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_status_list.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_status_registry.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_ob3_verifier.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_sign_ecc.pem +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_sign_rsa.pem +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_util.py +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_verify_ecc.pem +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/test_verify_rsa.pem +0 -0
- {openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/withoutxmlheader.svg +0 -0
- {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.
|
|
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
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
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
|
|
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
|
|
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]
|
|
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
|
-
|
|
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
|
|
149
|
-
"""Return the
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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'))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openbadgeslib
|
|
3
|
-
Version: 3.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json
RENAMED
|
File without changes
|
{openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json
RENAMED
|
File without changes
|
{openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json
RENAMED
|
File without changes
|
|
File without changes
|
{openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/openbadgeslib/ob3/contexts/security-multikey-v1.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt
RENAMED
|
File without changes
|
{openbadgeslib-3.7.0 → openbadgeslib-3.8.0}/tests/fixtures/vc_di_eddsa/signed-credential.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|