openbadgeslib 4.4.2__tar.gz → 4.4.4__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.2 → openbadgeslib-4.4.4}/CONTRIBUTING.md +3 -2
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/Changelog.txt +70 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/PKG-INFO +9 -7
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/README.md +5 -3
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/baking.py +66 -1
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/config.ini.example +7 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/confparser.py +57 -2
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/issue.py +6 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/mail.py +27 -5
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob1/verifier.py +4 -1
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob2/verifier.py +47 -8
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/ldp.py +29 -1
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/publish.py +23 -2
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/errors.py +4 -1
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/handler.py +36 -11
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/memory_store.py +12 -2
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/metadata.py +11 -4
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/offer.py +41 -15
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/reconcile.py +26 -10
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/sqlite_store.py +31 -4
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/store.py +6 -1
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_publish.py +4 -3
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_signer.py +26 -4
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/util.py +81 -36
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib.egg-info/PKG-INFO +9 -7
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib.egg-info/requires.txt +3 -3
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/pyproject.toml +7 -4
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_baking.py +32 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_cli_smoke.py +26 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_docs.py +12 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_mail.py +20 -1
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob1_signer.py +3 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob1_verifier.py +24 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob2_error_branches.py +6 -3
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob2_verifier.py +54 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_ldp_ecdsa_sd.py +64 -5
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_publish_cli.py +13 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_oid4vci_config.py +55 -1
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_oid4vci_protocol.py +116 -6
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_oid4vci_reservations.py +104 -5
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_oid4vci_store.py +48 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_util.py +26 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/GOVERNANCE.md +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/LICENSE.txt +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/MANIFEST.in +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/SECURITY.md +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/docs/README.md +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/__init__.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/_jsonmodel.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/_jws/__init__.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/_jws/exceptions.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/_jws/utils.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/_ob1_api.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/badge_model.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/cli_common.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/errors.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/keys.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/logs.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob1/__init__.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob1/badge.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob1/signer.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob2/__init__.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob2/models.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob2/signer.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/__init__.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/__init__.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/ob-v3p0-extensions.json +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/credential.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/did.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/eudi.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/signer.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/status.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/status_list.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/status_registry.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/ob3/verifier.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/__init__.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/codes.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/formats.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/nonce.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_cli.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_init.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_keygenerator.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_status.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_verifier.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/py.typed +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib/verify.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib.egg-info/SOURCES.txt +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib.egg-info/dependency_links.txt +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib.egg-info/entry_points.txt +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/openbadgeslib.egg-info/top_level.txt +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/setup.cfg +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/config1.ini +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/conformance/Dockerfile.ob3 +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/conformance/README.md +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/conformance/conftest.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/conformance/docker-compose.yml +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/conformance/test_official_validators.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/conftest.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/fixtures/ob_schemas/README.md +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/fixtures/ob_schemas/refresh.sh +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/fixtures/vc_di_eddsa/README.md +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/images/sample1.png +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/images/sample1.svg +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/images/userimage01.svg +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/ldp_helpers.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/logo Python Espan/314/203a.svg" +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/logo Python Espa/303/261a.svg" +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_cli_json.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_confparser.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_eddsa.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_errors.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_examples.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_issue.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_issue_credential.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_jsonmodel.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_jws.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_key_operation.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_keys.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob1_badge_io.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob1_deprecation.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob2_cli.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob2_models.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob2_signer.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_conformance_schema.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_contexts.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_credential.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_did.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_did_doc.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_endorsement.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_eudi_oid4vci.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_eudi_sd_jwt.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_eudi_type_metadata.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_eudi_x5c.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_ldp.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_ldp_cli.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_ldp_errors.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_ldp_sign.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_signer.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_signer_cli_ldp.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_status.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_status_list.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_status_registry.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_ob3_verifier.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_openbadges_cli.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_public_api.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_sign_ecc.pem +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_sign_rsa.pem +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_signer_batch.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_verifier_cli_coverage.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_verify.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_verify_ecc.pem +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/test_verify_rsa.pem +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/withoutxmlheader.svg +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.4}/tests/withxmlheader.svg +0 -0
|
@@ -75,8 +75,9 @@ policy is deliberately simple and predictable:
|
|
|
75
75
|
|
|
76
76
|
- A version is **dropped** — from `requires-python`, the classifiers, and the CI
|
|
77
77
|
matrix — at the next **major** release after it reaches end-of-life. For
|
|
78
|
-
example, Python **3.10 (EOL 2026-10)**
|
|
79
|
-
|
|
78
|
+
example, Python **3.10 (EOL 2026-10)** stays supported through 4.x and is
|
|
79
|
+
dropped at the next major after that EOL (5.0). Grouping drops into majors
|
|
80
|
+
keeps breaking changes rare.
|
|
80
81
|
- A new CPython minor is **added** to the CI matrix once it has a stable
|
|
81
82
|
(non-pre) release, so downstreams can adopt it early.
|
|
82
83
|
- The current matrix is **3.10–3.14**, and `requires-python = ">=3.10"`.
|
|
@@ -4,6 +4,76 @@ OpenBadgesLib - Changelog
|
|
|
4
4
|
Newest first. Dates are ISO 8601 (YYYY-MM-DD).
|
|
5
5
|
|
|
6
6
|
|
|
7
|
+
* v4.4.4 - 2026-09-10
|
|
8
|
+
|
|
9
|
+
- security(baking): SVG baking strips ``<script>`` / ``foreignObject`` /
|
|
10
|
+
event-handler attributes and ``javascript:`` / hostile ``data:`` URLs
|
|
11
|
+
from the carrier so a source image cannot turn a baked badge into an
|
|
12
|
+
XSS gadget.
|
|
13
|
+
- security(ob3): ``ecdsa-sd-2023`` presentations that omit ``validUntil``
|
|
14
|
+
or ``credentialStatus`` fail closed — a holder can no longer hide expiry
|
|
15
|
+
or revocation behind selective disclosure.
|
|
16
|
+
- security(oid4vci): explicit ``credential_endpoint`` / ``token_endpoint``
|
|
17
|
+
/ ``nonce_endpoint`` overrides must be absolute ``https://`` URLs, not
|
|
18
|
+
plaintext HTTP.
|
|
19
|
+
- security(cli): ``signer.log`` is created and kept at mode ``0600``
|
|
20
|
+
(recipient emails); a file inherited from a permissive umask is
|
|
21
|
+
tightened on the next append.
|
|
22
|
+
- security(ci): wiki-sync authenticates clone/push with an HTTP extraheader
|
|
23
|
+
instead of embedding ``GITHUB_TOKEN`` in the git remote URL.
|
|
24
|
+
- security(mail): SMTP connections time out after 30 s; plaintext SMTP to
|
|
25
|
+
a non-loopback host is upgraded with STARTTLS (validating context).
|
|
26
|
+
- security(util): ``allow_insecure=True`` HTTP downloads pin the connect
|
|
27
|
+
IP the same way HTTPS already did (DNS rebinding).
|
|
28
|
+
- security(oid4vci): a live PIN-gated offer presented without ``tx_code``
|
|
29
|
+
returns the same ``invalid_grant`` as an unknown code, so the token
|
|
30
|
+
endpoint is not an oracle for which offers are PIN-gated.
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
* v4.4.3 - 2026-08-18
|
|
34
|
+
|
|
35
|
+
- security(oid4vci): an issuer that advertises
|
|
36
|
+
``key_attestations_required`` now refuses a proof without
|
|
37
|
+
``key_attestation`` before the signature is checked and before the
|
|
38
|
+
``c_nonce`` is spent (openvc-core 1.26's ``require_key_attestation``).
|
|
39
|
+
Set ``oid4vci_key_attestations_required`` on the badge section, or pass
|
|
40
|
+
``require_key_attestation=True`` to ``handle_credential_request``.
|
|
41
|
+
Without this, a software wallet's §8.3.1 retry spent a fresh nonce on
|
|
42
|
+
every attempt and looped.
|
|
43
|
+
|
|
44
|
+
- build(deps): raise the openvc-core floors to `>=1.26` in the `[eudi]`,
|
|
45
|
+
`[ldp-sd]` and `[oid4vci]` extras.
|
|
46
|
+
|
|
47
|
+
- security(oid4vci): `--reclaim-unclaimed` no longer frees a status-list
|
|
48
|
+
index whose credential was already delivered to a wallet. Issued grants
|
|
49
|
+
are kept past the offer TTL (they are the only proof of delivery), and
|
|
50
|
+
a missing grant row is treated as undecided rather than "never claimed".
|
|
51
|
+
- security(oid4vci): presenting a pre-authorized code again after the
|
|
52
|
+
wallet has claimed no longer demotes the grant from issued to
|
|
53
|
+
invalidated (tokens are still revoked). That demotion made the next
|
|
54
|
+
`--reclaim-unclaimed` free the delivered credential's status index.
|
|
55
|
+
- security(ob2): a HostedBadge BadgeClass that embeds an issuer Profile
|
|
56
|
+
object is no longer trusted as-is. The Profile at ``issuer.id`` is
|
|
57
|
+
fetched and used for scope and revocation.
|
|
58
|
+
- security(publish): ``sd_jwt_vct`` paths are POSIX-normalized; a ``..``
|
|
59
|
+
that would write Type Metadata outside ``-o`` is refused.
|
|
60
|
+
- security(ob1): an empty revocation-list response fails closed instead
|
|
61
|
+
of treating the badge as not revoked.
|
|
62
|
+
- security(mail): attachment filenames are RFC 2231 encoded, and CR/LF
|
|
63
|
+
or quotes in a badge/recipient name are rejected before they can
|
|
64
|
+
inject MIME headers.
|
|
65
|
+
- security(ob2): HostedBadge ``startsWith`` is an origin + path-segment
|
|
66
|
+
prefix, not a raw string prefix (blocks subdomain / userinfo tricks).
|
|
67
|
+
- fix(oid4vci): a Credential Offer that fails validation (e.g. batch_size
|
|
68
|
+
> 1 with status_lists) no longer reserves a status-list index first.
|
|
69
|
+
- fix(ci): the Ubuntu test job installs ``[oid4vci]`` and sets
|
|
70
|
+
``OPENBADGES_REQUIRE_OID4VCI=1`` so the credential-endpoint suite cannot
|
|
71
|
+
skip itself green.
|
|
72
|
+
- fix(oid4vci): minting an access token stretches the grant to cover the
|
|
73
|
+
advertised ``expires_in``, so redeeming late in the offer window no
|
|
74
|
+
longer clips the token's lifetime.
|
|
75
|
+
|
|
76
|
+
|
|
7
77
|
* v4.4.2 - 2026-08-13
|
|
8
78
|
|
|
9
79
|
- fix(cli): key generation no longer crashes on Windows, where
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openbadgeslib
|
|
3
|
-
Version: 4.4.
|
|
3
|
+
Version: 4.4.4
|
|
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
|
|
@@ -36,11 +36,11 @@ Requires-Dist: defusedxml>=0.7
|
|
|
36
36
|
Provides-Extra: ldp
|
|
37
37
|
Requires-Dist: pyld>=2.0.4; extra == "ldp"
|
|
38
38
|
Provides-Extra: eudi
|
|
39
|
-
Requires-Dist: openvc-core<2,>=1.
|
|
39
|
+
Requires-Dist: openvc-core<2,>=1.26; extra == "eudi"
|
|
40
40
|
Provides-Extra: ldp-sd
|
|
41
|
-
Requires-Dist: openvc-core[data-integrity]<2,>=1.
|
|
41
|
+
Requires-Dist: openvc-core[data-integrity]<2,>=1.26; extra == "ldp-sd"
|
|
42
42
|
Provides-Extra: oid4vci
|
|
43
|
-
Requires-Dist: openvc-core<2,>=1.
|
|
43
|
+
Requires-Dist: openvc-core<2,>=1.26; extra == "oid4vci"
|
|
44
44
|
Provides-Extra: dev
|
|
45
45
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
46
46
|
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
@@ -144,7 +144,7 @@ all three OB versions.
|
|
|
144
144
|
- Python >= 3.10 (tested on 3.10–3.14)
|
|
145
145
|
- [pypng](https://pypi.org/project/pypng/) >= 0.20220715.0
|
|
146
146
|
- [PyJWT[crypto]](https://pypi.org/project/PyJWT/) >= 2.13
|
|
147
|
-
- [cryptography](https://pypi.org/project/cryptography/) >=
|
|
147
|
+
- [cryptography](https://pypi.org/project/cryptography/) >= 45
|
|
148
148
|
- [defusedxml](https://pypi.org/project/defusedxml/) >= 0.7
|
|
149
149
|
|
|
150
150
|
## Installation
|
|
@@ -157,8 +157,10 @@ All dependencies are installed automatically. The two OB 3.0 side-tracks ship
|
|
|
157
157
|
as optional extras (the base install stays lean):
|
|
158
158
|
|
|
159
159
|
```bash
|
|
160
|
-
pip install "openbadgeslib[ldp]"
|
|
161
|
-
pip install "openbadgeslib[
|
|
160
|
+
pip install "openbadgeslib[ldp]" # W3C Data Integrity issuance/verification
|
|
161
|
+
pip install "openbadgeslib[ldp-sd]" # verify ecdsa-sd-2023 (selective disclosure)
|
|
162
|
+
pip install "openbadgeslib[eudi]" # EUDI SD-JWT VC (pulls openvc-core)
|
|
163
|
+
pip install "openbadgeslib[oid4vci]" # issue to wallets (pre-authorized code flow)
|
|
162
164
|
```
|
|
163
165
|
|
|
164
166
|
For a development checkout with the test suite and linters:
|
|
@@ -89,7 +89,7 @@ all three OB versions.
|
|
|
89
89
|
- Python >= 3.10 (tested on 3.10–3.14)
|
|
90
90
|
- [pypng](https://pypi.org/project/pypng/) >= 0.20220715.0
|
|
91
91
|
- [PyJWT[crypto]](https://pypi.org/project/PyJWT/) >= 2.13
|
|
92
|
-
- [cryptography](https://pypi.org/project/cryptography/) >=
|
|
92
|
+
- [cryptography](https://pypi.org/project/cryptography/) >= 45
|
|
93
93
|
- [defusedxml](https://pypi.org/project/defusedxml/) >= 0.7
|
|
94
94
|
|
|
95
95
|
## Installation
|
|
@@ -102,8 +102,10 @@ All dependencies are installed automatically. The two OB 3.0 side-tracks ship
|
|
|
102
102
|
as optional extras (the base install stays lean):
|
|
103
103
|
|
|
104
104
|
```bash
|
|
105
|
-
pip install "openbadgeslib[ldp]"
|
|
106
|
-
pip install "openbadgeslib[
|
|
105
|
+
pip install "openbadgeslib[ldp]" # W3C Data Integrity issuance/verification
|
|
106
|
+
pip install "openbadgeslib[ldp-sd]" # verify ecdsa-sd-2023 (selective disclosure)
|
|
107
|
+
pip install "openbadgeslib[eudi]" # EUDI SD-JWT VC (pulls openvc-core)
|
|
108
|
+
pip install "openbadgeslib[oid4vci]" # issue to wallets (pre-authorized code flow)
|
|
107
109
|
```
|
|
108
110
|
|
|
109
111
|
For a development checkout with the test suite and linters:
|
|
@@ -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:
|
|
@@ -143,6 +143,13 @@ key_type = RSA
|
|
|
143
143
|
; above; vc+sd-jwt is the EUDI track and is IRREVOCABLE, so it cannot be
|
|
144
144
|
; combined with status_lists. vc+sd-jwt also needs key_type = ED25519 or ECC.
|
|
145
145
|
;oid4vci_formats = jwt_vc_json
|
|
146
|
+
; Demand a key attestation from the wallet (OID4VCI 1.0 §12.2.4). Unset is
|
|
147
|
+
; the spec default: the parameter MUST NOT appear unless you actually
|
|
148
|
+
; require one, or software wallets are locked out. true / {} = required
|
|
149
|
+
; with no extra constraints; a JSON object is the Appendix D.2 set.
|
|
150
|
+
; Advertising this also refuses a proof without key_attestation before
|
|
151
|
+
; the nonce is spent — do not publish it and then accept inline-jwk proofs.
|
|
152
|
+
;oid4vci_key_attestations_required = true
|
|
146
153
|
;mail = ${paths:base}/badge_1_mail.txt
|
|
147
154
|
|
|
148
155
|
[badge_2]
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
|
|
24
24
|
from configparser import ConfigParser, ExtendedInterpolation, Error as ConfigParserError
|
|
25
25
|
from dataclasses import dataclass
|
|
26
|
-
from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
|
|
26
|
+
from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING
|
|
27
|
+
import json
|
|
27
28
|
import os
|
|
28
29
|
import sys
|
|
29
30
|
import logging
|
|
@@ -304,7 +305,15 @@ def oid4vci_config(conf: ConfigParser) -> OID4VCIConfig:
|
|
|
304
305
|
|
|
305
306
|
def endpoint(key: str, default_path: str) -> str:
|
|
306
307
|
explicit = (section.get(key) or '').strip() if section else ''
|
|
307
|
-
|
|
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
|
|
308
317
|
|
|
309
318
|
store_path = (section.get('store_path') if section else None) or \
|
|
310
319
|
os.path.join(conf['paths']['base'], 'oid4vci.sqlite3')
|
|
@@ -380,6 +389,52 @@ def oid4vci_formats(conf: ConfigParser, badge_section: str) -> Tuple[str, ...]:
|
|
|
380
389
|
return tuple(formats)
|
|
381
390
|
|
|
382
391
|
|
|
392
|
+
def oid4vci_key_attestations_required(
|
|
393
|
+
conf: ConfigParser, badge_section: str) -> Optional[Dict[str, Any]]:
|
|
394
|
+
"""The key-attestation policy a badge advertises over OID4VCI, or None.
|
|
395
|
+
|
|
396
|
+
Absent (the default) means the issuer does **not** require a key
|
|
397
|
+
attestation, and OpenID4VCI 1.0 §12.2.4 says the parameter MUST NOT then
|
|
398
|
+
appear in metadata. Present means it is required:
|
|
399
|
+
|
|
400
|
+
* ``true`` / ``yes`` / ``1`` / ``{}`` / empty advertise an unconstrained
|
|
401
|
+
requirement (a key attestation is needed, with no extra constraints);
|
|
402
|
+
* a JSON object is the Appendix D.2 constraint set, validated by the same
|
|
403
|
+
shape check :func:`~openbadgeslib.ob3.eudi.badge_credential_configuration`
|
|
404
|
+
uses.
|
|
405
|
+
|
|
406
|
+
The credential endpoint reads this same key, so advertising the
|
|
407
|
+
requirement without enforcing it — the wallet-retry loop openvc-core
|
|
408
|
+
1.26's ``require_key_attestation`` exists to close — cannot happen from
|
|
409
|
+
configuration alone.
|
|
410
|
+
"""
|
|
411
|
+
if (badge_section not in conf
|
|
412
|
+
or 'oid4vci_key_attestations_required' not in conf[badge_section]):
|
|
413
|
+
return None
|
|
414
|
+
raw = (conf[badge_section].get('oid4vci_key_attestations_required')
|
|
415
|
+
or '').strip()
|
|
416
|
+
if raw.lower() in ('', 'true', 'yes', '1', '{}'):
|
|
417
|
+
value: Any = {}
|
|
418
|
+
else:
|
|
419
|
+
try:
|
|
420
|
+
parsed = json.loads(raw)
|
|
421
|
+
except ValueError as exc:
|
|
422
|
+
raise ConfigError(
|
|
423
|
+
"[%s] oid4vci_key_attestations_required must be true or a "
|
|
424
|
+
"JSON object, got %r" % (badge_section, raw)) from exc
|
|
425
|
+
if not isinstance(parsed, dict):
|
|
426
|
+
raise ConfigError(
|
|
427
|
+
"[%s] oid4vci_key_attestations_required must be a JSON "
|
|
428
|
+
"object, got %s" % (badge_section, type(parsed).__name__))
|
|
429
|
+
value = parsed
|
|
430
|
+
from .ob3.eudi import EudiError, _key_attestations_required
|
|
431
|
+
try:
|
|
432
|
+
return _key_attestations_required(value)
|
|
433
|
+
except EudiError as exc:
|
|
434
|
+
raise ConfigError("[%s] oid4vci_key_attestations_required: %s"
|
|
435
|
+
% (badge_section, exc)) from exc
|
|
436
|
+
|
|
437
|
+
|
|
383
438
|
# ── centralized config defaults ──────────────────────────────────────────────
|
|
384
439
|
# One inventory for the accepted-key defaults, so each lives in a single place
|
|
385
440
|
# rather than being re-spelled at every read site. (The status-list defaults —
|
|
@@ -116,6 +116,12 @@ def _safe_filename_component(value: str, field_name: str) -> str:
|
|
|
116
116
|
raise ValueError('%s is not safe for use in an output filename' % field_name)
|
|
117
117
|
if os.path.basename(value) != value or ntpath.basename(value) != value:
|
|
118
118
|
raise ValueError('%s must not contain path separators' % field_name)
|
|
119
|
+
# CR/LF/quotes/semicolons would inject MIME headers when this name is
|
|
120
|
+
# used as Content-Disposition filename (#316).
|
|
121
|
+
if any(ch in value for ch in '\r\n"\';'):
|
|
122
|
+
raise ValueError(
|
|
123
|
+
'%s contains characters that are not safe in a filename'
|
|
124
|
+
% field_name)
|
|
119
125
|
return value
|
|
120
126
|
|
|
121
127
|
|
|
@@ -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,
|
|
@@ -67,10 +79,12 @@ class BadgeMail():
|
|
|
67
79
|
raise BadgeImgFormatUnsupported(
|
|
68
80
|
'Unsupported image type: %r' % (badge.source.image_type,))
|
|
69
81
|
|
|
70
|
-
image = MIMEImage(badge.source.image,
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
82
|
+
image = MIMEImage(badge.source.image, _subtype=mime_type)
|
|
83
|
+
# Keyword form so the filename is RFC 2231 encoded, never interpolated
|
|
84
|
+
# into the header value as raw bytes (#316).
|
|
85
|
+
image.add_header('Content-Disposition', 'attachment',
|
|
86
|
+
filename=basename(badge.file_out))
|
|
87
|
+
image.add_header('Content-Description', 'Signed OpenBadge')
|
|
74
88
|
msg.attach(image)
|
|
75
89
|
|
|
76
90
|
try:
|
|
@@ -81,9 +95,17 @@ class BadgeMail():
|
|
|
81
95
|
# CERT_NONE), which would let an on-path attacker intercept the
|
|
82
96
|
# connection and capture the AUTH credentials sent below.
|
|
83
97
|
smtp = SMTP_SSL(self.smtp_server, self.smtp_port,
|
|
98
|
+
timeout=SMTP_TIMEOUT_SECONDS,
|
|
84
99
|
context=ssl.create_default_context())
|
|
85
100
|
else:
|
|
86
|
-
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())
|
|
87
109
|
|
|
88
110
|
if self.username:
|
|
89
111
|
smtp.login(self.username, self.password)
|
|
@@ -182,7 +182,10 @@ class Verifier():
|
|
|
182
182
|
|
|
183
183
|
revocation_json = download_file(revocation_url)
|
|
184
184
|
if not revocation_json:
|
|
185
|
-
|
|
185
|
+
# The issuer published a revocationList URL; an empty body is not
|
|
186
|
+
# "no revocations" — that is a missing key. Fail closed (#317).
|
|
187
|
+
raise AssertionFormatIncorrect(
|
|
188
|
+
"Revocation list at %s is empty" % revocation_url)
|
|
186
189
|
try:
|
|
187
190
|
revocation = jws_utils.from_json(revocation_json)
|
|
188
191
|
except Exception:
|
|
@@ -274,7 +274,8 @@ class OB2Verifier:
|
|
|
274
274
|
starts_with = _as_str_list(verification.get("startsWith"))
|
|
275
275
|
allowed_origins = _as_str_list(verification.get("allowedOrigins"))
|
|
276
276
|
if starts_with:
|
|
277
|
-
if not any(assertion_id
|
|
277
|
+
if not any(_url_starts_with(assertion_id, prefix)
|
|
278
|
+
for prefix in starts_with):
|
|
278
279
|
raise OB2VerificationError(
|
|
279
280
|
"Hosted assertion id %s is outside the issuer's declared "
|
|
280
281
|
"startsWith scope" % assertion_id)
|
|
@@ -342,16 +343,33 @@ class OB2Verifier:
|
|
|
342
343
|
# ── network / decode helpers ─────────────────────────────────────────────────
|
|
343
344
|
|
|
344
345
|
def _fetch_issuer(self, assertion: Assertion) -> dict[str, Any]:
|
|
345
|
-
"""Resolve the issuer Profile via the assertion's BadgeClass ``badge`` URL.
|
|
346
|
+
"""Resolve the issuer Profile via the assertion's BadgeClass ``badge`` URL.
|
|
347
|
+
|
|
348
|
+
An embedded Profile object is not trusted: its ``id`` is fetched and
|
|
349
|
+
that retrieved document is the trust anchor (scope, revocation). A
|
|
350
|
+
holder-supplied BadgeClass could otherwise name a real issuer ``id``
|
|
351
|
+
while writing its own ``startsWith`` / ``revocationList`` (#315).
|
|
352
|
+
"""
|
|
346
353
|
badge = self._fetch_json(assertion.badge, "BadgeClass")
|
|
347
|
-
|
|
348
|
-
if isinstance(
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
354
|
+
issuer = badge.get("issuer")
|
|
355
|
+
if isinstance(issuer, dict):
|
|
356
|
+
issuer_url = issuer.get("id")
|
|
357
|
+
if not isinstance(issuer_url, str) or not issuer_url:
|
|
358
|
+
raise OB2VerificationError(
|
|
359
|
+
"BadgeClass at %s has an embedded issuer with no id"
|
|
360
|
+
% assertion.badge)
|
|
361
|
+
elif isinstance(issuer, str) and issuer:
|
|
362
|
+
issuer_url = issuer
|
|
363
|
+
else:
|
|
352
364
|
raise OB2VerificationError(
|
|
353
365
|
"BadgeClass at %s has no valid 'issuer'" % assertion.badge)
|
|
354
|
-
|
|
366
|
+
fetched = self._fetch_json(issuer_url, "issuer Profile")
|
|
367
|
+
fetched_id = fetched.get("id")
|
|
368
|
+
if isinstance(fetched_id, str) and fetched_id != issuer_url:
|
|
369
|
+
raise OB2VerificationError(
|
|
370
|
+
"Issuer Profile at %s has id %r, expected %r"
|
|
371
|
+
% (issuer_url, fetched_id, issuer_url))
|
|
372
|
+
return fetched
|
|
355
373
|
|
|
356
374
|
def _fetch_json(self, url: str, where: str) -> dict[str, Any]:
|
|
357
375
|
try:
|
|
@@ -445,3 +463,24 @@ def _same_origin(a: str, b: str) -> bool:
|
|
|
445
463
|
"""Return True if two URLs share scheme + host + port (their origin)."""
|
|
446
464
|
pa, pb = urlparse(a), urlparse(b)
|
|
447
465
|
return (pa.scheme, pa.hostname, pa.port) == (pb.scheme, pb.hostname, pb.port)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _url_starts_with(url: str, prefix: str) -> bool:
|
|
469
|
+
"""True if *url* is in the hosted scope declared by *prefix*.
|
|
470
|
+
|
|
471
|
+
A raw ``str.startswith`` treats ``https://issuer.example`` as a prefix of
|
|
472
|
+
``https://issuer.example.attacker.tld/…`` (and similar userinfo tricks).
|
|
473
|
+
Require the same origin, then a path that equals the prefix path or is
|
|
474
|
+
a strict path-segment descendant (#314).
|
|
475
|
+
"""
|
|
476
|
+
if not prefix:
|
|
477
|
+
return False
|
|
478
|
+
if not _same_origin(url, prefix):
|
|
479
|
+
return False
|
|
480
|
+
u, p = urlparse(url), urlparse(prefix)
|
|
481
|
+
upath, ppath = u.path or '/', p.path or '/'
|
|
482
|
+
if upath == ppath:
|
|
483
|
+
return True
|
|
484
|
+
if not ppath.endswith('/'):
|
|
485
|
+
ppath += '/'
|
|
486
|
+
return upath.startswith(ppath)
|
|
@@ -453,6 +453,29 @@ def _reject_unsigned_ldp_aliases(doc: dict[str, Any]) -> None:
|
|
|
453
453
|
"not sign; use BitstringStatusListEntry")
|
|
454
454
|
|
|
455
455
|
|
|
456
|
+
def _reject_hidden_sd_lifecycle(doc: dict[str, Any], proof: dict[str, Any]) -> None:
|
|
457
|
+
"""Fail closed when an ecdsa-sd-2023 presentation omits lifecycle claims.
|
|
458
|
+
|
|
459
|
+
Selective disclosure lets a holder drop statements the issuer signed.
|
|
460
|
+
``validUntil`` and ``credentialStatus`` are the ones that bound a badge's
|
|
461
|
+
life: hiding them makes an expired or revoked credential look perpetual.
|
|
462
|
+
eddsa-rdfc-2022 signs the whole document, so this only applies to
|
|
463
|
+
ecdsa-sd-2023. The low-level ``verify_data_integrity_proof`` does not
|
|
464
|
+
enforce it (it checks the proof alone); ``OB3LdpVerifier.verify`` does
|
|
465
|
+
(#328).
|
|
466
|
+
"""
|
|
467
|
+
if proof.get('cryptosuite') != 'ecdsa-sd-2023':
|
|
468
|
+
return
|
|
469
|
+
if 'validUntil' not in doc:
|
|
470
|
+
raise OB3VerificationError(
|
|
471
|
+
"ecdsa-sd-2023 presentation omitted validUntil, which a holder "
|
|
472
|
+
"could hide to un-expire the badge; disclose validUntil")
|
|
473
|
+
if 'credentialStatus' not in doc:
|
|
474
|
+
raise OB3VerificationError(
|
|
475
|
+
"ecdsa-sd-2023 presentation omitted credentialStatus, which a "
|
|
476
|
+
"holder could hide to un-revoke the badge; disclose credentialStatus")
|
|
477
|
+
|
|
478
|
+
|
|
456
479
|
class OB3LdpVerifier:
|
|
457
480
|
"""Verifies OpenBadges 3.0 credentials secured with a Data Integrity
|
|
458
481
|
(Linked Data Proof) embedded proof — cryptosuites eddsa-rdfc-2022 and,
|
|
@@ -511,7 +534,9 @@ class OB3LdpVerifier:
|
|
|
511
534
|
own signature is verified too (reusing a pinned key, or resolving the
|
|
512
535
|
issuer DID), unless ``verify_status_list=False``. ``download`` resolves
|
|
513
536
|
a did:web verificationMethod and status list issuer DID (injectable for
|
|
514
|
-
testing).
|
|
537
|
+
testing). An ``ecdsa-sd-2023`` presentation that omits ``validUntil``
|
|
538
|
+
or ``credentialStatus`` is rejected so a holder cannot hide expiry or
|
|
539
|
+
revocation (#328).
|
|
515
540
|
"""
|
|
516
541
|
doc = self._parse_document(document)
|
|
517
542
|
|
|
@@ -528,6 +553,9 @@ class OB3LdpVerifier:
|
|
|
528
553
|
|
|
529
554
|
proof = _select_proof(doc)
|
|
530
555
|
_validate_proof(proof, 'assertionMethod')
|
|
556
|
+
# Before resolving keys or running crypto: an SD presentation that hid
|
|
557
|
+
# expiry/revocation is already a fail, regardless of the proof.
|
|
558
|
+
_reject_hidden_sd_lifecycle(doc, proof)
|
|
531
559
|
|
|
532
560
|
vm = proof['verificationMethod']
|
|
533
561
|
vm_did = vm.partition('#')[0]
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
|
|
40
40
|
import configparser
|
|
41
41
|
import os
|
|
42
|
+
import posixpath
|
|
42
43
|
import shutil
|
|
43
44
|
import tempfile
|
|
44
45
|
|
|
@@ -174,14 +175,28 @@ def _write_atomic(path: str, data: str) -> None:
|
|
|
174
175
|
def _sd_jwt_vct_relpath(vct: str, publish_url: str) -> Optional[str]:
|
|
175
176
|
"""The output-relative path to write Type Metadata for *vct* so serving the
|
|
176
177
|
output dir at *publish_url* resolves it there — or None if *vct* is not
|
|
177
|
-
hosted under *publish_url* (same scheme+host, path under it).
|
|
178
|
+
hosted under *publish_url* (same scheme+host, path under it).
|
|
179
|
+
|
|
180
|
+
The remainder is POSIX-normalized. A ``..`` that would leave the publish
|
|
181
|
+
prefix is refused (PublishError) rather than joined onto ``-o`` — a vct
|
|
182
|
+
of ``https://host/out/../../../tmp/x`` used to write outside the output
|
|
183
|
+
directory (#318).
|
|
184
|
+
"""
|
|
178
185
|
v, p = urlparse(vct), urlparse(publish_url)
|
|
179
186
|
if (v.scheme, v.netloc) != (p.scheme, p.netloc):
|
|
180
187
|
return None
|
|
181
188
|
base = p.path if p.path.endswith('/') else p.path + '/'
|
|
182
189
|
if not v.path.startswith(base):
|
|
183
190
|
return None
|
|
184
|
-
|
|
191
|
+
rel = v.path[len(base):]
|
|
192
|
+
if not rel:
|
|
193
|
+
return None
|
|
194
|
+
normalized = posixpath.normpath(rel)
|
|
195
|
+
if posixpath.isabs(normalized) or normalized == '..' \
|
|
196
|
+
or normalized.startswith('../'):
|
|
197
|
+
raise PublishError(
|
|
198
|
+
"[issuer] sd_jwt_vct path escapes the publish_url prefix")
|
|
199
|
+
return normalized
|
|
185
200
|
|
|
186
201
|
|
|
187
202
|
def _publish_type_metadata(vct: str, publish_url: str, output: str,
|
|
@@ -198,6 +213,12 @@ def _publish_type_metadata(vct: str, publish_url: str, output: str,
|
|
|
198
213
|
document = badge_type_metadata(vct)
|
|
199
214
|
served = type_metadata_document_bytes(document)
|
|
200
215
|
path = os.path.join(output, rel.replace('/', os.sep))
|
|
216
|
+
root = os.path.abspath(output)
|
|
217
|
+
dest = os.path.abspath(path)
|
|
218
|
+
if os.path.commonpath([root, dest]) != root:
|
|
219
|
+
raise PublishError(
|
|
220
|
+
"[issuer] sd_jwt_vct would write Type Metadata outside %s"
|
|
221
|
+
% output)
|
|
201
222
|
parent = os.path.dirname(path)
|
|
202
223
|
if parent:
|
|
203
224
|
os.makedirs(parent, exist_ok=True)
|
|
@@ -76,7 +76,10 @@ INVALID_NONCE = 'invalid_nonce'
|
|
|
76
76
|
INVALID_ENCRYPTION_PARAMETERS = 'invalid_encryption_parameters'
|
|
77
77
|
#: The pre-authorized code was unknown, expired, already redeemed or killed.
|
|
78
78
|
INVALID_GRANT = 'invalid_grant'
|
|
79
|
-
#: The token request itself was malformed (
|
|
79
|
+
#: The token request itself was malformed (bad grant type, empty code).
|
|
80
|
+
#: A live PIN-gated offer missing tx_code is invalid_grant, not this — the
|
|
81
|
+
#: distinct invalid_request was an oracle that the offer exists and is
|
|
82
|
+
#: PIN-gated (#328).
|
|
80
83
|
INVALID_REQUEST = 'invalid_request'
|
|
81
84
|
#: The access token was missing, unknown or expired. Served as HTTP 401 with a
|
|
82
85
|
#: ``WWW-Authenticate: Bearer`` header, per RFC 6750 — not 400.
|