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