openbadgeslib 4.4.4__tar.gz → 4.5.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-4.4.4 → openbadgeslib-4.5.0}/Changelog.txt +27 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/PKG-INFO +1 -1
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob2/verifier.py +82 -13
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/__init__.py +13 -1
- openbadgeslib-4.5.0/openbadgeslib/ob3/clr.py +176 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/__init__.py +3 -0
- openbadgeslib-4.5.0/openbadgeslib/ob3/contexts/clr_context_2.0.1.json +62 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/status.py +57 -9
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/status_list.py +20 -7
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/util.py +1 -1
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib.egg-info/PKG-INFO +1 -1
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib.egg-info/SOURCES.txt +4 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/conformance/Dockerfile.ob3 +11 -1
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/conformance/README.md +6 -1
- openbadgeslib-4.5.0/tests/conformance/relax-danubetech-checksums.awk +24 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob2_error_branches.py +2 -1
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob2_verifier.py +56 -1
- openbadgeslib-4.5.0/tests/test_ob3_clr.py +144 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_status.py +37 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_status_list.py +18 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/CONTRIBUTING.md +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/GOVERNANCE.md +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/LICENSE.txt +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/MANIFEST.in +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/README.md +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/SECURITY.md +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/docs/README.md +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/__init__.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/_jsonmodel.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/_jws/__init__.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/_jws/exceptions.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/_jws/utils.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/_ob1_api.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/badge_model.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/baking.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/cli_common.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/config.ini.example +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/confparser.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/errors.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/issue.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/keys.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/logs.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/mail.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob1/__init__.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob1/badge.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob1/signer.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob1/verifier.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob2/__init__.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob2/models.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob2/signer.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/ob-v3p0-extensions.json +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/credential.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/did.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/eudi.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/ldp.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/publish.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/signer.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/status_registry.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/ob3/verifier.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/__init__.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/codes.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/errors.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/formats.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/handler.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/memory_store.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/metadata.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/nonce.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/offer.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/reconcile.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/sqlite_store.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/store.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_cli.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_init.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_keygenerator.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_publish.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_signer.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_status.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_verifier.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/py.typed +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib/verify.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib.egg-info/dependency_links.txt +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib.egg-info/entry_points.txt +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib.egg-info/requires.txt +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/openbadgeslib.egg-info/top_level.txt +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/pyproject.toml +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/setup.cfg +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/config1.ini +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/conformance/conftest.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/conformance/docker-compose.yml +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/conformance/test_official_validators.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/conftest.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/fixtures/ob_schemas/README.md +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/fixtures/ob_schemas/refresh.sh +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/fixtures/vc_di_eddsa/README.md +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/images/sample1.png +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/images/sample1.svg +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/images/userimage01.svg +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/ldp_helpers.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/logo Python Espan/314/203a.svg" +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/logo Python Espa/303/261a.svg" +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_baking.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_cli_json.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_cli_smoke.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_confparser.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_docs.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_eddsa.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_errors.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_examples.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_issue.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_issue_credential.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_jsonmodel.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_jws.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_key_operation.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_keys.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_mail.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob1_badge_io.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob1_deprecation.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob1_signer.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob1_verifier.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob2_cli.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob2_models.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob2_signer.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_conformance_schema.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_contexts.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_credential.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_did.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_did_doc.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_endorsement.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_eudi_oid4vci.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_eudi_sd_jwt.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_eudi_type_metadata.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_eudi_x5c.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_ldp.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_ldp_cli.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_ldp_ecdsa_sd.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_ldp_errors.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_ldp_sign.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_publish_cli.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_signer.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_signer_cli_ldp.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_status_registry.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_ob3_verifier.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_oid4vci_config.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_oid4vci_protocol.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_oid4vci_reservations.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_oid4vci_store.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_openbadges_cli.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_public_api.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_sign_ecc.pem +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_sign_rsa.pem +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_signer_batch.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_util.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_verifier_cli_coverage.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_verify.py +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_verify_ecc.pem +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/test_verify_rsa.pem +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/withoutxmlheader.svg +0 -0
- {openbadgeslib-4.4.4 → openbadgeslib-4.5.0}/tests/withxmlheader.svg +0 -0
|
@@ -4,6 +4,33 @@ OpenBadgesLib - Changelog
|
|
|
4
4
|
Newest first. Dates are ISO 8601 (YYYY-MM-DD).
|
|
5
5
|
|
|
6
6
|
|
|
7
|
+
* v4.5.0 - 2026-09-12
|
|
8
|
+
|
|
9
|
+
- feat(ob3): public `decode_encoded_list(encoded) -> bytes` and
|
|
10
|
+
`set_indices(bitstring) -> frozenset[int]` for a published Bitstring
|
|
11
|
+
Status List `encodedList` (#322). Same multibase `u` / base64url /
|
|
12
|
+
bounded-gzip (5 MiB) / MSB-first decode the verifier already uses;
|
|
13
|
+
the private `_decode_encoded_list` name remains as a thin alias.
|
|
14
|
+
|
|
15
|
+
- feat(ob3): `build_status_list_credential(..., ttl=)` writes Bitstring
|
|
16
|
+
Status List §2.2 `ttl` (milliseconds) on `credentialSubject`, never at
|
|
17
|
+
the top level — `ttl` is a JSON-LD term scoped to `BitstringStatusList`,
|
|
18
|
+
so a top-level copy would not be signed (#321). Negative values raise
|
|
19
|
+
`ValueError`.
|
|
20
|
+
|
|
21
|
+
- feat(ob3): native CLR 2.0 `ClrCredential` builder (`build_clr_credential`,
|
|
22
|
+
`sign_clr_credential`) with the versioned `context-2.0.1.json` bundled
|
|
23
|
+
and hash-pinned (`CLR_CONTEXT_SHA256`) (#323). Identifier objects and
|
|
24
|
+
member credentials are copied as given — email hashing and member-count
|
|
25
|
+
caps stay downstream. Signing injects the pinned context via
|
|
26
|
+
`extra_contexts` (requires `[ldp]`).
|
|
27
|
+
|
|
28
|
+
- feat(ob2): `OB2Verifier.verify_hosted(document)` verifies a hosted
|
|
29
|
+
assertion JSON document (dict or JSON string) without synthesizing a
|
|
30
|
+
dummy JWS. The HTTPS fetcher is injectable via
|
|
31
|
+
`OB2Verifier(..., download=)`.
|
|
32
|
+
|
|
33
|
+
|
|
7
34
|
* v4.4.4 - 2026-09-10
|
|
8
35
|
|
|
9
36
|
- security(baking): SVG baking strips ``<script>`` / ``foreignObject`` /
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openbadgeslib
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.5.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
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import logging
|
|
24
24
|
from datetime import datetime, timezone
|
|
25
|
-
from typing import Any, List, Optional, Union
|
|
25
|
+
from typing import Any, Callable, List, Optional, Union
|
|
26
26
|
from urllib.parse import urlparse
|
|
27
27
|
|
|
28
28
|
from .models import Assertion, CryptographicKey, hash_identity, _parse_iso
|
|
@@ -67,10 +67,16 @@ class OB2Verifier:
|
|
|
67
67
|
pubkey_pem: Optional PEM-encoded trusted public key. When supplied it is
|
|
68
68
|
used to verify SignedBadge assertions and skips resolving the
|
|
69
69
|
badge-declared key.
|
|
70
|
+
download: Optional ``url -> bytes`` fetcher used by hosted verification
|
|
71
|
+
(assertion id, BadgeClass, issuer Profile, revocation list).
|
|
72
|
+
Defaults to :func:`download_file` (HTTPS-only, size-capped).
|
|
70
73
|
"""
|
|
71
74
|
|
|
72
|
-
def __init__(self, pubkey_pem: Optional[Any] = None
|
|
75
|
+
def __init__(self, pubkey_pem: Optional[Any] = None, *,
|
|
76
|
+
download: Optional[Callable[[str], bytes]] = None) -> None:
|
|
73
77
|
self.trusted_pubkey_pem: Optional[Union[str, bytes]] = None
|
|
78
|
+
self._download: Callable[[str], bytes] = (
|
|
79
|
+
download if download is not None else download_file)
|
|
74
80
|
if pubkey_pem is not None:
|
|
75
81
|
pem = key_to_pem(pubkey_pem)
|
|
76
82
|
try:
|
|
@@ -122,6 +128,67 @@ class OB2Verifier:
|
|
|
122
128
|
|
|
123
129
|
return assertion
|
|
124
130
|
|
|
131
|
+
def verify_hosted(self, document: Union[str, dict[str, Any]], *,
|
|
132
|
+
expected_recipient: Optional[str] = None,
|
|
133
|
+
check_revocation: bool = False) -> Assertion:
|
|
134
|
+
"""Verify a hosted OB 2.0 assertion JSON document (not a compact JWS).
|
|
135
|
+
|
|
136
|
+
*document* is the assertion as a dict or a JSON string. The hosted
|
|
137
|
+
model is fetch-from-id: the copy served at ``assertion.id`` is the
|
|
138
|
+
trust anchor (scoped to the issuer Profile), so this does not
|
|
139
|
+
synthesize a dummy JWS. Defence-in-depth JWS verification inside
|
|
140
|
+
:meth:`_verify_hosted` is skipped when there is no token.
|
|
141
|
+
|
|
142
|
+
``expected_recipient`` and ``check_revocation`` behave as in
|
|
143
|
+
:meth:`verify`. Raises :class:`OB2VerificationError` if the document
|
|
144
|
+
is not a HostedBadge assertion or if any check fails.
|
|
145
|
+
"""
|
|
146
|
+
parsed = self._hosted_document(document)
|
|
147
|
+
try:
|
|
148
|
+
assertion = Assertion.from_dict(parsed)
|
|
149
|
+
except ValueError as exc:
|
|
150
|
+
raise OB2VerificationError(
|
|
151
|
+
"Malformed OB 2.0 assertion: %s" % exc) from exc
|
|
152
|
+
if assertion.verification.type != "HostedBadge":
|
|
153
|
+
raise OB2VerificationError(
|
|
154
|
+
"verify_hosted() requires verification.type HostedBadge, got %r"
|
|
155
|
+
% assertion.verification.type)
|
|
156
|
+
|
|
157
|
+
assertion = self._verify_hosted(assertion, "")
|
|
158
|
+
|
|
159
|
+
self._check_expiration(assertion)
|
|
160
|
+
|
|
161
|
+
if expected_recipient is not None:
|
|
162
|
+
self._check_identity(assertion, expected_recipient)
|
|
163
|
+
|
|
164
|
+
if check_revocation:
|
|
165
|
+
self._check_revocation(assertion)
|
|
166
|
+
|
|
167
|
+
return assertion
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _hosted_document(document: Union[str, dict[str, Any]]) -> dict[str, Any]:
|
|
171
|
+
"""Parse a hosted assertion from a dict or JSON string (never a JWS)."""
|
|
172
|
+
if isinstance(document, str):
|
|
173
|
+
text = document.strip()
|
|
174
|
+
if text.count('.') == 2 and not text.startswith('{'):
|
|
175
|
+
raise OB2VerificationError(
|
|
176
|
+
"verify_hosted() expects a hosted assertion JSON document, "
|
|
177
|
+
"not a compact JWS; use verify() for SignedBadge tokens")
|
|
178
|
+
try:
|
|
179
|
+
parsed = jws_utils.from_json(text)
|
|
180
|
+
except Exception as exc:
|
|
181
|
+
raise OB2VerificationError(
|
|
182
|
+
"hosted assertion is not valid JSON: %s" % exc) from exc
|
|
183
|
+
elif isinstance(document, dict):
|
|
184
|
+
parsed = document
|
|
185
|
+
else:
|
|
186
|
+
raise OB2VerificationError(
|
|
187
|
+
"hosted assertion must be a dict or JSON string")
|
|
188
|
+
if not isinstance(parsed, dict):
|
|
189
|
+
raise OB2VerificationError("hosted assertion is not a JSON object")
|
|
190
|
+
return parsed
|
|
191
|
+
|
|
125
192
|
# ── signed path ──────────────────────────────────────────────────────────────
|
|
126
193
|
|
|
127
194
|
def _verify_signed(self, assertion: Assertion, token: str) -> None:
|
|
@@ -255,16 +322,18 @@ class OB2Verifier:
|
|
|
255
322
|
# verify it, but never fail the hosted verdict on it (a hosted badge is
|
|
256
323
|
# not required to be signed at all). The creator comes from the
|
|
257
324
|
# authoritative copy, so this never dereferences a holder-chosen URL.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
325
|
+
# verify_hosted() passes an empty token (there is no JWS); skip then.
|
|
326
|
+
if token:
|
|
327
|
+
try:
|
|
328
|
+
if self.trusted_pubkey_pem is not None:
|
|
329
|
+
self._verify_jws(token, self.trusted_pubkey_pem)
|
|
330
|
+
elif authoritative.verification.creator:
|
|
331
|
+
key = self._resolve_creator(authoritative.verification.creator)
|
|
332
|
+
self._verify_jws(token, key.public_key_pem.encode('utf-8'))
|
|
333
|
+
except OB2VerificationError as exc:
|
|
334
|
+
logger.debug("Hosted assertion %s: baked JWS did not verify (%s); "
|
|
335
|
+
"hosted trust comes from the HTTPS fetch, not the signature.",
|
|
336
|
+
assertion.id, exc)
|
|
268
337
|
return authoritative
|
|
269
338
|
|
|
270
339
|
def _check_hosted_scope(self, assertion_id: str, issuer: dict[str, Any]) -> None:
|
|
@@ -373,7 +442,7 @@ class OB2Verifier:
|
|
|
373
442
|
|
|
374
443
|
def _fetch_json(self, url: str, where: str) -> dict[str, Any]:
|
|
375
444
|
try:
|
|
376
|
-
raw =
|
|
445
|
+
raw = self._download(url)
|
|
377
446
|
except Exception as exc:
|
|
378
447
|
raise OB2VerificationError(
|
|
379
448
|
"Could not fetch %s at %s: %s" % (where, url, exc)) from exc
|
|
@@ -27,9 +27,12 @@ from .verifier import (OB3VerificationError, OB3Verifier,
|
|
|
27
27
|
verify_endorsement_jwt)
|
|
28
28
|
from .ldp import (OB3LdpSigner, OB3LdpVerifier, add_data_integrity_proof,
|
|
29
29
|
verify_data_integrity_proof)
|
|
30
|
-
from .status import check_credential_status
|
|
30
|
+
from .status import check_credential_status, decode_encoded_list, set_indices
|
|
31
31
|
from .status_list import (build_status_list_credential, encode_bitstring,
|
|
32
32
|
sign_status_list_credential, status_entry)
|
|
33
|
+
from .clr import (CLR_CONTEXT_SHA256, CLR_CONTEXT_URL, OB_V3_CONTEXT,
|
|
34
|
+
VCDM_V2_CONTEXT, build_clr_credential, bundled_clr_contexts,
|
|
35
|
+
sign_clr_credential)
|
|
33
36
|
from .status_registry import StatusRegistry
|
|
34
37
|
from .did import (build_did_document, did_jwk_from_jwk, did_key_from_pem,
|
|
35
38
|
did_web_from_url, multikey_from_pem, resolve_did,
|
|
@@ -68,16 +71,25 @@ __all__ = [
|
|
|
68
71
|
'verify_data_integrity_proof',
|
|
69
72
|
'StatusRegistry',
|
|
70
73
|
'build_did_document',
|
|
74
|
+
'build_clr_credential',
|
|
71
75
|
'build_status_list_credential',
|
|
76
|
+
'bundled_clr_contexts',
|
|
72
77
|
'check_credential_status',
|
|
78
|
+
'CLR_CONTEXT_SHA256',
|
|
79
|
+
'CLR_CONTEXT_URL',
|
|
80
|
+
'decode_encoded_list',
|
|
73
81
|
'did_jwk_from_jwk',
|
|
74
82
|
'did_key_from_pem',
|
|
75
83
|
'did_web_from_url',
|
|
76
84
|
'encode_bitstring',
|
|
77
85
|
'multikey_from_pem',
|
|
86
|
+
'OB_V3_CONTEXT',
|
|
78
87
|
'resolve_did',
|
|
79
88
|
'resolve_verification_method',
|
|
89
|
+
'set_indices',
|
|
90
|
+
'sign_clr_credential',
|
|
80
91
|
'sign_status_list_credential',
|
|
81
92
|
'status_entry',
|
|
93
|
+
'VCDM_V2_CONTEXT',
|
|
82
94
|
'verify_endorsement_jwt',
|
|
83
95
|
]
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenBadges Library
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2014-2026, Luis González Fernández, luisgf@luisgf.es
|
|
5
|
+
Copyright (c) 2014-2026, Jesús Cea Avión, jcea@jcea.es
|
|
6
|
+
|
|
7
|
+
All rights reserved.
|
|
8
|
+
|
|
9
|
+
This library is free software; you can redistribute it and/or
|
|
10
|
+
modify it under the terms of the GNU Lesser General Public
|
|
11
|
+
License as published by the Free Software Foundation; either
|
|
12
|
+
version 3.0 of the License, or (at your option) any later version.
|
|
13
|
+
|
|
14
|
+
This library is distributed in the hope that it will be useful,
|
|
15
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
16
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
17
|
+
Lesser General Public License for more details.
|
|
18
|
+
|
|
19
|
+
You should have received a copy of the GNU Lesser General Public
|
|
20
|
+
License along with this library.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
# 1EdTech Comprehensive Learner Record 2.0 (Final v1.1) ClrCredential assembly.
|
|
24
|
+
#
|
|
25
|
+
# Builds an unsigned JSON-LD envelope whose credentialSubject.verifiableCredential
|
|
26
|
+
# array embeds already-signed member documents by value. Email hashing and
|
|
27
|
+
# member-count caps are product policy and stay downstream: this module copies
|
|
28
|
+
# the identifier objects and members it is given.
|
|
29
|
+
#
|
|
30
|
+
# The CLR JSON-LD context is pinned to the VERSIONED 2.0.1 revision. 1EdTech's
|
|
31
|
+
# unversioned …/clr/v2p0/context.json endpoint serves a stub whose ClrSubject
|
|
32
|
+
# scope defines no ``identifier`` term — signing against it silently drops the
|
|
33
|
+
# learner binding from the RDF. The versioned bytes are bundled next to the
|
|
34
|
+
# other contexts and hash-checked on every load so a bad bundle fails closed.
|
|
35
|
+
|
|
36
|
+
import copy
|
|
37
|
+
import hashlib
|
|
38
|
+
import json
|
|
39
|
+
|
|
40
|
+
from datetime import datetime, timezone
|
|
41
|
+
from importlib import resources
|
|
42
|
+
from typing import Any, Mapping, Optional, Sequence, Union
|
|
43
|
+
|
|
44
|
+
from ..errors import LibOpenBadgesException
|
|
45
|
+
from . import contexts as _contexts
|
|
46
|
+
from .credential import _iso
|
|
47
|
+
|
|
48
|
+
VCDM_V2_CONTEXT = "https://www.w3.org/ns/credentials/v2"
|
|
49
|
+
OB_V3_CONTEXT = "https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json"
|
|
50
|
+
CLR_CONTEXT_URL = "https://purl.imsglobal.org/spec/clr/v2p0/context-2.0.1.json"
|
|
51
|
+
CLR_CONTEXT_SHA256 = "880f1902d4158f2f33ce06af41b90719977cd1ea337a9de2e41edb3ff5e543d6"
|
|
52
|
+
|
|
53
|
+
_CLR_CONTEXT_RESOURCE = "clr_context_2.0.1.json"
|
|
54
|
+
_ENVELOPE_CONTEXTS = (VCDM_V2_CONTEXT, OB_V3_CONTEXT, CLR_CONTEXT_URL)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _bundled_context_bytes() -> bytes:
|
|
58
|
+
"""Raw bytes of the bundled CLR context file (split out so tests can stub)."""
|
|
59
|
+
return resources.files(_contexts).joinpath(_CLR_CONTEXT_RESOURCE).read_bytes()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def bundled_clr_contexts() -> dict[str, dict[str, Any]]:
|
|
63
|
+
"""Pinned CLR 2.0.1 context mapping for Data Integrity ``extra_contexts``.
|
|
64
|
+
|
|
65
|
+
Hashes the bundled file's raw bytes against :data:`CLR_CONTEXT_SHA256` on
|
|
66
|
+
every call and raises :class:`LibOpenBadgesException` if they do not match
|
|
67
|
+
— a context is signing input, so a bad bundle must fail closed.
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
raw = _bundled_context_bytes()
|
|
71
|
+
except (OSError, FileNotFoundError) as exc:
|
|
72
|
+
raise LibOpenBadgesException(
|
|
73
|
+
"bundled CLR context is unreadable") from exc
|
|
74
|
+
if hashlib.sha256(raw).hexdigest() != CLR_CONTEXT_SHA256:
|
|
75
|
+
raise LibOpenBadgesException(
|
|
76
|
+
"bundled CLR context does not match its pinned hash")
|
|
77
|
+
document = json.loads(raw)
|
|
78
|
+
if not isinstance(document, dict):
|
|
79
|
+
raise LibOpenBadgesException("bundled CLR context is not an object")
|
|
80
|
+
return {CLR_CONTEXT_URL: document}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def build_clr_credential(
|
|
84
|
+
issuer: Union[str, Mapping[str, Any]],
|
|
85
|
+
identifier: Sequence[Mapping[str, Any]],
|
|
86
|
+
members: Sequence[Mapping[str, Any]],
|
|
87
|
+
*,
|
|
88
|
+
credential_id: str,
|
|
89
|
+
valid_from: Optional[datetime] = None,
|
|
90
|
+
valid_until: Optional[datetime] = None,
|
|
91
|
+
status: Optional[Mapping[str, Any]] = None) -> dict[str, Any]:
|
|
92
|
+
"""Build an unsigned CLR 2.0 ``ClrCredential`` JSON-LD document.
|
|
93
|
+
|
|
94
|
+
*identifier* is a list of IdentityObject dicts (already hashed — this
|
|
95
|
+
function does not hash emails). *members* are already-signed member
|
|
96
|
+
credential documents, copied into ``credentialSubject.verifiableCredential``.
|
|
97
|
+
*issuer* may be a Profile dict or a string URL. *status*, when given, is
|
|
98
|
+
copied onto ``credentialStatus``.
|
|
99
|
+
|
|
100
|
+
Raises ValueError if *members* is not a non-empty list of dicts, if
|
|
101
|
+
*credential_id* is empty, or if both *valid_from* and *valid_until* are
|
|
102
|
+
timezone-aware and *valid_until* is not strictly after *valid_from*.
|
|
103
|
+
"""
|
|
104
|
+
if not isinstance(credential_id, str) or not credential_id:
|
|
105
|
+
raise ValueError("credential_id must be a non-empty string")
|
|
106
|
+
if not isinstance(members, list) or not members:
|
|
107
|
+
raise ValueError("members must be a non-empty list of credential dicts")
|
|
108
|
+
if not isinstance(identifier, list):
|
|
109
|
+
raise ValueError("identifier must be a list of IdentityObject dicts")
|
|
110
|
+
|
|
111
|
+
issued = valid_from if valid_from is not None else datetime.now(timezone.utc)
|
|
112
|
+
if (valid_until is not None
|
|
113
|
+
and issued.tzinfo is not None and valid_until.tzinfo is not None
|
|
114
|
+
and valid_until <= issued):
|
|
115
|
+
raise ValueError("validUntil must be after validFrom")
|
|
116
|
+
|
|
117
|
+
if isinstance(issuer, str):
|
|
118
|
+
if not issuer:
|
|
119
|
+
raise ValueError("issuer must be a non-empty string or a dict")
|
|
120
|
+
issuer_value: Union[str, dict[str, Any]] = issuer
|
|
121
|
+
elif isinstance(issuer, dict):
|
|
122
|
+
issuer_value = copy.deepcopy(dict(issuer))
|
|
123
|
+
else:
|
|
124
|
+
raise ValueError("issuer must be a string URL or a dict")
|
|
125
|
+
|
|
126
|
+
copied_ids: list[dict[str, Any]] = []
|
|
127
|
+
for i, item in enumerate(identifier):
|
|
128
|
+
if not isinstance(item, dict):
|
|
129
|
+
raise ValueError("identifier[%d] must be a JSON object" % i)
|
|
130
|
+
copied_ids.append(copy.deepcopy(item))
|
|
131
|
+
|
|
132
|
+
issuer_id = issuer_value if isinstance(issuer_value, str) else issuer_value.get("id")
|
|
133
|
+
copied_members: list[dict[str, Any]] = []
|
|
134
|
+
for i, member in enumerate(members):
|
|
135
|
+
if not isinstance(member, dict):
|
|
136
|
+
raise ValueError("members[%d] must be a JSON object" % i)
|
|
137
|
+
member_id = member.get("id", member.get("@id"))
|
|
138
|
+
if isinstance(member_id, str) and member_id in (credential_id, issuer_id):
|
|
139
|
+
raise ValueError(
|
|
140
|
+
"members[%d] id collides with the CLR envelope or issuer id" % i)
|
|
141
|
+
copied_members.append(copy.deepcopy(member))
|
|
142
|
+
|
|
143
|
+
document: dict[str, Any] = {
|
|
144
|
+
"@context": list(_ENVELOPE_CONTEXTS),
|
|
145
|
+
"type": ["VerifiableCredential", "ClrCredential"],
|
|
146
|
+
"id": credential_id,
|
|
147
|
+
"issuer": issuer_value,
|
|
148
|
+
"validFrom": _iso(issued),
|
|
149
|
+
"credentialSubject": {
|
|
150
|
+
"type": "ClrSubject",
|
|
151
|
+
"identifier": copied_ids,
|
|
152
|
+
"verifiableCredential": copied_members,
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
if valid_until is not None:
|
|
156
|
+
document["validUntil"] = _iso(valid_until)
|
|
157
|
+
if status is not None:
|
|
158
|
+
if not isinstance(status, dict):
|
|
159
|
+
raise ValueError("credentialStatus must be a JSON object")
|
|
160
|
+
document["credentialStatus"] = copy.deepcopy(dict(status))
|
|
161
|
+
return document
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def sign_clr_credential(document: dict[str, Any], signing_key_pem: Any,
|
|
165
|
+
verification_method: str) -> dict[str, Any]:
|
|
166
|
+
"""Sign a ``ClrCredential`` with an eddsa-rdfc-2022 Data Integrity proof.
|
|
167
|
+
|
|
168
|
+
Thin wrapper around :func:`add_data_integrity_proof` that injects the
|
|
169
|
+
hash-pinned CLR context via ``extra_contexts`` — the library never fetches
|
|
170
|
+
remote contexts, so without this injection canonicalization cannot see the
|
|
171
|
+
CLR terms. Requires the optional ``[ldp]`` extra.
|
|
172
|
+
"""
|
|
173
|
+
from .ldp import add_data_integrity_proof
|
|
174
|
+
return add_data_integrity_proof(
|
|
175
|
+
document, signing_key_pem, verification_method,
|
|
176
|
+
extra_contexts=bundled_clr_contexts())
|
|
@@ -51,6 +51,9 @@
|
|
|
51
51
|
# https://w3id.org/security/multikey/v1
|
|
52
52
|
# security-multikey-v1.json
|
|
53
53
|
# ba2c182de2d92f7e47184bcca8fcf0beaee6d3986c527bf664c195bbc7c58597
|
|
54
|
+
# https://purl.imsglobal.org/spec/clr/v2p0/context-2.0.1.json (captured 2026-08-01)
|
|
55
|
+
# clr_context_2.0.1.json
|
|
56
|
+
# 880f1902d4158f2f33ce06af41b90719977cd1ea337a9de2e41edb3ff5e543d6
|
|
54
57
|
#
|
|
55
58
|
# This module deliberately does NOT import pyld: it only hands out plain dicts
|
|
56
59
|
# in the shape pyld's documentLoader expects, so it works (and is tested)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"@context": {
|
|
3
|
+
"id": "@id",
|
|
4
|
+
"type": "@type",
|
|
5
|
+
"ClrCredential": {
|
|
6
|
+
"@id": "https://purl.imsglobal.org/spec/vc/clr/vocab.html#ClrCredential",
|
|
7
|
+
"@context": {
|
|
8
|
+
"id": "@id",
|
|
9
|
+
"type": "@type",
|
|
10
|
+
"partial": {
|
|
11
|
+
"@id": "https://purl.imsglobal.org/spec/vc/clr/vocab.html#partial",
|
|
12
|
+
"@type": "xsd:boolean"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"ClrSubject": {
|
|
17
|
+
"@id": "https://purl.imsglobal.org/spec/vc/clr/vocab.html#ClrSubject",
|
|
18
|
+
"@context": {
|
|
19
|
+
"id": "@id",
|
|
20
|
+
"type": "@type",
|
|
21
|
+
"cred": "https://www.w3.org/2018/credentials#",
|
|
22
|
+
"obi": "https://purl.imsglobal.org/spec/vc/ob/vocab.html#",
|
|
23
|
+
"achievement": {
|
|
24
|
+
"@id": "https://purl.imsglobal.org/spec/vc/clr/vocab.html#achievement",
|
|
25
|
+
"@type": "obi:Achievement",
|
|
26
|
+
"@container": "@set"
|
|
27
|
+
},
|
|
28
|
+
"association": {
|
|
29
|
+
"@id": "https://purl.imsglobal.org/spec/vc/clr/vocab.html#association",
|
|
30
|
+
"@type": "https://purl.imsglobal.org/spec/vc/clr/vocab.html#Association",
|
|
31
|
+
"@container": "@set"
|
|
32
|
+
},
|
|
33
|
+
"verifiableCredential": {
|
|
34
|
+
"@id": "https://purl.imsglobal.org/spec/vc/clr/vocab.html#verifiableCredential",
|
|
35
|
+
"@type": "cred:verifiableCredential",
|
|
36
|
+
"@container": "@set"
|
|
37
|
+
},
|
|
38
|
+
"identifier": {
|
|
39
|
+
"@id": "https://purl.imsglobal.org/spec/vc/clr/vocab.html#identifier-1",
|
|
40
|
+
"@type": "obi:Identifier",
|
|
41
|
+
"@container": "@set"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"Association": {
|
|
46
|
+
"@id": "https://purl.imsglobal.org/spec/vc/clr/vocab.html#Association",
|
|
47
|
+
"@context": {
|
|
48
|
+
"associationType": {
|
|
49
|
+
"@id": "https://purl.imsglobal.org/spec/vc/clr/vocab.html#AssociationType"
|
|
50
|
+
},
|
|
51
|
+
"sourceId": {
|
|
52
|
+
"@id": "https://purl.imsglobal.org/spec/vc/clr/vocab.html#sourceId",
|
|
53
|
+
"@type": "xsd:anyURI"
|
|
54
|
+
},
|
|
55
|
+
"targetId": {
|
|
56
|
+
"@id": "https://purl.imsglobal.org/spec/vc/clr/vocab.html#targetId",
|
|
57
|
+
"@type": "xsd:anyURI"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -142,7 +142,7 @@ def _check_entry(entry: dict[str, Any], download: Callable[[str], bytes],
|
|
|
142
142
|
encoded = subject.get("encodedList")
|
|
143
143
|
if not isinstance(encoded, str) or not encoded:
|
|
144
144
|
raise OB3VerificationError("status list credential has no encodedList")
|
|
145
|
-
bitstring =
|
|
145
|
+
bitstring = decode_encoded_list(encoded)
|
|
146
146
|
list_purposes = set(_as_list(subject.get("statusPurpose")))
|
|
147
147
|
except OB3VerificationError:
|
|
148
148
|
raise
|
|
@@ -320,20 +320,68 @@ def _b64url_decode(value: str) -> bytes:
|
|
|
320
320
|
return base64.urlsafe_b64decode(value)
|
|
321
321
|
|
|
322
322
|
|
|
323
|
-
def
|
|
324
|
-
"""Decode
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
323
|
+
def decode_encoded_list(encoded: str) -> bytes:
|
|
324
|
+
"""Decode a Bitstring Status List ``encodedList`` to the raw bitstring.
|
|
325
|
+
|
|
326
|
+
Accepts the multibase base64url prefix ``u`` (Bitstring Status List v1.0)
|
|
327
|
+
or a bare base64url string, then inflates the GZIP payload. The
|
|
328
|
+
decompressed bitstring is capped at :data:`MAX_STATUS_LIST_BYTES` (5 MiB)
|
|
329
|
+
so a crafted gzip bomb cannot exhaust memory — the list is fetched from a
|
|
330
|
+
URL named in an untrusted credential.
|
|
331
|
+
|
|
332
|
+
Raises:
|
|
333
|
+
ValueError: *encoded* is not a non-empty string, or is not valid
|
|
334
|
+
base64url / gzip.
|
|
335
|
+
OB3VerificationError: the inflated bitstring exceeds the 5 MiB cap.
|
|
336
|
+
"""
|
|
337
|
+
if not isinstance(encoded, str) or not encoded:
|
|
338
|
+
raise ValueError("encodedList must be a non-empty string")
|
|
339
|
+
payload = encoded[1:] if encoded.startswith('u') else encoded
|
|
340
|
+
if not payload:
|
|
341
|
+
raise ValueError("encodedList is missing base64url data")
|
|
342
|
+
try:
|
|
343
|
+
compressed = _b64url_decode(payload)
|
|
344
|
+
if not compressed:
|
|
345
|
+
raise ValueError("encodedList is not valid base64url")
|
|
346
|
+
inflator = zlib.decompressobj(wbits=31) # 31 => gzip framing
|
|
347
|
+
out = inflator.decompress(compressed, MAX_STATUS_LIST_BYTES)
|
|
348
|
+
except OB3VerificationError:
|
|
349
|
+
raise
|
|
350
|
+
except Exception as exc:
|
|
351
|
+
raise ValueError("invalid encodedList: %s" % exc) from exc
|
|
331
352
|
if inflator.unconsumed_tail:
|
|
332
353
|
raise OB3VerificationError(
|
|
333
354
|
"status list bitstring exceeds the %d-byte limit" % MAX_STATUS_LIST_BYTES)
|
|
355
|
+
if not inflator.eof:
|
|
356
|
+
raise ValueError("invalid encodedList: truncated gzip")
|
|
334
357
|
return out
|
|
335
358
|
|
|
336
359
|
|
|
360
|
+
def _decode_encoded_list(encoded: str) -> bytes:
|
|
361
|
+
"""Private alias of :func:`decode_encoded_list` (kept for existing tests)."""
|
|
362
|
+
return decode_encoded_list(encoded)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def set_indices(bitstring: bytes) -> frozenset[int]:
|
|
366
|
+
"""Return the indices of bits that are set in an MSB-first bitstring.
|
|
367
|
+
|
|
368
|
+
The bit order matches :func:`_bit_set` / Bitstring Status List v1.0:
|
|
369
|
+
index 0 is the high bit of the first byte. Pair with
|
|
370
|
+
:func:`decode_encoded_list` to recover the set of revoked/suspended
|
|
371
|
+
indices from a published ``encodedList``.
|
|
372
|
+
"""
|
|
373
|
+
if not isinstance(bitstring, (bytes, bytearray)):
|
|
374
|
+
raise ValueError("bitstring must be bytes")
|
|
375
|
+
total = len(bitstring) * 8
|
|
376
|
+
rendered = format(int.from_bytes(bitstring, "big"), "0%db" % total)
|
|
377
|
+
found: set[int] = set()
|
|
378
|
+
index = rendered.find("1")
|
|
379
|
+
while index != -1:
|
|
380
|
+
found.add(index)
|
|
381
|
+
index = rendered.find("1", index + 1)
|
|
382
|
+
return frozenset(found)
|
|
383
|
+
|
|
384
|
+
|
|
337
385
|
def _bit_set(bitstring: bytes, index: int) -> bool:
|
|
338
386
|
"""Return the bit at *index* in a big-endian (MSB-first) bitstring, matching
|
|
339
387
|
the Bitstring Status List / StatusList2021 bit ordering."""
|
|
@@ -78,7 +78,8 @@ 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
80
|
issued: Optional[datetime] = None,
|
|
81
|
-
valid_until: Optional[datetime] = None
|
|
81
|
+
valid_until: Optional[datetime] = None,
|
|
82
|
+
ttl: Optional[int] = None
|
|
82
83
|
) -> dict[str, Any]:
|
|
83
84
|
"""Build an (unsigned) BitstringStatusListCredential document.
|
|
84
85
|
|
|
@@ -96,10 +97,27 @@ def build_status_list_credential(issuer_id: str, url: str, purpose: str,
|
|
|
96
97
|
authentic older copy (with fewer revocations) passes the window unchecked.
|
|
97
98
|
openbadges-publish warns loudly when it does this; issuers of revocable
|
|
98
99
|
badges should set ``status_validity_days`` and republish on that cadence.
|
|
100
|
+
|
|
101
|
+
*ttl*, when given, is Bitstring Status List §2.2's refresh hint in
|
|
102
|
+
milliseconds. It is written on ``credentialSubject`` (the JSON-LD term is
|
|
103
|
+
scoped to ``BitstringStatusList``); a top-level copy would be dropped
|
|
104
|
+
during expansion and would not be covered by a Data Integrity proof.
|
|
99
105
|
"""
|
|
100
106
|
if purpose not in STATUS_PURPOSES:
|
|
101
107
|
raise ValueError("statusPurpose must be one of %r, got %r"
|
|
102
108
|
% (STATUS_PURPOSES, purpose))
|
|
109
|
+
if ttl is not None and (isinstance(ttl, bool) or not isinstance(ttl, int)
|
|
110
|
+
or ttl < 0):
|
|
111
|
+
raise ValueError("ttl must be a non-negative integer (milliseconds), "
|
|
112
|
+
"got %r" % (ttl,))
|
|
113
|
+
subject: dict[str, Any] = {
|
|
114
|
+
"id": url + "#list",
|
|
115
|
+
"type": "BitstringStatusList",
|
|
116
|
+
"statusPurpose": purpose,
|
|
117
|
+
"encodedList": encode_bitstring(set_indices, size_bits),
|
|
118
|
+
}
|
|
119
|
+
if ttl is not None:
|
|
120
|
+
subject["ttl"] = ttl
|
|
103
121
|
vc: dict[str, Any] = {
|
|
104
122
|
"@context": [_VC2_CONTEXT],
|
|
105
123
|
"id": url,
|
|
@@ -107,12 +125,7 @@ def build_status_list_credential(issuer_id: str, url: str, purpose: str,
|
|
|
107
125
|
"issuer": issuer_id,
|
|
108
126
|
"validFrom": _iso(issued if issued is not None
|
|
109
127
|
else datetime.now(tz=timezone.utc)),
|
|
110
|
-
"credentialSubject":
|
|
111
|
-
"id": url + "#list",
|
|
112
|
-
"type": "BitstringStatusList",
|
|
113
|
-
"statusPurpose": purpose,
|
|
114
|
-
"encodedList": encode_bitstring(set_indices, size_bits),
|
|
115
|
-
},
|
|
128
|
+
"credentialSubject": subject,
|
|
116
129
|
}
|
|
117
130
|
if valid_until is not None:
|
|
118
131
|
vc["validUntil"] = _iso(valid_until)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openbadgeslib
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.5.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
|
|
@@ -49,6 +49,7 @@ openbadgeslib/ob2/models.py
|
|
|
49
49
|
openbadgeslib/ob2/signer.py
|
|
50
50
|
openbadgeslib/ob2/verifier.py
|
|
51
51
|
openbadgeslib/ob3/__init__.py
|
|
52
|
+
openbadgeslib/ob3/clr.py
|
|
52
53
|
openbadgeslib/ob3/credential.py
|
|
53
54
|
openbadgeslib/ob3/did.py
|
|
54
55
|
openbadgeslib/ob3/eudi.py
|
|
@@ -60,6 +61,7 @@ openbadgeslib/ob3/status_list.py
|
|
|
60
61
|
openbadgeslib/ob3/status_registry.py
|
|
61
62
|
openbadgeslib/ob3/verifier.py
|
|
62
63
|
openbadgeslib/ob3/contexts/__init__.py
|
|
64
|
+
openbadgeslib/ob3/contexts/clr_context_2.0.1.json
|
|
63
65
|
openbadgeslib/ob3/contexts/credentials-v2.json
|
|
64
66
|
openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json
|
|
65
67
|
openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json
|
|
@@ -108,6 +110,7 @@ tests/test_ob2_error_branches.py
|
|
|
108
110
|
tests/test_ob2_models.py
|
|
109
111
|
tests/test_ob2_signer.py
|
|
110
112
|
tests/test_ob2_verifier.py
|
|
113
|
+
tests/test_ob3_clr.py
|
|
111
114
|
tests/test_ob3_conformance_schema.py
|
|
112
115
|
tests/test_ob3_contexts.py
|
|
113
116
|
tests/test_ob3_credential.py
|
|
@@ -150,6 +153,7 @@ tests/conformance/Dockerfile.ob3
|
|
|
150
153
|
tests/conformance/README.md
|
|
151
154
|
tests/conformance/conftest.py
|
|
152
155
|
tests/conformance/docker-compose.yml
|
|
156
|
+
tests/conformance/relax-danubetech-checksums.awk
|
|
153
157
|
tests/conformance/test_official_validators.py
|
|
154
158
|
tests/fixtures/ob_schemas/README.md
|
|
155
159
|
tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json
|