openbadgeslib 4.4.2__tar.gz → 4.4.3__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.3}/CONTRIBUTING.md +3 -2
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/Changelog.txt +44 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/PKG-INFO +9 -7
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/README.md +5 -3
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/config.ini.example +7 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/confparser.py +48 -1
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/issue.py +6 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/mail.py +6 -4
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob1/verifier.py +4 -1
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob2/verifier.py +47 -8
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/publish.py +23 -2
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/oid4vci/handler.py +29 -7
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/oid4vci/memory_store.py +12 -2
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/oid4vci/metadata.py +11 -4
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/oid4vci/offer.py +41 -15
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/oid4vci/reconcile.py +26 -10
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/oid4vci/sqlite_store.py +31 -4
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/oid4vci/store.py +6 -1
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/openbadges_publish.py +4 -3
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/util.py +1 -1
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib.egg-info/PKG-INFO +9 -7
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib.egg-info/requires.txt +3 -3
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/pyproject.toml +7 -4
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_docs.py +12 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_mail.py +4 -1
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob1_signer.py +3 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob1_verifier.py +24 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob2_error_branches.py +6 -3
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob2_verifier.py +54 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_publish_cli.py +13 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_oid4vci_config.py +45 -1
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_oid4vci_protocol.py +109 -4
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_oid4vci_reservations.py +104 -5
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_oid4vci_store.py +48 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/GOVERNANCE.md +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/LICENSE.txt +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/MANIFEST.in +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/SECURITY.md +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/docs/README.md +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/__init__.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/_jsonmodel.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/_jws/__init__.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/_jws/exceptions.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/_jws/utils.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/_ob1_api.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/badge_model.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/baking.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/cli_common.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/errors.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/keys.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/logs.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob1/__init__.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob1/badge.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob1/signer.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob2/__init__.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob2/models.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob2/signer.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/__init__.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/contexts/__init__.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/contexts/ob-v3p0-extensions.json +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/credential.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/did.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/eudi.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/ldp.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/signer.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/status.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/status_list.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/status_registry.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/ob3/verifier.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/oid4vci/__init__.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/oid4vci/codes.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/oid4vci/errors.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/oid4vci/formats.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/oid4vci/nonce.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/openbadges_cli.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/openbadges_init.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/openbadges_keygenerator.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/openbadges_signer.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/openbadges_status.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/openbadges_verifier.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/py.typed +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib/verify.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib.egg-info/SOURCES.txt +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib.egg-info/dependency_links.txt +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib.egg-info/entry_points.txt +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/openbadgeslib.egg-info/top_level.txt +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/setup.cfg +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/config1.ini +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/conformance/Dockerfile.ob3 +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/conformance/README.md +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/conformance/conftest.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/conformance/docker-compose.yml +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/conformance/test_official_validators.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/conftest.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/fixtures/ob_schemas/README.md +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/fixtures/ob_schemas/refresh.sh +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/fixtures/vc_di_eddsa/README.md +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/images/sample1.png +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/images/sample1.svg +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/images/userimage01.svg +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/ldp_helpers.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/logo Python Espan/314/203a.svg" +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/logo Python Espa/303/261a.svg" +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_baking.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_cli_json.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_cli_smoke.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_confparser.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_eddsa.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_errors.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_examples.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_issue.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_issue_credential.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_jsonmodel.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_jws.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_key_operation.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_keys.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob1_badge_io.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob1_deprecation.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob2_cli.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob2_models.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob2_signer.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_conformance_schema.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_contexts.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_credential.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_did.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_did_doc.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_endorsement.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_eudi_oid4vci.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_eudi_sd_jwt.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_eudi_type_metadata.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_eudi_x5c.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_ldp.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_ldp_cli.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_ldp_ecdsa_sd.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_ldp_errors.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_ldp_sign.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_signer.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_signer_cli_ldp.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_status.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_status_list.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_status_registry.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_ob3_verifier.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_openbadges_cli.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_public_api.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_sign_ecc.pem +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_sign_rsa.pem +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_signer_batch.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_util.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_verifier_cli_coverage.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_verify.py +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_verify_ecc.pem +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/test_verify_rsa.pem +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/tests/withoutxmlheader.svg +0 -0
- {openbadgeslib-4.4.2 → openbadgeslib-4.4.3}/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,50 @@ OpenBadgesLib - Changelog
|
|
|
4
4
|
Newest first. Dates are ISO 8601 (YYYY-MM-DD).
|
|
5
5
|
|
|
6
6
|
|
|
7
|
+
* v4.4.3 - 2026-08-18
|
|
8
|
+
|
|
9
|
+
- security(oid4vci): an issuer that advertises
|
|
10
|
+
``key_attestations_required`` now refuses a proof without
|
|
11
|
+
``key_attestation`` before the signature is checked and before the
|
|
12
|
+
``c_nonce`` is spent (openvc-core 1.26's ``require_key_attestation``).
|
|
13
|
+
Set ``oid4vci_key_attestations_required`` on the badge section, or pass
|
|
14
|
+
``require_key_attestation=True`` to ``handle_credential_request``.
|
|
15
|
+
Without this, a software wallet's §8.3.1 retry spent a fresh nonce on
|
|
16
|
+
every attempt and looped.
|
|
17
|
+
|
|
18
|
+
- build(deps): raise the openvc-core floors to `>=1.26` in the `[eudi]`,
|
|
19
|
+
`[ldp-sd]` and `[oid4vci]` extras.
|
|
20
|
+
|
|
21
|
+
- security(oid4vci): `--reclaim-unclaimed` no longer frees a status-list
|
|
22
|
+
index whose credential was already delivered to a wallet. Issued grants
|
|
23
|
+
are kept past the offer TTL (they are the only proof of delivery), and
|
|
24
|
+
a missing grant row is treated as undecided rather than "never claimed".
|
|
25
|
+
- security(oid4vci): presenting a pre-authorized code again after the
|
|
26
|
+
wallet has claimed no longer demotes the grant from issued to
|
|
27
|
+
invalidated (tokens are still revoked). That demotion made the next
|
|
28
|
+
`--reclaim-unclaimed` free the delivered credential's status index.
|
|
29
|
+
- security(ob2): a HostedBadge BadgeClass that embeds an issuer Profile
|
|
30
|
+
object is no longer trusted as-is. The Profile at ``issuer.id`` is
|
|
31
|
+
fetched and used for scope and revocation.
|
|
32
|
+
- security(publish): ``sd_jwt_vct`` paths are POSIX-normalized; a ``..``
|
|
33
|
+
that would write Type Metadata outside ``-o`` is refused.
|
|
34
|
+
- security(ob1): an empty revocation-list response fails closed instead
|
|
35
|
+
of treating the badge as not revoked.
|
|
36
|
+
- security(mail): attachment filenames are RFC 2231 encoded, and CR/LF
|
|
37
|
+
or quotes in a badge/recipient name are rejected before they can
|
|
38
|
+
inject MIME headers.
|
|
39
|
+
- security(ob2): HostedBadge ``startsWith`` is an origin + path-segment
|
|
40
|
+
prefix, not a raw string prefix (blocks subdomain / userinfo tricks).
|
|
41
|
+
- fix(oid4vci): a Credential Offer that fails validation (e.g. batch_size
|
|
42
|
+
> 1 with status_lists) no longer reserves a status-list index first.
|
|
43
|
+
- fix(ci): the Ubuntu test job installs ``[oid4vci]`` and sets
|
|
44
|
+
``OPENBADGES_REQUIRE_OID4VCI=1`` so the credential-endpoint suite cannot
|
|
45
|
+
skip itself green.
|
|
46
|
+
- fix(oid4vci): minting an access token stretches the grant to cover the
|
|
47
|
+
advertised ``expires_in``, so redeeming late in the offer window no
|
|
48
|
+
longer clips the token's lifetime.
|
|
49
|
+
|
|
50
|
+
|
|
7
51
|
* v4.4.2 - 2026-08-13
|
|
8
52
|
|
|
9
53
|
- 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.3
|
|
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:
|
|
@@ -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
|
|
@@ -380,6 +381,52 @@ def oid4vci_formats(conf: ConfigParser, badge_section: str) -> Tuple[str, ...]:
|
|
|
380
381
|
return tuple(formats)
|
|
381
382
|
|
|
382
383
|
|
|
384
|
+
def oid4vci_key_attestations_required(
|
|
385
|
+
conf: ConfigParser, badge_section: str) -> Optional[Dict[str, Any]]:
|
|
386
|
+
"""The key-attestation policy a badge advertises over OID4VCI, or None.
|
|
387
|
+
|
|
388
|
+
Absent (the default) means the issuer does **not** require a key
|
|
389
|
+
attestation, and OpenID4VCI 1.0 §12.2.4 says the parameter MUST NOT then
|
|
390
|
+
appear in metadata. Present means it is required:
|
|
391
|
+
|
|
392
|
+
* ``true`` / ``yes`` / ``1`` / ``{}`` / empty advertise an unconstrained
|
|
393
|
+
requirement (a key attestation is needed, with no extra constraints);
|
|
394
|
+
* a JSON object is the Appendix D.2 constraint set, validated by the same
|
|
395
|
+
shape check :func:`~openbadgeslib.ob3.eudi.badge_credential_configuration`
|
|
396
|
+
uses.
|
|
397
|
+
|
|
398
|
+
The credential endpoint reads this same key, so advertising the
|
|
399
|
+
requirement without enforcing it — the wallet-retry loop openvc-core
|
|
400
|
+
1.26's ``require_key_attestation`` exists to close — cannot happen from
|
|
401
|
+
configuration alone.
|
|
402
|
+
"""
|
|
403
|
+
if (badge_section not in conf
|
|
404
|
+
or 'oid4vci_key_attestations_required' not in conf[badge_section]):
|
|
405
|
+
return None
|
|
406
|
+
raw = (conf[badge_section].get('oid4vci_key_attestations_required')
|
|
407
|
+
or '').strip()
|
|
408
|
+
if raw.lower() in ('', 'true', 'yes', '1', '{}'):
|
|
409
|
+
value: Any = {}
|
|
410
|
+
else:
|
|
411
|
+
try:
|
|
412
|
+
parsed = json.loads(raw)
|
|
413
|
+
except ValueError as exc:
|
|
414
|
+
raise ConfigError(
|
|
415
|
+
"[%s] oid4vci_key_attestations_required must be true or a "
|
|
416
|
+
"JSON object, got %r" % (badge_section, raw)) from exc
|
|
417
|
+
if not isinstance(parsed, dict):
|
|
418
|
+
raise ConfigError(
|
|
419
|
+
"[%s] oid4vci_key_attestations_required must be a JSON "
|
|
420
|
+
"object, got %s" % (badge_section, type(parsed).__name__))
|
|
421
|
+
value = parsed
|
|
422
|
+
from .ob3.eudi import EudiError, _key_attestations_required
|
|
423
|
+
try:
|
|
424
|
+
return _key_attestations_required(value)
|
|
425
|
+
except EudiError as exc:
|
|
426
|
+
raise ConfigError("[%s] oid4vci_key_attestations_required: %s"
|
|
427
|
+
% (badge_section, exc)) from exc
|
|
428
|
+
|
|
429
|
+
|
|
383
430
|
# ── centralized config defaults ──────────────────────────────────────────────
|
|
384
431
|
# One inventory for the accepted-key defaults, so each lives in a single place
|
|
385
432
|
# 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
|
|
|
@@ -67,10 +67,12 @@ class BadgeMail():
|
|
|
67
67
|
raise BadgeImgFormatUnsupported(
|
|
68
68
|
'Unsupported image type: %r' % (badge.source.image_type,))
|
|
69
69
|
|
|
70
|
-
image = MIMEImage(badge.source.image,
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
image = MIMEImage(badge.source.image, _subtype=mime_type)
|
|
71
|
+
# Keyword form so the filename is RFC 2231 encoded, never interpolated
|
|
72
|
+
# into the header value as raw bytes (#316).
|
|
73
|
+
image.add_header('Content-Disposition', 'attachment',
|
|
74
|
+
filename=basename(badge.file_out))
|
|
75
|
+
image.add_header('Content-Description', 'Signed OpenBadge')
|
|
74
76
|
msg.attach(image)
|
|
75
77
|
|
|
76
78
|
try:
|
|
@@ -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)
|
|
@@ -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)
|
|
@@ -43,7 +43,7 @@ from dataclasses import dataclass, field
|
|
|
43
43
|
from datetime import datetime, timedelta, timezone
|
|
44
44
|
from typing import Any, Dict, List, Mapping, Optional, Union
|
|
45
45
|
|
|
46
|
-
from ..confparser import oid4vci_config
|
|
46
|
+
from ..confparser import oid4vci_config, oid4vci_key_attestations_required
|
|
47
47
|
from ..issue import CredentialResult, issue_credential_from_conf
|
|
48
48
|
from .codes import (dummy_tx_verification, new_secret, secret_id,
|
|
49
49
|
verify_tx_code)
|
|
@@ -70,11 +70,13 @@ MAX_REQUEST_BYTES = 128 * 1024
|
|
|
70
70
|
def _require_openvc() -> Any:
|
|
71
71
|
"""Import the openvc-core OID4VCI pieces, or raise with an actionable hint.
|
|
72
72
|
|
|
73
|
-
The openvc-core floor is 1.
|
|
74
|
-
``resolve_proof_key_in_context`` (1.24)
|
|
75
|
-
are guaranteed present when the
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
The openvc-core floor is 1.26 across every extra that pulls it, so
|
|
74
|
+
``resolve_proof_key_in_context`` (1.24), the discovery parsers (1.25)
|
|
75
|
+
and ``require_key_attestation`` (1.26) are guaranteed present when the
|
|
76
|
+
import succeeds; a resolver that somehow resolves older anyway fails
|
|
77
|
+
loudly at the unexpected-keyword call rather than silently dropping
|
|
78
|
+
the attested-key form or spending a nonce on a proof that lacked the
|
|
79
|
+
advertised attestation.
|
|
78
80
|
"""
|
|
79
81
|
try:
|
|
80
82
|
from openvc.openid4vci import (parse_credential_request,
|
|
@@ -215,7 +217,8 @@ def handle_credential_request(conf: configparser.ConfigParser,
|
|
|
215
217
|
access_token: Optional[str], store: Any,
|
|
216
218
|
nonces: NonceIssuer,
|
|
217
219
|
now: Optional[datetime] = None,
|
|
218
|
-
resolve_proof_key_in_context: Any = None
|
|
220
|
+
resolve_proof_key_in_context: Any = None,
|
|
221
|
+
require_key_attestation: bool = False
|
|
219
222
|
) -> CredentialResponse:
|
|
220
223
|
"""Verify a Credential Request and issue what its grant authorises.
|
|
221
224
|
|
|
@@ -237,6 +240,19 @@ def handle_credential_request(conf: configparser.ConfigParser,
|
|
|
237
240
|
decision, not this library's default. When None (the default), only
|
|
238
241
|
proofs that carry their key inline are accepted, exactly as before.
|
|
239
242
|
|
|
243
|
+
*require_key_attestation* refuses a proof whose header has no
|
|
244
|
+
``key_attestation`` **before** the signature is checked and **before**
|
|
245
|
+
the ``c_nonce`` is spent (openvc-core ≥1.26). Default False, matching
|
|
246
|
+
the normative metadata default: OpenID4VCI 1.0 §12.2.4 says
|
|
247
|
+
``key_attestations_required`` MUST NOT appear unless the issuer
|
|
248
|
+
actually requires one. The flag is also forced on when the grant's
|
|
249
|
+
badge advertises that parameter (via
|
|
250
|
+
``oid4vci_key_attestations_required`` in the config, or an explicit
|
|
251
|
+
True here for a caller that published the requirement through
|
|
252
|
+
:func:`~openbadgeslib.ob3.eudi.badge_credential_configuration`).
|
|
253
|
+
Advertising it without this check spends the nonce on a proof the
|
|
254
|
+
wallet will retry forever.
|
|
255
|
+
|
|
240
256
|
Raises :class:`OID4VCIError` for anything the wallet did wrong, and lets
|
|
241
257
|
:class:`~openbadgeslib.errors.IssuanceError` through untouched for anything
|
|
242
258
|
the ISSUER did wrong (broken config, unreadable key, exhausted status
|
|
@@ -286,6 +302,9 @@ def handle_credential_request(conf: configparser.ConfigParser,
|
|
|
286
302
|
'this issuer does not use credential_identifier; send a '
|
|
287
303
|
'credential_configuration_id')
|
|
288
304
|
|
|
305
|
+
badge, _fmt = parse_credential_configuration_id(
|
|
306
|
+
conf, grant.credential_configuration_id)
|
|
307
|
+
advertised = oid4vci_key_attestations_required(conf, badge) is not None
|
|
289
308
|
try:
|
|
290
309
|
proofs = verify_proofs(
|
|
291
310
|
request,
|
|
@@ -303,6 +322,9 @@ def handle_credential_request(conf: configparser.ConfigParser,
|
|
|
303
322
|
resolve_proof_key=None,
|
|
304
323
|
resolve_proof_key_in_context=resolve_proof_key_in_context,
|
|
305
324
|
trust_anchors=None,
|
|
325
|
+
# OR, never AND-NOT: advertising the requirement without
|
|
326
|
+
# enforcing it is the retry loop this flag exists to close.
|
|
327
|
+
require_key_attestation=require_key_attestation or advertised,
|
|
306
328
|
max_age_s=cfg.proof_max_age_s, now=moment,
|
|
307
329
|
batch_size=grant.max_proofs)
|
|
308
330
|
except OID4VCIStoreError:
|
|
@@ -118,7 +118,9 @@ class InMemoryOID4VCIStore:
|
|
|
118
118
|
def invalidate_grant(self, grant_id: str) -> None:
|
|
119
119
|
with self._lock:
|
|
120
120
|
grant = self._grants.get(grant_id)
|
|
121
|
-
|
|
121
|
+
# Keep STATE_ISSUED: it is the delivery evidence reclaim needs
|
|
122
|
+
# (#313). Tokens are still dropped (OAuth 2.1 code-reuse).
|
|
123
|
+
if grant is not None and grant.state != STATE_ISSUED:
|
|
122
124
|
grant.state = STATE_INVALIDATED
|
|
123
125
|
self._tokens = {tid: entry for tid, entry in self._tokens.items()
|
|
124
126
|
if entry[0] != grant_id}
|
|
@@ -129,6 +131,10 @@ class InMemoryOID4VCIStore:
|
|
|
129
131
|
expires_at: datetime) -> None:
|
|
130
132
|
with self._lock:
|
|
131
133
|
self._tokens[token_id] = (grant_id, expires_at)
|
|
134
|
+
grant = self._grants.get(grant_id)
|
|
135
|
+
# Cover the advertised token TTL (#320).
|
|
136
|
+
if grant is not None and grant.expires_at < expires_at:
|
|
137
|
+
grant.expires_at = expires_at
|
|
132
138
|
|
|
133
139
|
def grant_for_token(self, token_id: str, *,
|
|
134
140
|
now: datetime) -> Optional[PreAuthorizedGrant]:
|
|
@@ -179,8 +185,12 @@ class InMemoryOID4VCIStore:
|
|
|
179
185
|
def purge_expired(self, *, now: datetime, limit: int = 500) -> PurgeStats:
|
|
180
186
|
with self._lock:
|
|
181
187
|
stats = PurgeStats()
|
|
188
|
+
# Keep STATE_ISSUED grants past expiry: they are the only proof
|
|
189
|
+
# a wallet claimed the offer, and reconcile needs them so it
|
|
190
|
+
# does not free a delivered credential's status-list index (#303).
|
|
182
191
|
dead_grants = [gid for gid, g in self._grants.items()
|
|
183
|
-
if g.is_expired(now)
|
|
192
|
+
if g.is_expired(now)
|
|
193
|
+
and g.state != STATE_ISSUED][:limit]
|
|
184
194
|
for gid in dead_grants:
|
|
185
195
|
grant = self._grants.pop(gid)
|
|
186
196
|
self._codes.pop(grant.code_id, None)
|
|
@@ -40,7 +40,7 @@ import logging
|
|
|
40
40
|
from typing import Any, Dict, List, Optional, Sequence
|
|
41
41
|
|
|
42
42
|
from ..confparser import (OID4VCIConfig, oid4vci_config, oid4vci_formats,
|
|
43
|
-
resolve_key_type)
|
|
43
|
+
oid4vci_key_attestations_required, resolve_key_type)
|
|
44
44
|
from ..errors import ConfigError
|
|
45
45
|
from ..keys import KeyType
|
|
46
46
|
from .formats import FORMAT_JWT_VC_JSON, FORMAT_SD_JWT_VC
|
|
@@ -175,9 +175,10 @@ def _validate_issuer_metadata(metadata: Dict[str, Any]) -> None:
|
|
|
175
175
|
try:
|
|
176
176
|
from openvc.openid4vci import parse_credential_issuer_metadata
|
|
177
177
|
except ImportError:
|
|
178
|
-
# An install WITH the extra that lands an openvc-core below the 1.
|
|
179
|
-
#
|
|
180
|
-
# minimal install, and silent about
|
|
178
|
+
# An install WITH the extra that lands an openvc-core below the 1.26
|
|
179
|
+
# floor (parsers landed in 1.25) would take this branch too —
|
|
180
|
+
# indistinguishable from a plain minimal install, and silent about
|
|
181
|
+
# the skipped check either way.
|
|
181
182
|
logger.debug('openvc-core OID4VCI discovery parsers unavailable; '
|
|
182
183
|
'the issuer metadata self-check is skipped (install the '
|
|
183
184
|
'[oid4vci] extra for it)')
|
|
@@ -232,6 +233,12 @@ def _configuration(conf: configparser.ConfigParser, cfg: OID4VCIConfig,
|
|
|
232
233
|
list(PROOF_SIGNING_ALGS)},
|
|
233
234
|
},
|
|
234
235
|
}
|
|
236
|
+
required = oid4vci_key_attestations_required(conf, badge)
|
|
237
|
+
if required is not None:
|
|
238
|
+
# Presence is the requirement. An empty object is the spec's
|
|
239
|
+
# "needed, unconstrained" spelling — not a placeholder.
|
|
240
|
+
entry['proof_types_supported']['jwt'][
|
|
241
|
+
'key_attestations_required'] = required
|
|
235
242
|
if credential_format == FORMAT_SD_JWT_VC:
|
|
236
243
|
from ..ob3.eudi import OB3_SD_JWT_VCT
|
|
237
244
|
entry['vct'] = (conf['issuer'].get('sd_jwt_vct') or '').strip() \
|
|
@@ -139,22 +139,22 @@ def build_credential_offer(conf: configparser.ConfigParser, badge: str,
|
|
|
139
139
|
notices: List[str] = []
|
|
140
140
|
expires_at = moment + timedelta(
|
|
141
141
|
seconds=expires_in_s if expires_in_s is not None else cfg.offer_ttl_s)
|
|
142
|
+
if cfg.batch_size > 1:
|
|
143
|
+
# Check before reserving: a failed offer used to burn a status-list
|
|
144
|
+
# index on every retry (#319).
|
|
145
|
+
from ..confparser import ob3_status_config
|
|
146
|
+
if ob3_status_config(conf, badge) is not None:
|
|
147
|
+
raise ConfigError(
|
|
148
|
+
"[%s] configures status_lists and [oid4vci] sets batch_size = %d, "
|
|
149
|
+
"but one grant reserves one revocation slot — the extra "
|
|
150
|
+
"credentials would be irrevocable. Set batch_size = 1, or drop "
|
|
151
|
+
"status_lists from that section."
|
|
152
|
+
% (badge, cfg.batch_size))
|
|
153
|
+
|
|
142
154
|
# The credential's id is fixed now, not at signing time: the status
|
|
143
155
|
# registry indexes the reserved slot by it, and a signing-time id would
|
|
144
156
|
# leave the registry naming a credential that never existed.
|
|
145
157
|
credential_id = 'urn:uuid:%s' % uuid.uuid4()
|
|
146
|
-
status_index = _reserve_status_index(conf, badge, chosen, recipient,
|
|
147
|
-
credential_id, expires_at, notices)
|
|
148
|
-
if status_index is not None and cfg.batch_size > 1:
|
|
149
|
-
# A grant reserves exactly one status-list slot, so batch issuance
|
|
150
|
-
# would hand out N credentials of which N-1 could never be revoked.
|
|
151
|
-
raise ConfigError(
|
|
152
|
-
"[%s] configures status_lists and [oid4vci] sets batch_size = %d, "
|
|
153
|
-
"but one grant reserves one revocation slot — the extra "
|
|
154
|
-
"credentials would be irrevocable. Set batch_size = 1, or drop "
|
|
155
|
-
"status_lists from that section."
|
|
156
|
-
% (badge, cfg.batch_size))
|
|
157
|
-
|
|
158
158
|
code = new_secret()
|
|
159
159
|
configuration_id = credential_configuration_id(badge, chosen)
|
|
160
160
|
|
|
@@ -162,7 +162,7 @@ def build_credential_offer(conf: configparser.ConfigParser, badge: str,
|
|
|
162
162
|
grant_id=new_id(), code_id=secret_id(code), badge=badge,
|
|
163
163
|
credential_configuration_id=configuration_id,
|
|
164
164
|
credential_format=chosen, recipient=recipient,
|
|
165
|
-
expires_at=expires_at, status_index=
|
|
165
|
+
expires_at=expires_at, status_index=None,
|
|
166
166
|
credential_id=credential_id,
|
|
167
167
|
max_proofs=cfg.batch_size, created_at=moment)
|
|
168
168
|
|
|
@@ -178,7 +178,15 @@ def build_credential_offer(conf: configparser.ConfigParser, badge: str,
|
|
|
178
178
|
offer = _offer_document(cfg.credential_issuer, [configuration_id], code,
|
|
179
179
|
cfg if tx_code else None)
|
|
180
180
|
_validate_offer(offer)
|
|
181
|
-
|
|
181
|
+
status_index = _reserve_status_index(conf, badge, chosen, recipient,
|
|
182
|
+
credential_id, expires_at, notices)
|
|
183
|
+
grant.status_index = status_index
|
|
184
|
+
try:
|
|
185
|
+
store.save_grant(grant)
|
|
186
|
+
except BaseException:
|
|
187
|
+
if status_index is not None:
|
|
188
|
+
_release_status_index(conf, badge, credential_id)
|
|
189
|
+
raise
|
|
182
190
|
return CredentialOffer(
|
|
183
191
|
offer=offer, uri=_offer_uri(offer), pre_authorized_code=code,
|
|
184
192
|
grant_id=grant.grant_id, recipient=recipient, expires_at=expires_at,
|
|
@@ -224,7 +232,8 @@ def _validate_offer(offer: Dict[str, Any]) -> None:
|
|
|
224
232
|
from openvc.openid4vci import parse_credential_offer
|
|
225
233
|
except ImportError:
|
|
226
234
|
# Same caveat as the metadata self-check: an openvc-core older than
|
|
227
|
-
# 1.25 takes this branch too,
|
|
235
|
+
# the 1.26 floor (parsers landed in 1.25) takes this branch too,
|
|
236
|
+
# silently skipping the check.
|
|
228
237
|
logger.debug('openvc-core OID4VCI discovery parsers unavailable; '
|
|
229
238
|
'the Credential Offer self-check is skipped (install '
|
|
230
239
|
'the [oid4vci] extra for it)')
|
|
@@ -323,5 +332,22 @@ def _reserve_status_index(conf: configparser.ConfigParser, badge: str,
|
|
|
323
332
|
return index
|
|
324
333
|
|
|
325
334
|
|
|
335
|
+
def _release_status_index(conf: configparser.ConfigParser, badge: str,
|
|
336
|
+
credential_id: str) -> None:
|
|
337
|
+
"""Undo a reservation if the grant could not be persisted (#319)."""
|
|
338
|
+
from ..confparser import ob3_status_config
|
|
339
|
+
from ..ob3.status_registry import StatusRegistry
|
|
340
|
+
|
|
341
|
+
status_conf = ob3_status_config(conf, badge)
|
|
342
|
+
if status_conf is None:
|
|
343
|
+
return
|
|
344
|
+
with StatusRegistry.locked(status_conf.registry_path,
|
|
345
|
+
status_conf.size_bits) as registry:
|
|
346
|
+
if credential_id in registry.entries \
|
|
347
|
+
and registry.entries[credential_id].pending:
|
|
348
|
+
registry.reclaim(credential_id)
|
|
349
|
+
registry.save()
|
|
350
|
+
|
|
351
|
+
|
|
326
352
|
__all__ = ['CredentialOffer', 'OFFER_SCHEME', 'QR_COMFORTABLE_LIMIT',
|
|
327
353
|
'build_credential_offer', 'parse_received_credential_offer']
|