openbadgeslib 4.2.0__tar.gz → 4.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/Changelog.txt +87 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/PKG-INFO +5 -3
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/__init__.py +7 -3
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/config.ini.example +45 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/confparser.py +187 -2
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/errors.py +4 -7
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/issue.py +187 -2
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/__init__.py +4 -2
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/credential.py +21 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/did.py +47 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/status_registry.py +91 -7
- openbadgeslib-4.3.0/openbadgeslib/oid4vci/__init__.py +110 -0
- openbadgeslib-4.3.0/openbadgeslib/oid4vci/codes.py +138 -0
- openbadgeslib-4.3.0/openbadgeslib/oid4vci/errors.py +128 -0
- openbadgeslib-4.3.0/openbadgeslib/oid4vci/formats.py +61 -0
- openbadgeslib-4.3.0/openbadgeslib/oid4vci/handler.py +348 -0
- openbadgeslib-4.3.0/openbadgeslib/oid4vci/memory_store.py +209 -0
- openbadgeslib-4.3.0/openbadgeslib/oid4vci/metadata.py +239 -0
- openbadgeslib-4.3.0/openbadgeslib/oid4vci/nonce.py +138 -0
- openbadgeslib-4.3.0/openbadgeslib/oid4vci/offer.py +260 -0
- openbadgeslib-4.3.0/openbadgeslib/oid4vci/reconcile.py +135 -0
- openbadgeslib-4.3.0/openbadgeslib/oid4vci/sqlite_store.py +596 -0
- openbadgeslib-4.3.0/openbadgeslib/oid4vci/store.py +318 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/openbadges_publish.py +114 -1
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/openbadges_signer.py +11 -3
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/openbadges_verifier.py +5 -1
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/util.py +1 -1
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib.egg-info/PKG-INFO +5 -3
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib.egg-info/SOURCES.txt +17 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib.egg-info/requires.txt +5 -2
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/pyproject.toml +16 -2
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_cli_json.py +30 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_cli_smoke.py +49 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_errors.py +19 -0
- openbadgeslib-4.3.0/tests/test_issue_credential.py +288 -0
- openbadgeslib-4.3.0/tests/test_oid4vci_config.py +265 -0
- openbadgeslib-4.3.0/tests/test_oid4vci_protocol.py +897 -0
- openbadgeslib-4.3.0/tests/test_oid4vci_reservations.py +295 -0
- openbadgeslib-4.3.0/tests/test_oid4vci_store.py +607 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/CONTRIBUTING.md +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/GOVERNANCE.md +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/LICENSE.txt +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/MANIFEST.in +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/README.md +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/SECURITY.md +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/docs/README.md +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/_jsonmodel.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/_jws/__init__.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/_jws/exceptions.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/_jws/utils.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/_ob1_api.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/badge_model.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/baking.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/cli_common.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/keys.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/logs.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/mail.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob1/__init__.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob1/badge.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob1/signer.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob1/verifier.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob2/__init__.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob2/models.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob2/signer.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob2/verifier.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/contexts/__init__.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/contexts/ob-v3p0-extensions.json +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/eudi.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/ldp.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/publish.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/signer.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/status.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/status_list.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/ob3/verifier.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/openbadges_cli.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/openbadges_init.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/openbadges_keygenerator.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/openbadges_status.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/py.typed +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib/verify.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib.egg-info/dependency_links.txt +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib.egg-info/entry_points.txt +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/openbadgeslib.egg-info/top_level.txt +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/setup.cfg +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/config1.ini +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/conformance/Dockerfile.ob3 +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/conformance/README.md +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/conformance/conftest.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/conformance/docker-compose.yml +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/conformance/test_official_validators.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/conftest.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/fixtures/ob_schemas/README.md +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/fixtures/ob_schemas/refresh.sh +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/fixtures/vc_di_eddsa/README.md +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/images/sample1.png +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/images/sample1.svg +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/images/userimage01.svg +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/ldp_helpers.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/logo Python Espan/314/203a.svg" +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/logo Python Espa/303/261a.svg" +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_baking.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_confparser.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_docs.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_eddsa.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_examples.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_issue.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_jsonmodel.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_jws.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_key_operation.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_keys.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_mail.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob1_badge_io.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob1_deprecation.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob1_signer.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob1_verifier.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob2_cli.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob2_error_branches.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob2_models.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob2_signer.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob2_verifier.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob3_conformance_schema.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob3_contexts.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob3_credential.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob3_did.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob3_did_doc.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob3_endorsement.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob3_eudi_oid4vci.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob3_eudi_sd_jwt.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob3_eudi_type_metadata.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob3_eudi_x5c.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob3_ldp.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob3_ldp_cli.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob3_ldp_ecdsa_sd.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob3_ldp_errors.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob3_ldp_sign.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob3_publish_cli.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob3_signer.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob3_signer_cli_ldp.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob3_status.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob3_status_list.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob3_status_registry.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_ob3_verifier.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_openbadges_cli.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_public_api.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_sign_ecc.pem +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_sign_rsa.pem +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_signer_batch.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_util.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_verifier_cli_coverage.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_verify.py +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_verify_ecc.pem +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/test_verify_rsa.pem +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/withoutxmlheader.svg +0 -0
- {openbadgeslib-4.2.0 → openbadgeslib-4.3.0}/tests/withxmlheader.svg +0 -0
|
@@ -4,6 +4,93 @@ OpenBadgesLib - Changelog
|
|
|
4
4
|
Newest first. Dates are ISO 8601 (YYYY-MM-DD).
|
|
5
5
|
|
|
6
6
|
|
|
7
|
+
* v4.3.0 - 2026-08-01
|
|
8
|
+
|
|
9
|
+
Issue a badge straight to a wallet, over OpenID4VCI.
|
|
10
|
+
|
|
11
|
+
- feat(oid4vci): issue badges to a wallet over OpenID for Verifiable
|
|
12
|
+
Credential Issuance 1.0, pre-authorized code flow. New `openbadgeslib.
|
|
13
|
+
oid4vci` package: Credential Offers, the issuer and RFC 8414 discovery
|
|
14
|
+
documents, and handlers for the token, nonce and credential endpoints. It
|
|
15
|
+
ships NO web server — the handlers return response bodies and the
|
|
16
|
+
integrator mounts the routes — which keeps the boundary openbadges-publish
|
|
17
|
+
already has (it writes files to serve, it does not serve them). The wallet
|
|
18
|
+
key-proof cryptography is delegated to openvc-core's `openvc.openid4vci`
|
|
19
|
+
behind a new `[oid4vci]` extra; the state that openvc refuses to hold —
|
|
20
|
+
codes, tokens, nonces, grants and their lifetimes — lives here, and with it
|
|
21
|
+
the "no wrong-issue" property. Formats: `jwt_vc_json` (revocable) and
|
|
22
|
+
`vc+sd-jwt` (the EUDI track, irrevocable per #226).
|
|
23
|
+
NOT HAIP-conformant and nothing built on it may claim to be: no DPoP, no
|
|
24
|
+
verified key attestations, no client authentication, no authorization code
|
|
25
|
+
flow. See wiki/Issuing-to-Wallets-with-OID4VCI.md and the new Security
|
|
26
|
+
Model section for the full list of what is and is not defended.
|
|
27
|
+
The state store is an interface with two backends: in-memory for a single
|
|
28
|
+
process, and SQLite for the threads and processes of one host. Single-use is
|
|
29
|
+
settled in the database rather than in Python — burning a nonce and claiming
|
|
30
|
+
an issuance are INSERTs under BEGIN IMMEDIATE — so exactly one caller wins
|
|
31
|
+
no matter how many workers race. Secrets are stored as digests, the file is
|
|
32
|
+
0600 in a 0700 directory, and an unknown schema version is refused.
|
|
33
|
+
- feat(issue): `issue_credential_from_conf()` issues an OB 3.0 credential as
|
|
34
|
+
a TOKEN rather than a baked badge image, returning a `CredentialResult`.
|
|
35
|
+
The existing `issue_from_conf` always bakes a PNG/SVG, which is wasted work
|
|
36
|
+
(and the wrong deliverable) when the credential goes to a wallet, an LMS
|
|
37
|
+
API or an email — reaching the raw token previously meant baking an SVG and
|
|
38
|
+
extracting it again. Takes an optional `holder_jwk` to bind the credential
|
|
39
|
+
to a wallet key: for `jwt_vc_json` the subject id becomes that key's
|
|
40
|
+
`did:jwk` and the recipient moves to a salted `credentialSubject.
|
|
41
|
+
identifier` hash, so the badge stays attributable to a person without
|
|
42
|
+
publishing their address; for `vc+sd-jwt` the key goes into `cnf`.
|
|
43
|
+
- feat(ob3): `did_jwk_from_jwk()` derives a did:jwk from a public JWK, and
|
|
44
|
+
`IdentityObject.create()` builds a salted-hash identifier (the OB 3.0
|
|
45
|
+
mirror of the OB 2.0 constructor). Both refuse JWKs carrying private key
|
|
46
|
+
material.
|
|
47
|
+
- feat(ob3): the status registry gains reservations (schema version 2).
|
|
48
|
+
An OID4VCI offer reserves its revocation slot when the offer is built, not
|
|
49
|
+
when the wallet claims — deciding it at claim time would put the registry's
|
|
50
|
+
exclusive lock and full-file rewrite inside a concurrent HTTP path, on a
|
|
51
|
+
lock that is a no-op where fcntl is missing. Reserved entries show as
|
|
52
|
+
`reserved` in `--list`; `openbadges-publish -V 3 --reclaim-unclaimed`
|
|
53
|
+
frees the ones whose offer lapsed, refusing to touch any slot the registry
|
|
54
|
+
and the OID4VCI store do not both agree was never issued against. Schema
|
|
55
|
+
version 1 files load unchanged, and a registry with no reservations
|
|
56
|
+
serialises byte-identically to before.
|
|
57
|
+
- fix(oid4vci): the SQLite store's opportunistic garbage collection takes an
|
|
58
|
+
injectable clock. It ran on wall-clock time while every query takes an
|
|
59
|
+
explicit `now=`, so a write collected the very rows a caller had just
|
|
60
|
+
written against a frozen instant — which made the store's own test suite
|
|
61
|
+
pass on the day it was written and fail a week later on the calendar
|
|
62
|
+
rather than on a code change.
|
|
63
|
+
- security(verifier): `openbadges-verifier -V 1` no longer reports
|
|
64
|
+
`trusted: true` for a badge that FAILED verification (#278). The OB 1.0 path
|
|
65
|
+
set the trust flag before the valid/invalid branch, so a tampered badge
|
|
66
|
+
checked against an operator-supplied key (-k/--local) emitted
|
|
67
|
+
`{valid: false, trusted: true, status: SIGNATURE_ERROR}` — contradicting the
|
|
68
|
+
invariant the OB 2.0 and OB 3.0 paths already honoured (#258) and that a
|
|
69
|
+
consumer reading the JSON object, rather than the exit status, relies on.
|
|
70
|
+
Exit codes are unchanged: such a badge already exited 1.
|
|
71
|
+
- fix(cli): `openbadges-signer` maps a misconfigured badge section to a clean
|
|
72
|
+
`[!]` error instead of a raw traceback (#279). It built the badge model
|
|
73
|
+
without the mapper that turns a missing config key or an unreadable
|
|
74
|
+
key/image into an IssuanceError, so the most likely first-run mistake — a
|
|
75
|
+
section missing `private_key`, or keys not generated yet — ended in a
|
|
76
|
+
KeyError traceback in human mode (`--json` was already covered).
|
|
77
|
+
- fix(errors): the documented exception tree no longer misfiles
|
|
78
|
+
`AmbiguousCredential` (#280). `openbadgeslib.errors` carried a dead
|
|
79
|
+
`AmbiguousCredential(StatusError)` that was never raised, while the one
|
|
80
|
+
actually raised and exported is `ob3.publish.AmbiguousCredential`, a
|
|
81
|
+
`PublishError` — so `except StatusError` around a publish/revoke call, as
|
|
82
|
+
the tree in the docstring and the wiki instructed, did not catch it. The
|
|
83
|
+
dead class is gone and the `PublishError` family (with `CredentialNotFound`)
|
|
84
|
+
is documented in both places.
|
|
85
|
+
- build(deps): the openvc-core floors move to >=1.24 across `[eudi]`,
|
|
86
|
+
`[ldp-sd]` and `[oid4vci]`, which is the release the suite is actually
|
|
87
|
+
tested against — the rule the extras already documented, applied uniformly
|
|
88
|
+
(`[ldp-sd]` had been left behind at >=1.21). Substantively 1.23 still
|
|
89
|
+
suffices for the OpenID4VCI key proof and 1.22 for the Token Status List;
|
|
90
|
+
1.24 is additive over both (`ResolveProofKey` is unchanged and not
|
|
91
|
+
deprecated), so an install already on 1.24 sees no behaviour change.
|
|
92
|
+
|
|
93
|
+
|
|
7
94
|
* v4.2.0 - 2026-07-29
|
|
8
95
|
|
|
9
96
|
The OpenID4VCI credential configuration an issuer advertises can now declare
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openbadgeslib
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.3.0
|
|
4
4
|
Summary: Production-ready Python library & CLI for Open Badges 3.0 (W3C Verifiable Credentials): issue, verify, and revoke JWT-VC credentials with Bitstring Status Lists and did:web — plus strict OB 2.0 and legacy OB 1.0.
|
|
5
5
|
Author-email: Luis González Fernández <luisgf@luisgf.es>, Jesús Cea Avión <jcea@jcea.es>
|
|
6
6
|
License: LGPLv3
|
|
@@ -36,9 +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.24; 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.24; extra == "ldp-sd"
|
|
42
|
+
Provides-Extra: oid4vci
|
|
43
|
+
Requires-Dist: openvc-core<2,>=1.24; extra == "oid4vci"
|
|
42
44
|
Provides-Extra: dev
|
|
43
45
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
44
46
|
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
@@ -66,7 +66,8 @@ from ._ob1_api import OB1_API as _OB1_API # noqa: F401
|
|
|
66
66
|
# (hence cryptography) — inside the run-to-run noise — so the documentation of
|
|
67
67
|
# the recommended modern API is worth more than it.
|
|
68
68
|
from .issue import ( # noqa: F401
|
|
69
|
-
IssuanceError, SignResult,
|
|
69
|
+
CredentialResult, IssuanceError, SignResult, issue_credential_from_conf,
|
|
70
|
+
issue_from_conf,
|
|
70
71
|
)
|
|
71
72
|
from .verify import verify_badge # noqa: F401
|
|
72
73
|
|
|
@@ -113,6 +114,9 @@ __all__ = [
|
|
|
113
114
|
'OpenBadgeCredential', 'Achievement', 'Issuer',
|
|
114
115
|
# Keys (KeyEd25519 is the recommended OB 3.0 / LDP key type).
|
|
115
116
|
'KeyFactory', 'KeyRSA', 'KeyECC', 'KeyEd25519',
|
|
116
|
-
# Programmatic issue / verify facades.
|
|
117
|
-
|
|
117
|
+
# Programmatic issue / verify facades. issue_credential_from_conf is the
|
|
118
|
+
# token-returning sibling of issue_from_conf, for delivering a credential
|
|
119
|
+
# over a digital channel (a wallet, an API) rather than as a badge image.
|
|
120
|
+
'issue_from_conf', 'issue_credential_from_conf', 'verify_badge',
|
|
121
|
+
'IssuanceError', 'SignResult', 'CredentialResult',
|
|
118
122
|
]
|
|
@@ -52,6 +52,45 @@ revocationList = revoked.json
|
|
|
52
52
|
; imsglobal vct.
|
|
53
53
|
;sd_jwt_vct = ${publish_url}vct/openbadge
|
|
54
54
|
|
|
55
|
+
; OpenID for Verifiable Credential Issuance (OID4VCI), issuer side. Optional and
|
|
56
|
+
; entirely opt-in: without this section nothing changes, and a badge is only
|
|
57
|
+
; offered over OID4VCI if its own 'oid4vci_formats' key says so.
|
|
58
|
+
;
|
|
59
|
+
; This library does NOT run the endpoints. It builds the offers, verifies the
|
|
60
|
+
; wallet's key proof (via openvc-core, the [oid4vci] extra) and issues the
|
|
61
|
+
; credential; you mount the routes in your own web framework at the URLs below.
|
|
62
|
+
;
|
|
63
|
+
; NOT a HAIP-conformant issuer: no DPoP, no key attestation checking, no client
|
|
64
|
+
; authentication and no authorization code flow. Pre-authorized code only.
|
|
65
|
+
;[oid4vci]
|
|
66
|
+
; The Credential Issuer Identifier: the https base URL where you serve
|
|
67
|
+
; /.well-known/openid-credential-issuer. Every wallet key proof binds its 'aud'
|
|
68
|
+
; to this exact string, so it must match what you actually serve. Default:
|
|
69
|
+
; [issuer] publish_url.
|
|
70
|
+
;credential_issuer = https://openbadges.issuer.badge/issuer/
|
|
71
|
+
; The three endpoints you mount. Default: derived from credential_issuer.
|
|
72
|
+
;credential_endpoint = https://openbadges.issuer.badge/issuer/credential
|
|
73
|
+
;nonce_endpoint = https://openbadges.issuer.badge/issuer/nonce
|
|
74
|
+
;token_endpoint = https://openbadges.issuer.badge/issuer/token
|
|
75
|
+
; Where the flow's short-lived state lives (pre-authorized codes, access tokens,
|
|
76
|
+
; spent nonces). SQLite, single host: NOT on NFS/SMB, where its locking breaks.
|
|
77
|
+
; The directory is created 0700 and holds recipient PII — do not back it up.
|
|
78
|
+
; Default: ${paths:base}/oid4vci.sqlite3
|
|
79
|
+
;store_path = ${paths:base}/oid4vci.sqlite3
|
|
80
|
+
; Lifetimes, in seconds. The offer TTL bounds how long a QR code stays
|
|
81
|
+
; redeemable; keep it short, it is a bearer secret.
|
|
82
|
+
;offer_ttl_s = 600
|
|
83
|
+
;nonce_ttl_s = 120
|
|
84
|
+
;token_ttl_s = 300
|
|
85
|
+
; How stale a wallet's key proof may be before it is refused.
|
|
86
|
+
;proof_max_age_s = 300
|
|
87
|
+
; Credentials one Credential Request may claim. Leave at 1 unless you have a
|
|
88
|
+
; reason: a batch mints one credential per key proof off a single grant.
|
|
89
|
+
;batch_size = 1
|
|
90
|
+
; The out-of-band PIN, when an offer asks for one. 'numeric' or 'text'.
|
|
91
|
+
;tx_code_length = 6
|
|
92
|
+
;tx_code_input_mode = numeric
|
|
93
|
+
|
|
55
94
|
;Badge configuration sections.
|
|
56
95
|
[badge_1]
|
|
57
96
|
name = Badge 1
|
|
@@ -98,6 +137,12 @@ key_type = RSA
|
|
|
98
137
|
; lapses; pick a cadence you can sustain (e.g. 7 => weekly). Unset = no bound
|
|
99
138
|
; (offline-friendly, but publishes a loud warning).
|
|
100
139
|
;status_validity_days = 7
|
|
140
|
+
; OID4VCI opt-in for this badge (comma-separated subset of: jwt_vc_json,
|
|
141
|
+
; vc+sd-jwt). Unset means the badge is not published in the issuer metadata and
|
|
142
|
+
; cannot be offered to a wallet. jwt_vc_json is revocable via the status lists
|
|
143
|
+
; above; vc+sd-jwt is the EUDI track and is IRREVOCABLE, so it cannot be
|
|
144
|
+
; combined with status_lists. vc+sd-jwt also needs key_type = ED25519 or ECC.
|
|
145
|
+
;oid4vci_formats = jwt_vc_json
|
|
101
146
|
;mail = ${paths:base}/badge_1_mail.txt
|
|
102
147
|
|
|
103
148
|
[badge_2]
|
|
@@ -23,7 +23,7 @@
|
|
|
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, TYPE_CHECKING
|
|
26
|
+
from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
|
|
27
27
|
import os
|
|
28
28
|
import sys
|
|
29
29
|
import logging
|
|
@@ -197,6 +197,189 @@ def ob3_status_config(conf: ConfigParser,
|
|
|
197
197
|
validity_days=validity_days)
|
|
198
198
|
|
|
199
199
|
|
|
200
|
+
# ── OID4VCI (OpenID for Verifiable Credential Issuance) ──────────────────────
|
|
201
|
+
# Issuing to a wallet inverts the library's usual direction: instead of the
|
|
202
|
+
# issuer pushing a badge to a known recipient, the wallet claims it against an
|
|
203
|
+
# offer and proves possession of its own key. That needs a handful of
|
|
204
|
+
# deployment values the rest of the config has no reason to carry — the public
|
|
205
|
+
# identifier the wallet's key proofs bind to, and the lifetimes of the
|
|
206
|
+
# short-lived secrets the flow mints.
|
|
207
|
+
#
|
|
208
|
+
# The whole section is optional: without it, nothing else in the library
|
|
209
|
+
# changes behaviour.
|
|
210
|
+
|
|
211
|
+
#: How long a Credential Offer's pre-authorized code stays redeemable. Short by
|
|
212
|
+
#: default: it is a bearer secret that travels in a QR code or a link.
|
|
213
|
+
DEFAULT_OFFER_TTL_S = 600
|
|
214
|
+
|
|
215
|
+
#: Lifetime of a c_nonce handed out by the nonce endpoint.
|
|
216
|
+
DEFAULT_NONCE_TTL_S = 120
|
|
217
|
+
|
|
218
|
+
#: Lifetime of an access token minted at the token endpoint. It only has to
|
|
219
|
+
#: survive the wallet's immediate follow-up call to the credential endpoint.
|
|
220
|
+
DEFAULT_TOKEN_TTL_S = 300
|
|
221
|
+
|
|
222
|
+
#: How stale a wallet key proof's `iat` may be (openvc's max_age_s).
|
|
223
|
+
DEFAULT_PROOF_MAX_AGE_S = 300
|
|
224
|
+
|
|
225
|
+
#: Digits in a numeric tx_code, and the input modes OID4VCI 1.0 defines.
|
|
226
|
+
DEFAULT_TX_CODE_LENGTH = 6
|
|
227
|
+
TX_CODE_INPUT_MODES = ('numeric', 'text')
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@dataclass
|
|
231
|
+
class OID4VCIConfig:
|
|
232
|
+
"""Resolved [oid4vci] section: the issuer identifier, the endpoint URLs and
|
|
233
|
+
the lifetimes of the flow's short-lived secrets.
|
|
234
|
+
|
|
235
|
+
``credential_issuer`` is load-bearing beyond being an identifier: it is the
|
|
236
|
+
value every wallet key proof must name in its ``aud``, so it has to match
|
|
237
|
+
byte for byte what is actually served at
|
|
238
|
+
``<credential_issuer>/.well-known/openid-credential-issuer``."""
|
|
239
|
+
|
|
240
|
+
credential_issuer: str
|
|
241
|
+
credential_endpoint: str
|
|
242
|
+
nonce_endpoint: str
|
|
243
|
+
token_endpoint: str
|
|
244
|
+
store_path: str
|
|
245
|
+
offer_ttl_s: int = DEFAULT_OFFER_TTL_S
|
|
246
|
+
nonce_ttl_s: int = DEFAULT_NONCE_TTL_S
|
|
247
|
+
token_ttl_s: int = DEFAULT_TOKEN_TTL_S
|
|
248
|
+
proof_max_age_s: int = DEFAULT_PROOF_MAX_AGE_S
|
|
249
|
+
batch_size: int = 1
|
|
250
|
+
tx_code_length: int = DEFAULT_TX_CODE_LENGTH
|
|
251
|
+
tx_code_input_mode: str = 'numeric'
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _positive_int(conf: ConfigParser, section: str, key: str,
|
|
255
|
+
default: int) -> int:
|
|
256
|
+
"""Read a positive integer config key, or its default when unset."""
|
|
257
|
+
raw = (conf[section].get(key) or '').strip() if section in conf else ''
|
|
258
|
+
if not raw:
|
|
259
|
+
return default
|
|
260
|
+
try:
|
|
261
|
+
value = int(raw)
|
|
262
|
+
except ValueError:
|
|
263
|
+
raise ConfigError("[%s] %s must be an integer, got %r"
|
|
264
|
+
% (section, key, raw)) from None
|
|
265
|
+
if value <= 0:
|
|
266
|
+
raise ConfigError("[%s] %s must be positive" % (section, key))
|
|
267
|
+
return value
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def oid4vci_config(conf: ConfigParser) -> OID4VCIConfig:
|
|
271
|
+
"""Resolve the optional [oid4vci] section.
|
|
272
|
+
|
|
273
|
+
Every key has a default, so a typical deployment writes none of them: the
|
|
274
|
+
issuer identifier falls back to ``[issuer] publish_url`` and the three
|
|
275
|
+
endpoints are derived from it. Raises :class:`ConfigError` when there is no
|
|
276
|
+
base to derive an issuer identifier from, when it is not https, or when a
|
|
277
|
+
lifetime is not a positive integer.
|
|
278
|
+
|
|
279
|
+
``https`` is required rather than encouraged. A key proof's ``aud`` binds
|
|
280
|
+
the wallet's signature to this exact string; over plaintext http an attacker
|
|
281
|
+
who can rewrite the metadata response also chooses what the wallet signs
|
|
282
|
+
for, which makes the binding decorative.
|
|
283
|
+
"""
|
|
284
|
+
from urllib.parse import urljoin
|
|
285
|
+
|
|
286
|
+
section = conf['oid4vci'] if 'oid4vci' in conf else None
|
|
287
|
+
base = (section.get('credential_issuer') if section else None) or \
|
|
288
|
+
conf['issuer'].get('publish_url') or ''
|
|
289
|
+
base = base.strip()
|
|
290
|
+
if not base:
|
|
291
|
+
raise ConfigError(
|
|
292
|
+
"[oid4vci] needs a credential_issuer (or an [issuer] publish_url "
|
|
293
|
+
"to derive it from): it is the identifier wallet key proofs bind "
|
|
294
|
+
"their 'aud' to")
|
|
295
|
+
if not base.startswith('https://'):
|
|
296
|
+
raise ConfigError(
|
|
297
|
+
"[oid4vci] credential_issuer must be an https URL, got %r — a "
|
|
298
|
+
"wallet key proof's audience binding is meaningless over plaintext"
|
|
299
|
+
% base)
|
|
300
|
+
# A trailing slash makes urljoin append rather than replace the last path
|
|
301
|
+
# segment; without it, an issuer at https://host/issuer would derive
|
|
302
|
+
# https://host/credential.
|
|
303
|
+
endpoint_base = base if base.endswith('/') else base + '/'
|
|
304
|
+
|
|
305
|
+
def endpoint(key: str, default_path: str) -> str:
|
|
306
|
+
explicit = (section.get(key) or '').strip() if section else ''
|
|
307
|
+
return explicit or urljoin(endpoint_base, default_path)
|
|
308
|
+
|
|
309
|
+
store_path = (section.get('store_path') if section else None) or \
|
|
310
|
+
os.path.join(conf['paths']['base'], 'oid4vci.sqlite3')
|
|
311
|
+
|
|
312
|
+
input_mode = ((section.get('tx_code_input_mode') if section else None)
|
|
313
|
+
or 'numeric').strip()
|
|
314
|
+
if input_mode not in TX_CODE_INPUT_MODES:
|
|
315
|
+
raise ConfigError("[oid4vci] tx_code_input_mode must be one of %s, "
|
|
316
|
+
"got %r" % (', '.join(TX_CODE_INPUT_MODES),
|
|
317
|
+
input_mode))
|
|
318
|
+
|
|
319
|
+
return OID4VCIConfig(
|
|
320
|
+
credential_issuer=base,
|
|
321
|
+
credential_endpoint=endpoint('credential_endpoint', 'credential'),
|
|
322
|
+
nonce_endpoint=endpoint('nonce_endpoint', 'nonce'),
|
|
323
|
+
token_endpoint=endpoint('token_endpoint', 'token'),
|
|
324
|
+
store_path=store_path,
|
|
325
|
+
offer_ttl_s=_positive_int(conf, 'oid4vci', 'offer_ttl_s',
|
|
326
|
+
DEFAULT_OFFER_TTL_S),
|
|
327
|
+
nonce_ttl_s=_positive_int(conf, 'oid4vci', 'nonce_ttl_s',
|
|
328
|
+
DEFAULT_NONCE_TTL_S),
|
|
329
|
+
token_ttl_s=_positive_int(conf, 'oid4vci', 'token_ttl_s',
|
|
330
|
+
DEFAULT_TOKEN_TTL_S),
|
|
331
|
+
proof_max_age_s=_positive_int(conf, 'oid4vci', 'proof_max_age_s',
|
|
332
|
+
DEFAULT_PROOF_MAX_AGE_S),
|
|
333
|
+
batch_size=_positive_int(conf, 'oid4vci', 'batch_size', 1),
|
|
334
|
+
tx_code_length=_positive_int(conf, 'oid4vci', 'tx_code_length',
|
|
335
|
+
DEFAULT_TX_CODE_LENGTH),
|
|
336
|
+
tx_code_input_mode=input_mode,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def oid4vci_formats(conf: ConfigParser, badge_section: str) -> Tuple[str, ...]:
|
|
341
|
+
"""The OID4VCI credential formats a badge section opts into, in order.
|
|
342
|
+
|
|
343
|
+
Returns an empty tuple when the badge does not opt in (no
|
|
344
|
+
``oid4vci_formats`` key), which callers treat as "this badge is not offered
|
|
345
|
+
over OID4VCI" — the same contract as :func:`ob3_status_config` returning
|
|
346
|
+
None. Opt-in rather than opt-out because listing a badge in the issuer
|
|
347
|
+
metadata publishes its existence and makes it claimable.
|
|
348
|
+
|
|
349
|
+
Raises :class:`ConfigError` for an unknown format, or for ``vc+sd-jwt`` on a
|
|
350
|
+
section with ``key_type = RSA``: SD-JWT VC has no RSA algorithm profile, so
|
|
351
|
+
that pairing could never issue and the operator should learn about it when
|
|
352
|
+
the config loads, not when a wallet is waiting.
|
|
353
|
+
"""
|
|
354
|
+
from .keys import KeyType
|
|
355
|
+
from .oid4vci.formats import EC_ONLY_FORMATS, OID4VCI_FORMATS
|
|
356
|
+
|
|
357
|
+
raw = conf[badge_section].get('oid4vci_formats', '')
|
|
358
|
+
formats: List[str] = []
|
|
359
|
+
for piece in raw.split(','):
|
|
360
|
+
fmt = piece.strip()
|
|
361
|
+
if not fmt:
|
|
362
|
+
continue
|
|
363
|
+
if fmt not in OID4VCI_FORMATS:
|
|
364
|
+
raise ConfigError(
|
|
365
|
+
"[%s] oid4vci_formats: unknown format %r (choose from %s)"
|
|
366
|
+
% (badge_section, fmt, ', '.join(OID4VCI_FORMATS)))
|
|
367
|
+
if fmt not in formats:
|
|
368
|
+
formats.append(fmt)
|
|
369
|
+
if not formats:
|
|
370
|
+
return ()
|
|
371
|
+
|
|
372
|
+
key_type = resolve_key_type(conf[badge_section].get('key_type'))
|
|
373
|
+
if key_type is KeyType.RSA:
|
|
374
|
+
rsa_incapable = [f for f in formats if f in EC_ONLY_FORMATS]
|
|
375
|
+
if rsa_incapable:
|
|
376
|
+
raise ConfigError(
|
|
377
|
+
"[%s] oid4vci_formats includes %s, which cannot be signed with "
|
|
378
|
+
"an RSA key — use key_type = ED25519 or ECC, or drop that "
|
|
379
|
+
"format" % (badge_section, ', '.join(rsa_incapable)))
|
|
380
|
+
return tuple(formats)
|
|
381
|
+
|
|
382
|
+
|
|
200
383
|
# ── centralized config defaults ──────────────────────────────────────────────
|
|
201
384
|
# One inventory for the accepted-key defaults, so each lives in a single place
|
|
202
385
|
# rather than being re-spelled at every read site. (The status-list defaults —
|
|
@@ -285,7 +468,9 @@ def badge_section_config(conf: ConfigParser,
|
|
|
285
468
|
#: Sections whose values are the *public* identifiers of a deployment, checked
|
|
286
469
|
#: by :func:`reject_url_userinfo`. [smtp] is deliberately excluded: its
|
|
287
470
|
#: username/password are credentials that stay on the issuer's machine.
|
|
288
|
-
|
|
471
|
+
#: [oid4vci] belongs here because its credential_issuer and endpoint URLs are
|
|
472
|
+
#: published in the issuer metadata and handed to every wallet that reads it.
|
|
473
|
+
_PUBLIC_URL_SECTIONS = ('issuer', 'oid4vci')
|
|
289
474
|
_PUBLIC_URL_SECTION_PREFIX = 'badge_'
|
|
290
475
|
|
|
291
476
|
|
|
@@ -47,9 +47,10 @@ class LibOpenBadgesException(Exception):
|
|
|
47
47
|
├── IssuanceError
|
|
48
48
|
├── DecompressionLimitExceeded
|
|
49
49
|
├── StatusError (issuer-side revocation)
|
|
50
|
-
│ └── StatusListFull / UnknownCredential /
|
|
51
|
-
│ /
|
|
52
|
-
|
|
50
|
+
│ └── StatusListFull / UnknownCredential / AlreadyRevoked
|
|
51
|
+
│ / AlreadySuspended / NotSuspended / RegistryCorrupt
|
|
52
|
+
├── PublishError (openbadgeslib.ob3.publish)
|
|
53
|
+
│ └── CredentialNotFound / AmbiguousCredential
|
|
53
54
|
├── OB2VerificationError (openbadgeslib.ob2.verifier)
|
|
54
55
|
└── OB3VerificationError (openbadgeslib.ob3.verifier)
|
|
55
56
|
|
|
@@ -201,10 +202,6 @@ class UnknownCredential(StatusError):
|
|
|
201
202
|
pass
|
|
202
203
|
|
|
203
204
|
|
|
204
|
-
class AmbiguousCredential(StatusError):
|
|
205
|
-
pass
|
|
206
|
-
|
|
207
|
-
|
|
208
205
|
class AlreadyRevoked(StatusError):
|
|
209
206
|
pass
|
|
210
207
|
|
|
@@ -371,13 +371,19 @@ def _ob3_setup(conf: configparser.ConfigParser, badge: str, badge_obj: Badge,
|
|
|
371
371
|
|
|
372
372
|
def _build_ob3_credential(ctx: _Ob3Context, recipient: str,
|
|
373
373
|
evidence: Optional[str],
|
|
374
|
-
expires: Optional[int]
|
|
375
|
-
|
|
374
|
+
expires: Optional[int],
|
|
375
|
+
credential_id: Optional[str] = None) -> Any:
|
|
376
|
+
"""Build one OpenBadgeCredential from the shared context (no status yet).
|
|
377
|
+
|
|
378
|
+
``credential_id`` pins the credential's id instead of letting the model
|
|
379
|
+
mint one, for a flow that had to know the id before signing — a reserved
|
|
380
|
+
status-list slot is recorded against it."""
|
|
376
381
|
from .ob3 import OpenBadgeCredential
|
|
377
382
|
expiration_date = None
|
|
378
383
|
if expires:
|
|
379
384
|
expiration_date = datetime.now(tz=timezone.utc) + timedelta(days=expires)
|
|
380
385
|
return OpenBadgeCredential(
|
|
386
|
+
id=credential_id,
|
|
381
387
|
issuer=ctx.issuer,
|
|
382
388
|
recipient_id=normalize_recipient_id(recipient),
|
|
383
389
|
achievement=ctx.achievement,
|
|
@@ -413,6 +419,185 @@ def _sign_ob3_credential(badge: str, credential: Any, badge_obj: Badge,
|
|
|
413
419
|
raise IssuanceError(str(exc)) from exc
|
|
414
420
|
|
|
415
421
|
|
|
422
|
+
@dataclass
|
|
423
|
+
class CredentialResult:
|
|
424
|
+
"""One OB 3.0 credential issued as a TOKEN rather than baked into an image.
|
|
425
|
+
|
|
426
|
+
The counterpart of :class:`SignResult` for callers that deliver a credential
|
|
427
|
+
over a digital channel — a wallet claiming it over OID4VCI, an LMS API, an
|
|
428
|
+
email carrying the JWT — where a PNG is not the deliverable and baking one
|
|
429
|
+
is wasted work.
|
|
430
|
+
|
|
431
|
+
``credential_format`` is the OID4VCI vocabulary (``jwt_vc_json`` /
|
|
432
|
+
``vc+sd-jwt``), which is a different axis from the badge section's
|
|
433
|
+
``proof_format``: that one governs the baked-image path only."""
|
|
434
|
+
|
|
435
|
+
credential_format: str
|
|
436
|
+
token: str
|
|
437
|
+
credential: 'OpenBadgeCredential'
|
|
438
|
+
jti: Optional[str] = None
|
|
439
|
+
status_index: Optional[int] = None
|
|
440
|
+
holder_did: Optional[str] = None # did:jwk of the bound holder, if any
|
|
441
|
+
notices: List[str] = field(default_factory=list)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _bind_holder(credential: Any, recipient: str,
|
|
445
|
+
holder_jwk: dict[str, Any]) -> str:
|
|
446
|
+
"""Rebind a credential's subject to a wallet key, keeping the recipient.
|
|
447
|
+
|
|
448
|
+
``credentialSubject.id`` becomes the holder's did:jwk — the key that proved
|
|
449
|
+
possession — and the recipient's address moves to
|
|
450
|
+
``credentialSubject.identifier`` as a salted hash. Both matter: without the
|
|
451
|
+
first the credential is not bound to the wallet at all, and without the
|
|
452
|
+
second the badge stops being attributable to a person, since the holder key
|
|
453
|
+
is an opaque blob nobody can match to a learner.
|
|
454
|
+
|
|
455
|
+
The salt is fresh per credential, so the same address hashed for two badges
|
|
456
|
+
does not correlate them. Returns the did:jwk.
|
|
457
|
+
"""
|
|
458
|
+
from .ob3 import IdentityObject
|
|
459
|
+
from .ob3.did import did_jwk_from_jwk
|
|
460
|
+
|
|
461
|
+
try:
|
|
462
|
+
holder_did = did_jwk_from_jwk(holder_jwk)
|
|
463
|
+
except ValueError as exc:
|
|
464
|
+
raise IssuanceError('invalid holder key: %s' % exc) from exc
|
|
465
|
+
credential.recipient_id = holder_did
|
|
466
|
+
credential.identifiers = [
|
|
467
|
+
IdentityObject.create(recipient, salt=os.urandom(16).hex())]
|
|
468
|
+
return holder_did
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _stamp_status(credential: Any, status_conf: Any, index: int) -> None:
|
|
472
|
+
"""Attach credentialStatus entries for an ALREADY allocated index.
|
|
473
|
+
|
|
474
|
+
The split from :func:`_allocate_status_batch` exists because OID4VCI
|
|
475
|
+
allocates at offer time and signs later, when the wallet claims: the index
|
|
476
|
+
is decided in the issuer's own control path, and the request path only
|
|
477
|
+
stamps it. Reserving an index inside the credential endpoint instead would
|
|
478
|
+
put a registry write — an exclusive lock plus a full-file rewrite, and a
|
|
479
|
+
no-op lock on platforms without fcntl — into a concurrent HTTP path."""
|
|
480
|
+
from .ob3.status_list import status_entry
|
|
481
|
+
credential.credential_status = [
|
|
482
|
+
status_entry(status_conf.list_urls[p], p, index)
|
|
483
|
+
for p in status_conf.purposes]
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def issue_credential_from_conf(conf: configparser.ConfigParser, badge: str,
|
|
487
|
+
recipient: str, *,
|
|
488
|
+
credential_format: str = 'jwt_vc_json',
|
|
489
|
+
holder_jwk: Optional[dict[str, Any]] = None,
|
|
490
|
+
evidence: Optional[str] = None,
|
|
491
|
+
expires: Optional[int] = None,
|
|
492
|
+
status_index: Optional[int] = None,
|
|
493
|
+
credential_id: Optional[str] = None
|
|
494
|
+
) -> CredentialResult:
|
|
495
|
+
"""Issue an OB 3.0 credential as a token, without baking it into an image.
|
|
496
|
+
|
|
497
|
+
``credential_format`` is ``jwt_vc_json`` (a compact JWT-VC, revocable) or
|
|
498
|
+
``vc+sd-jwt`` (an SD-JWT VC, the EUDI track). ``ldp`` has no equivalent
|
|
499
|
+
here: a document carrying an embedded proof is not a token.
|
|
500
|
+
|
|
501
|
+
Pass ``holder_jwk`` — the public key a wallet's OID4VCI key proof
|
|
502
|
+
demonstrated possession of — to bind the credential to that holder. For
|
|
503
|
+
``jwt_vc_json`` the subject id becomes the key's did:jwk and the recipient
|
|
504
|
+
moves to a salted ``identifier`` hash; for ``vc+sd-jwt`` the key goes into
|
|
505
|
+
``cnf``. Without it the subject is the normalised recipient, as in
|
|
506
|
+
:func:`issue_from_conf`.
|
|
507
|
+
|
|
508
|
+
``status_index`` uses an index reserved earlier instead of allocating one,
|
|
509
|
+
for a flow that decides revocability before it knows whether the credential
|
|
510
|
+
will ever be claimed. Passing it for a badge with no status list configured
|
|
511
|
+
is an error rather than a silent no-op. Pass ``credential_id`` alongside it
|
|
512
|
+
with the id that reservation was recorded under, or the registry will name
|
|
513
|
+
a credential that was never issued and the delivered one will be
|
|
514
|
+
unrevocable.
|
|
515
|
+
|
|
516
|
+
Returns a :class:`CredentialResult`; raises :class:`IssuanceError`.
|
|
517
|
+
"""
|
|
518
|
+
from .oid4vci.formats import (FORMAT_JWT_VC_JSON, FORMAT_SD_JWT_VC,
|
|
519
|
+
OID4VCI_FORMATS, REVOCABLE_FORMATS)
|
|
520
|
+
|
|
521
|
+
if credential_format not in OID4VCI_FORMATS:
|
|
522
|
+
raise IssuanceError(
|
|
523
|
+
"credential_format must be one of %s, got %r"
|
|
524
|
+
% (', '.join(OID4VCI_FORMATS), credential_format))
|
|
525
|
+
|
|
526
|
+
badge_obj = _badge_from_conf(conf, badge)
|
|
527
|
+
# Pin the proof format to vc-jwt: the badge section's own setting governs
|
|
528
|
+
# the baked-image path, and a section configured for ldp must not divert
|
|
529
|
+
# this one into a Data Integrity document.
|
|
530
|
+
ctx = _ob3_setup(conf, badge, badge_obj, 'vc-jwt')
|
|
531
|
+
|
|
532
|
+
if ctx.status_conf is not None and credential_format not in REVOCABLE_FORMATS:
|
|
533
|
+
# Refuse rather than drop the status: issuing a credential the issuer
|
|
534
|
+
# believes is revocable, and which silently is not, is the failure mode
|
|
535
|
+
# #226 already closed on the SD-JWT path. Catch it here so the caller
|
|
536
|
+
# learns before a wallet is waiting.
|
|
537
|
+
raise IssuanceError(
|
|
538
|
+
"[%s] configures status_lists, but %s cannot carry credential "
|
|
539
|
+
"status, so the badge would be irrevocable. Issue it as %s, or "
|
|
540
|
+
"drop status_lists from that section."
|
|
541
|
+
% (badge, credential_format, FORMAT_JWT_VC_JSON))
|
|
542
|
+
if status_index is not None and ctx.status_conf is None:
|
|
543
|
+
raise IssuanceError(
|
|
544
|
+
"a status_index was reserved for [%s], but that section configures "
|
|
545
|
+
"no status_lists" % badge)
|
|
546
|
+
|
|
547
|
+
credential = _build_ob3_credential(ctx, recipient, evidence, expires,
|
|
548
|
+
credential_id)
|
|
549
|
+
holder_did = None
|
|
550
|
+
if holder_jwk is not None:
|
|
551
|
+
holder_did = _bind_holder(credential, recipient, holder_jwk)
|
|
552
|
+
|
|
553
|
+
if status_index is not None:
|
|
554
|
+
_stamp_status(credential, ctx.status_conf, status_index)
|
|
555
|
+
else:
|
|
556
|
+
status_index = _allocate_status_batch([credential], ctx.status_conf)[0]
|
|
557
|
+
|
|
558
|
+
assert badge_obj.privkey_pem is not None
|
|
559
|
+
notices: List[str] = []
|
|
560
|
+
if credential_format == FORMAT_SD_JWT_VC:
|
|
561
|
+
token = _issue_sd_jwt_token(conf, credential, badge_obj, holder_jwk)
|
|
562
|
+
else:
|
|
563
|
+
token = _issue_jwt_vc_token(credential, badge_obj, ctx)
|
|
564
|
+
|
|
565
|
+
return CredentialResult(
|
|
566
|
+
credential_format=credential_format, token=token,
|
|
567
|
+
credential=credential, jti=credential.id, status_index=status_index,
|
|
568
|
+
holder_did=holder_did, notices=notices)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _issue_jwt_vc_token(credential: Any, badge_obj: Badge,
|
|
572
|
+
ctx: _Ob3Context) -> str:
|
|
573
|
+
"""Sign a credential into a compact JWT-VC (OID4VCI ``jwt_vc_json``)."""
|
|
574
|
+
from .ob3 import OB3Signer
|
|
575
|
+
algorithm = alg_for_key_type(ctx.key_type)
|
|
576
|
+
signer = OB3Signer(privkey_pem=badge_obj.privkey_pem, algorithm=algorithm)
|
|
577
|
+
try:
|
|
578
|
+
return signer.sign(credential)
|
|
579
|
+
except ErrorSigningFile as exc:
|
|
580
|
+
raise IssuanceError(str(exc)) from exc
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _issue_sd_jwt_token(conf: configparser.ConfigParser, credential: Any,
|
|
584
|
+
badge_obj: Badge,
|
|
585
|
+
holder_jwk: Optional[dict[str, Any]]) -> str:
|
|
586
|
+
"""Issue a credential as an SD-JWT VC (OID4VCI ``vc+sd-jwt``).
|
|
587
|
+
|
|
588
|
+
Uses the issuer's own ``[issuer] sd_jwt_vct`` when configured — the vct
|
|
589
|
+
whose Type Metadata openbadges-publish writes — so a wallet resolving the
|
|
590
|
+
type reaches this issuer's document rather than the generic imsglobal one.
|
|
591
|
+
"""
|
|
592
|
+
from .ob3.eudi import OB3_SD_JWT_VCT, EudiError, issue_badge_sd_jwt
|
|
593
|
+
vct = (conf['issuer'].get('sd_jwt_vct') or '').strip() or OB3_SD_JWT_VCT
|
|
594
|
+
try:
|
|
595
|
+
return issue_badge_sd_jwt(credential, privkey_pem=badge_obj.privkey_pem,
|
|
596
|
+
holder_jwk=holder_jwk, vct=vct)
|
|
597
|
+
except EudiError as exc:
|
|
598
|
+
raise IssuanceError(str(exc)) from exc
|
|
599
|
+
|
|
600
|
+
|
|
416
601
|
def _issue_ob3(conf: configparser.ConfigParser, badge: str, recipient: str,
|
|
417
602
|
badge_obj: Badge, evidence: Optional[str], expires: Optional[int],
|
|
418
603
|
proof_format: Optional[str]) -> SignResult:
|
|
@@ -31,8 +31,9 @@ from .status import check_credential_status
|
|
|
31
31
|
from .status_list import (build_status_list_credential, encode_bitstring,
|
|
32
32
|
sign_status_list_credential, status_entry)
|
|
33
33
|
from .status_registry import StatusRegistry
|
|
34
|
-
from .did import (build_did_document,
|
|
35
|
-
multikey_from_pem, resolve_did,
|
|
34
|
+
from .did import (build_did_document, did_jwk_from_jwk, did_key_from_pem,
|
|
35
|
+
did_web_from_url, multikey_from_pem, resolve_did,
|
|
36
|
+
resolve_verification_method)
|
|
36
37
|
from .publish import (AmbiguousCredential, CredentialNotFound, PublishError,
|
|
37
38
|
PublishResult, StatusOperation, publish_ob3)
|
|
38
39
|
from ..util import CachingDownloader
|
|
@@ -69,6 +70,7 @@ __all__ = [
|
|
|
69
70
|
'build_did_document',
|
|
70
71
|
'build_status_list_credential',
|
|
71
72
|
'check_credential_status',
|
|
73
|
+
'did_jwk_from_jwk',
|
|
72
74
|
'did_key_from_pem',
|
|
73
75
|
'did_web_from_url',
|
|
74
76
|
'encode_bitstring',
|