openbadgeslib 4.4.3__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.3 → openbadgeslib-4.5.0}/Changelog.txt +53 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/PKG-INFO +1 -1
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/baking.py +66 -1
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/confparser.py +9 -1
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/mail.py +21 -1
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob2/verifier.py +82 -13
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/__init__.py +13 -1
- openbadgeslib-4.5.0/openbadgeslib/ob3/clr.py +176 -0
- {openbadgeslib-4.4.3 → 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.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/ldp.py +29 -1
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/status.py +57 -9
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/status_list.py +20 -7
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/errors.py +4 -1
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/handler.py +7 -4
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_signer.py +26 -4
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/util.py +81 -36
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib.egg-info/PKG-INFO +1 -1
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib.egg-info/SOURCES.txt +4 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/conformance/Dockerfile.ob3 +11 -1
- {openbadgeslib-4.4.3 → 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.3 → openbadgeslib-4.5.0}/tests/test_baking.py +32 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_cli_smoke.py +26 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_mail.py +16 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob2_error_branches.py +2 -1
- {openbadgeslib-4.4.3 → 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.3 → openbadgeslib-4.5.0}/tests/test_ob3_ldp_ecdsa_sd.py +64 -5
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_status.py +37 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_status_list.py +18 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_oid4vci_config.py +10 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_oid4vci_protocol.py +7 -2
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_util.py +26 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/CONTRIBUTING.md +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/GOVERNANCE.md +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/LICENSE.txt +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/MANIFEST.in +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/README.md +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/SECURITY.md +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/docs/README.md +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/__init__.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/_jsonmodel.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/_jws/__init__.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/_jws/exceptions.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/_jws/utils.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/_ob1_api.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/badge_model.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/cli_common.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/config.ini.example +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/errors.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/issue.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/keys.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/logs.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob1/__init__.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob1/badge.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob1/signer.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob1/verifier.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob2/__init__.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob2/models.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob2/signer.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/ob-v3p0-extensions.json +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/credential.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/did.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/eudi.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/publish.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/signer.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/status_registry.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/ob3/verifier.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/__init__.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/codes.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/formats.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/memory_store.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/metadata.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/nonce.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/offer.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/reconcile.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/sqlite_store.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/oid4vci/store.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_cli.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_init.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_keygenerator.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_publish.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_status.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/openbadges_verifier.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/py.typed +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib/verify.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib.egg-info/dependency_links.txt +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib.egg-info/entry_points.txt +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib.egg-info/requires.txt +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/openbadgeslib.egg-info/top_level.txt +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/pyproject.toml +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/setup.cfg +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/config1.ini +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/conformance/conftest.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/conformance/docker-compose.yml +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/conformance/test_official_validators.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/conftest.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/fixtures/ob_schemas/README.md +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/fixtures/ob_schemas/refresh.sh +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/fixtures/vc_di_eddsa/README.md +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/images/sample1.png +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/images/sample1.svg +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/images/userimage01.svg +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/ldp_helpers.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/logo Python Espan/314/203a.svg" +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/logo Python Espa/303/261a.svg" +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_cli_json.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_confparser.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_docs.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_eddsa.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_errors.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_examples.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_issue.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_issue_credential.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_jsonmodel.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_jws.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_key_operation.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_keys.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob1_badge_io.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob1_deprecation.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob1_signer.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob1_verifier.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob2_cli.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob2_models.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob2_signer.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_conformance_schema.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_contexts.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_credential.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_did.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_did_doc.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_endorsement.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_eudi_oid4vci.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_eudi_sd_jwt.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_eudi_type_metadata.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_eudi_x5c.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_ldp.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_ldp_cli.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_ldp_errors.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_ldp_sign.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_publish_cli.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_signer.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_signer_cli_ldp.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_status_registry.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_ob3_verifier.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_oid4vci_reservations.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_oid4vci_store.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_openbadges_cli.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_public_api.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_sign_ecc.pem +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_sign_rsa.pem +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_signer_batch.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_verifier_cli_coverage.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_verify.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_verify_ecc.pem +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/test_verify_rsa.pem +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/withoutxmlheader.svg +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.5.0}/tests/withxmlheader.svg +0 -0
|
@@ -4,6 +4,59 @@ 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
|
+
|
|
34
|
+
* v4.4.4 - 2026-09-10
|
|
35
|
+
|
|
36
|
+
- security(baking): SVG baking strips ``<script>`` / ``foreignObject`` /
|
|
37
|
+
event-handler attributes and ``javascript:`` / hostile ``data:`` URLs
|
|
38
|
+
from the carrier so a source image cannot turn a baked badge into an
|
|
39
|
+
XSS gadget.
|
|
40
|
+
- security(ob3): ``ecdsa-sd-2023`` presentations that omit ``validUntil``
|
|
41
|
+
or ``credentialStatus`` fail closed — a holder can no longer hide expiry
|
|
42
|
+
or revocation behind selective disclosure.
|
|
43
|
+
- security(oid4vci): explicit ``credential_endpoint`` / ``token_endpoint``
|
|
44
|
+
/ ``nonce_endpoint`` overrides must be absolute ``https://`` URLs, not
|
|
45
|
+
plaintext HTTP.
|
|
46
|
+
- security(cli): ``signer.log`` is created and kept at mode ``0600``
|
|
47
|
+
(recipient emails); a file inherited from a permissive umask is
|
|
48
|
+
tightened on the next append.
|
|
49
|
+
- security(ci): wiki-sync authenticates clone/push with an HTTP extraheader
|
|
50
|
+
instead of embedding ``GITHUB_TOKEN`` in the git remote URL.
|
|
51
|
+
- security(mail): SMTP connections time out after 30 s; plaintext SMTP to
|
|
52
|
+
a non-loopback host is upgraded with STARTTLS (validating context).
|
|
53
|
+
- security(util): ``allow_insecure=True`` HTTP downloads pin the connect
|
|
54
|
+
IP the same way HTTPS already did (DNS rebinding).
|
|
55
|
+
- security(oid4vci): a live PIN-gated offer presented without ``tx_code``
|
|
56
|
+
returns the same ``invalid_grant`` as an unknown code, so the token
|
|
57
|
+
endpoint is not an oracle for which offers are PIN-gated.
|
|
58
|
+
|
|
59
|
+
|
|
7
60
|
* v4.4.3 - 2026-08-18
|
|
8
61
|
|
|
9
62
|
- security(oid4vci): an issuer that advertises
|
|
@@ -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
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
from struct import pack
|
|
30
30
|
from zlib import crc32
|
|
31
31
|
|
|
32
|
-
from typing import List, Optional, Tuple, Union, cast
|
|
32
|
+
from typing import Any, List, Optional, Tuple, Union, cast
|
|
33
33
|
|
|
34
34
|
from defusedxml.minidom import parseString
|
|
35
35
|
from png import Reader, signature as _png_signature
|
|
@@ -76,6 +76,66 @@ def _bounded_inflate(data: bytes, limit: int = MAX_ITXT_DECOMPRESSED) -> bytes:
|
|
|
76
76
|
|
|
77
77
|
# ── SVG ─────────────────────────────────────────────────────────────────────
|
|
78
78
|
|
|
79
|
+
# Active content stripped from the carrier at bake time. A baked badge is an
|
|
80
|
+
# image a recipient may open in a browser; leaving <script>, on* handlers or
|
|
81
|
+
# javascript: URLs in the source SVG would execute in that context (#328).
|
|
82
|
+
# `opacity` is the one SVG presentation attribute whose local name starts
|
|
83
|
+
# with "on" and is not an event handler.
|
|
84
|
+
_ACTIVE_SVG_ELEMENTS = frozenset({
|
|
85
|
+
'script', 'foreignobject', 'iframe', 'embed', 'object', 'handler',
|
|
86
|
+
})
|
|
87
|
+
_URL_ATTRS = frozenset({'href', 'src', 'action'})
|
|
88
|
+
_SAFE_DATA_IMAGE_TYPES = (
|
|
89
|
+
'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _svg_local_name(node: Any) -> str:
|
|
94
|
+
return str(node.tagName).split(':')[-1].lower()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _iter_svg_elements(node: Any) -> List[Any]:
|
|
98
|
+
found: List[Any] = []
|
|
99
|
+
if getattr(node, 'nodeType', None) == node.ELEMENT_NODE:
|
|
100
|
+
found.append(node)
|
|
101
|
+
for child in list(node.childNodes):
|
|
102
|
+
found.extend(_iter_svg_elements(child))
|
|
103
|
+
return found
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _is_active_url(value: str) -> bool:
|
|
107
|
+
"""True for javascript:/vbscript: and for data: that is not a raster image."""
|
|
108
|
+
lowered = value.strip().lower()
|
|
109
|
+
if lowered.startswith('javascript:') or lowered.startswith('vbscript:'):
|
|
110
|
+
return True
|
|
111
|
+
if not lowered.startswith('data:'):
|
|
112
|
+
return False
|
|
113
|
+
payload = lowered[5:]
|
|
114
|
+
return not any(payload.startswith(kind) for kind in _SAFE_DATA_IMAGE_TYPES)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _strip_svg_active_content(root: Any) -> None:
|
|
118
|
+
"""Drop script-class elements, on* handlers and active URLs under *root*."""
|
|
119
|
+
if root is None:
|
|
120
|
+
return
|
|
121
|
+
for el in _iter_svg_elements(root):
|
|
122
|
+
if _svg_local_name(el) in _ACTIVE_SVG_ELEMENTS:
|
|
123
|
+
parent = el.parentNode
|
|
124
|
+
if parent is not None:
|
|
125
|
+
parent.removeChild(el)
|
|
126
|
+
for el in _iter_svg_elements(root):
|
|
127
|
+
attrs = el.attributes
|
|
128
|
+
if attrs is None:
|
|
129
|
+
continue
|
|
130
|
+
for name in list(attrs.keys()):
|
|
131
|
+
local = name.split(':')[-1].lower()
|
|
132
|
+
if local.startswith('on') and local != 'opacity':
|
|
133
|
+
el.removeAttribute(name)
|
|
134
|
+
continue
|
|
135
|
+
if local in _URL_ATTRS and _is_active_url(attrs[name].value):
|
|
136
|
+
el.removeAttribute(name)
|
|
137
|
+
|
|
138
|
+
|
|
79
139
|
def bake_svg(image_bytes: bytes, token: str, comment: Optional[str] = None, *,
|
|
80
140
|
element: str = SVG_ELEMENT, namespace: str = SVG_NS,
|
|
81
141
|
as_text: bool = False) -> bytes:
|
|
@@ -87,10 +147,15 @@ def bake_svg(image_bytes: bytes, token: str, comment: Optional[str] = None, *,
|
|
|
87
147
|
instead of the ``verify`` attribute — the OB 3.0 carrier for credentials
|
|
88
148
|
secured with a Data Integrity proof, whose payload is a JSON document
|
|
89
149
|
rather than a compact JWT (OB 3.0 §5.3).
|
|
150
|
+
|
|
151
|
+
Active content in the source SVG (``<script>``, event-handler attributes,
|
|
152
|
+
``javascript:`` / hostile ``data:`` URLs) is stripped before the token is
|
|
153
|
+
attached, so the baked carrier is not an XSS gadget (#328).
|
|
90
154
|
"""
|
|
91
155
|
svg_doc = parseString(image_bytes)
|
|
92
156
|
try:
|
|
93
157
|
svg_tag = svg_doc.getElementsByTagName('svg').item(0)
|
|
158
|
+
_strip_svg_active_content(svg_tag)
|
|
94
159
|
node = svg_doc.createElement(element)
|
|
95
160
|
node.attributes['xmlns:openbadges'] = namespace
|
|
96
161
|
if as_text:
|
|
@@ -305,7 +305,15 @@ def oid4vci_config(conf: ConfigParser) -> OID4VCIConfig:
|
|
|
305
305
|
|
|
306
306
|
def endpoint(key: str, default_path: str) -> str:
|
|
307
307
|
explicit = (section.get(key) or '').strip() if section else ''
|
|
308
|
-
|
|
308
|
+
url = explicit or urljoin(endpoint_base, default_path)
|
|
309
|
+
# Derived endpoints inherit https from credential_issuer; an explicit
|
|
310
|
+
# override can be any string, and a plaintext http URL would advertise
|
|
311
|
+
# a token/credential/nonce endpoint wallets POST secrets to (#328).
|
|
312
|
+
if not url.startswith('https://'):
|
|
313
|
+
raise ConfigError(
|
|
314
|
+
"[oid4vci] %s must be an absolute https URL, got %r"
|
|
315
|
+
% (key, url))
|
|
316
|
+
return url
|
|
309
317
|
|
|
310
318
|
store_path = (section.get('store_path') if section else None) or \
|
|
311
319
|
os.path.join(conf['paths']['base'], 'oid4vci.sqlite3')
|
|
@@ -33,6 +33,18 @@ from email.header import Header
|
|
|
33
33
|
from .badge_model import BadgeImgType # version-neutral model (was ob1.badge)
|
|
34
34
|
from .errors import BadgeImgFormatUnsupported
|
|
35
35
|
|
|
36
|
+
#: Per-operation socket timeout for SMTP connect/read/write. smtplib's
|
|
37
|
+
#: default is None (block forever).
|
|
38
|
+
SMTP_TIMEOUT_SECONDS = 30
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _smtp_is_loopback(host: str) -> bool:
|
|
42
|
+
"""True for the hostnames/addresses operators use for a local MTA."""
|
|
43
|
+
name = host.strip().lower()
|
|
44
|
+
if name.startswith('[') and name.endswith(']'):
|
|
45
|
+
name = name[1:-1]
|
|
46
|
+
return name in ('localhost', '127.0.0.1', '::1')
|
|
47
|
+
|
|
36
48
|
|
|
37
49
|
class BadgeMail():
|
|
38
50
|
def __init__(self, smtp_server: str = 'localhost', smtp_port: int = 25,
|
|
@@ -83,9 +95,17 @@ class BadgeMail():
|
|
|
83
95
|
# CERT_NONE), which would let an on-path attacker intercept the
|
|
84
96
|
# connection and capture the AUTH credentials sent below.
|
|
85
97
|
smtp = SMTP_SSL(self.smtp_server, self.smtp_port,
|
|
98
|
+
timeout=SMTP_TIMEOUT_SECONDS,
|
|
86
99
|
context=ssl.create_default_context())
|
|
87
100
|
else:
|
|
88
|
-
smtp = SMTP(self.smtp_server, self.smtp_port
|
|
101
|
+
smtp = SMTP(self.smtp_server, self.smtp_port,
|
|
102
|
+
timeout=SMTP_TIMEOUT_SECONDS)
|
|
103
|
+
# Plain SMTP to a non-loopback host is upgraded with STARTTLS
|
|
104
|
+
# so the message (and any later AUTH) is not sent in the
|
|
105
|
+
# clear. localhost is left alone: a local MTA often has no
|
|
106
|
+
# TLS listener on :25 (#328).
|
|
107
|
+
if not _smtp_is_loopback(self.smtp_server):
|
|
108
|
+
smtp.starttls(context=ssl.create_default_context())
|
|
89
109
|
|
|
90
110
|
if self.username:
|
|
91
111
|
smtp.login(self.username, self.password)
|
|
@@ -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
|
+
}
|