openbadgeslib 4.0.0__tar.gz → 4.1.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.0.0 → openbadgeslib-4.1.0}/Changelog.txt +128 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/PKG-INFO +4 -4
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/README.md +1 -1
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/__init__.py +24 -23
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/config.ini.example +10 -4
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/keys.py +50 -24
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob2/verifier.py +42 -9
- openbadgeslib-4.1.0/openbadgeslib/ob3/eudi.py +707 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/status_registry.py +17 -2
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/openbadges_publish.py +5 -13
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/openbadges_verifier.py +18 -9
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/util.py +1 -1
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib.egg-info/PKG-INFO +4 -4
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib.egg-info/SOURCES.txt +1 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib.egg-info/requires.txt +2 -2
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/pyproject.toml +12 -2
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_cli_json.py +17 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_docs.py +55 -1
- openbadgeslib-4.1.0/tests/test_keys.py +189 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob2_verifier.py +39 -0
- openbadgeslib-4.1.0/tests/test_ob3_contexts.py +162 -0
- openbadgeslib-4.1.0/tests/test_ob3_eudi_oid4vci.py +133 -0
- openbadgeslib-4.1.0/tests/test_ob3_eudi_sd_jwt.py +466 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_eudi_x5c.py +52 -1
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_status_registry.py +21 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_public_api.py +18 -2
- openbadgeslib-4.0.0/openbadgeslib/ob3/eudi.py +0 -398
- openbadgeslib-4.0.0/tests/test_keys.py +0 -63
- openbadgeslib-4.0.0/tests/test_ob3_contexts.py +0 -87
- openbadgeslib-4.0.0/tests/test_ob3_eudi_sd_jwt.py +0 -206
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/CONTRIBUTING.md +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/GOVERNANCE.md +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/LICENSE.txt +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/MANIFEST.in +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/SECURITY.md +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/docs/README.md +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/_jsonmodel.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/_jws/__init__.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/_jws/exceptions.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/_jws/utils.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/_ob1_api.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/badge_model.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/baking.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/cli_common.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/confparser.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/errors.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/issue.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/logs.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/mail.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob1/__init__.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob1/badge.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob1/signer.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob1/verifier.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob2/__init__.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob2/models.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob2/signer.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/__init__.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/contexts/__init__.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/contexts/ob-v3p0-extensions.json +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/credential.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/did.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/ldp.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/publish.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/signer.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/status.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/status_list.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/ob3/verifier.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/openbadges_cli.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/openbadges_init.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/openbadges_keygenerator.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/openbadges_signer.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/openbadges_status.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/py.typed +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib/verify.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib.egg-info/dependency_links.txt +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib.egg-info/entry_points.txt +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/openbadgeslib.egg-info/top_level.txt +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/setup.cfg +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/config1.ini +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/conformance/Dockerfile.ob3 +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/conformance/README.md +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/conformance/conftest.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/conformance/docker-compose.yml +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/conformance/test_official_validators.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/conftest.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/fixtures/ob_schemas/README.md +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/fixtures/ob_schemas/refresh.sh +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/fixtures/vc_di_eddsa/README.md +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/images/sample1.png +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/images/sample1.svg +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/images/userimage01.svg +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/ldp_helpers.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/logo Python Espan/314/203a.svg" +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/logo Python Espa/303/261a.svg" +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_baking.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_cli_smoke.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_confparser.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_eddsa.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_errors.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_examples.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_issue.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_jsonmodel.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_jws.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_key_operation.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_mail.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob1_badge_io.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob1_deprecation.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob1_signer.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob1_verifier.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob2_cli.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob2_error_branches.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob2_models.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob2_signer.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_conformance_schema.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_credential.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_did.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_did_doc.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_endorsement.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_eudi_type_metadata.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_ldp.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_ldp_cli.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_ldp_ecdsa_sd.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_ldp_errors.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_ldp_sign.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_publish_cli.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_signer.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_signer_cli_ldp.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_status.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_status_list.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_ob3_verifier.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_openbadges_cli.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_sign_ecc.pem +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_sign_rsa.pem +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_signer_batch.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_util.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_verifier_cli_coverage.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_verify.py +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_verify_ecc.pem +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/test_verify_rsa.pem +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/withoutxmlheader.svg +0 -0
- {openbadgeslib-4.0.0 → openbadgeslib-4.1.0}/tests/withxmlheader.svg +0 -0
|
@@ -4,6 +4,134 @@ OpenBadgesLib - Changelog
|
|
|
4
4
|
Newest first. Dates are ISO 8601 (YYYY-MM-DD).
|
|
5
5
|
|
|
6
6
|
|
|
7
|
+
* v4.1.0 - 2026-07-27
|
|
8
|
+
|
|
9
|
+
The EUDI SD-JWT VC track becomes revocable (IETF Token Status List) and
|
|
10
|
+
advertisable over OpenID4VCI, on top of a security & correctness hardening
|
|
11
|
+
pass (audit-bot).
|
|
12
|
+
|
|
13
|
+
- fix(build): stop git's EOL conversion from defeating the bundled contexts'
|
|
14
|
+
recorded SHA-256 provenance on Windows. Without a `.gitattributes` entry git
|
|
15
|
+
checks the JSON-LD contexts out with CRLF on a Windows clone, so the bytes on
|
|
16
|
+
disk are not the bytes the digests in ob3/contexts/__init__.py cover — the
|
|
17
|
+
control that makes the fail-closed context allowlist meaningful could not be
|
|
18
|
+
evaluated on that platform at all, and the `test (windows)` CI leg had been
|
|
19
|
+
red since the gate landed (which also blocks releases: `publish` is
|
|
20
|
+
`needs: test`). The contexts are now marked `-text`, and a gate asserts that
|
|
21
|
+
attribute stays. Wheel/sdist installs were never affected (#274).
|
|
22
|
+
- fix(keys): a failed PEM load lands in the library's exception taxonomy on
|
|
23
|
+
every supported `cryptography`, and the floor moves to >=45. cryptography
|
|
24
|
+
v47 changed the contract — loading a key of an unsupported algorithm now
|
|
25
|
+
raises `UnsupportedAlgorithm`, which is NOT a ValueError — while
|
|
26
|
+
`KeyRSA`/`KeyECC.read_private_key`/`read_public_key` and the Ed25519 loaders
|
|
27
|
+
caught nothing at all, so a corrupt or unsupported key PEM escaped as a raw
|
|
28
|
+
crypto traceback (the `Badge` entry point was already guarded; these direct
|
|
29
|
+
callers were not). All PEM loading now goes through one guarded helper that
|
|
30
|
+
maps both contracts onto `PrivateKeyReadError` / `PublicKeyReadError`,
|
|
31
|
+
keeping the original exception as `__cause__`; `detect_key_type` /
|
|
32
|
+
`ec_curve_from_pem` stay total. `KeyRSA.generate_keypair` maps the v48
|
|
33
|
+
1024-bit floor to `GenPrivateKeyError` instead of a raw ValueError. The
|
|
34
|
+
floor is 45 (openvc-core's own pin, so the extras resolve one range) rather
|
|
35
|
+
than 47: both contracts are handled and tested, so requiring an April-2026
|
|
36
|
+
release would buy nothing (#244).
|
|
37
|
+
- feat(eudi): build the OpenID4VCI credential configuration entry for an Open
|
|
38
|
+
Badge issued as SD-JWT VC. `badge_credential_configuration()` returns the
|
|
39
|
+
`credential_configurations_supported` entry a wallet reads to request the
|
|
40
|
+
badge — `format: dc+sd-jwt`, the `vct`, signing/proof algorithms (ES256, the
|
|
41
|
+
HAIP floor), `cryptographic_binding_methods_supported`, `claims` and
|
|
42
|
+
`display` — derived from the same `badge_type_metadata()` document the
|
|
43
|
+
issuer serves at that `vct`, so the advertised claim set and the served Type
|
|
44
|
+
Metadata cannot drift apart (the drift makes a wallet reject the credential
|
|
45
|
+
or display nothing). Pure-Python, no `[eudi]` extra. The enclosing
|
|
46
|
+
`/.well-known/openid-credential-issuer` document, the Credential Offer and
|
|
47
|
+
the endpoints stay the application's: they are deployment policy, one per
|
|
48
|
+
tenant (#271).
|
|
49
|
+
- feat(eudi): SD-JWT VC badges are revocable, via the IETF OAuth Token Status
|
|
50
|
+
List. `issue_badge_sd_jwt(..., status=...)` takes the reference
|
|
51
|
+
`openvc.status.issue.build_token_status_reference(uri=…, index=…)` builds and
|
|
52
|
+
emits it as a `status` claim that is **always disclosed** — never selectively
|
|
53
|
+
disclosable, whatever `disclosable=` says, since a holder able to withhold
|
|
54
|
+
the pointer could present a revoked badge as an unrevokable one. On the
|
|
55
|
+
verify side both trust paths (pinned key and x5c) now take
|
|
56
|
+
`require_status` / `resolve_status_list_token`: a badge whose bit says
|
|
57
|
+
revoked or suspended raises `EudiError`, and every way of not knowing the
|
|
58
|
+
status fails closed — an unresolvable, wrongly-signed, expired or
|
|
59
|
+
URL-swapped list raises rather than reading as "not revoked". The new
|
|
60
|
+
`status_list_token_resolver(pubkey_pem=…)` builds the safe default resolver
|
|
61
|
+
(HTTPS fetch + openvc-core's `verify_status_list_token`, including the IETF
|
|
62
|
+
anti-swap `sub` check). Encoding, signing and packing stay openvc-core's;
|
|
63
|
+
the `[eudi]` floor moves to >=1.23. This closes the #226 irrevocability
|
|
64
|
+
boundary for this track. Two formats, two lists: a credential carrying a
|
|
65
|
+
W3C `credentialStatus` (Bitstring) is still refused at SD-JWT issuance
|
|
66
|
+
rather than silently mapped onto a different wire format — a badge issued
|
|
67
|
+
on both tracks needs an index in each list (#245, #270).
|
|
68
|
+
- security(ob2): make the fetched copy of a HostedBadge authoritative for the
|
|
69
|
+
whole document. `_verify_hosted` reconciled only `id`, `recipient`, `badge`,
|
|
70
|
+
`issuedOn` and `expires` and then kept the copy baked into the image, so
|
|
71
|
+
`image`, `evidence` and `narrative` stayed holder-controlled on a badge
|
|
72
|
+
reported valid AND trusted — and the baked JWS is deliberately non-gating for
|
|
73
|
+
a hosted badge, so re-baking costs the holder nothing. `verify()` now returns
|
|
74
|
+
the assertion parsed from the HTTPS fetch, and the non-gating JWS check
|
|
75
|
+
resolves `verification.creator` from that copy instead of a holder-chosen
|
|
76
|
+
URL. The equality checks stay (they still catch a local copy claiming an `id`
|
|
77
|
+
that hosts something else). Narrow behaviour change: the served document must
|
|
78
|
+
itself parse as a strict OB 2.0 Assertion — it is the assertion now (#257).
|
|
79
|
+
- fix(cli): never report `"trusted": true` for a badge that failed to verify.
|
|
80
|
+
`openbadges-verifier --json` seeded the OB2/OB3 result with `trusted` True
|
|
81
|
+
before verifying anything, so every failure emitted it next to
|
|
82
|
+
`"valid": false`; the OB1 path seeded no `trusted` at all and set it only on
|
|
83
|
+
a successful parse, so a malformed OB1 badge emitted a payload missing the
|
|
84
|
+
documented common field entirely. All three paths (and the missing-file
|
|
85
|
+
early exit) now seed both booleans False and raise them only on a verdict,
|
|
86
|
+
so `trusted` is always present and `trusted => valid` holds. Exit statuses
|
|
87
|
+
are unchanged — they already keyed on `valid` first (#258).
|
|
88
|
+
- ci: derive the openvc-core floor the `eudi` job tests from pyproject.toml
|
|
89
|
+
instead of restating it. The pin drifted: 677f4ed raised the [eudi]/[ldp-sd]
|
|
90
|
+
floors to >=1.21 and touched only pyproject.toml, so the job's authoritative
|
|
91
|
+
(non-continue-on-error) `floor` leg kept installing openvc-core 1.18 —
|
|
92
|
+
downgrading below the package's own constraint and leaving the version users
|
|
93
|
+
actually resolve at the bottom of the range tested by nothing. A gate in
|
|
94
|
+
tests/test_docs.py now fails if a literal pin comes back (#264).
|
|
95
|
+
- fix(build): stop the documented conda setup from reinstating the dropped
|
|
96
|
+
pycryptodome / python-ecdsa. `environment.yml` still listed both as runtime
|
|
97
|
+
dependencies although the #167 port moved every key path to `cryptography`
|
|
98
|
+
and v4.0.0 removed the last soft-import shim — so `conda env create` brought
|
|
99
|
+
back a package the project dropped partly for its permanent pip-audit flag
|
|
100
|
+
(CVE-2024-23342, "Minerva"), one the weekly Audit job believes is gone. Its
|
|
101
|
+
`pyjwt` floor was also stale at >=2.8, below the >=2.13 security floor. Both
|
|
102
|
+
corrected, and a gate in tests/test_docs.py now fails if environment.yml's
|
|
103
|
+
runtime block and the pyproject dependencies ever disagree again (#265).
|
|
104
|
+
- security(ob3): enforce the bundled JSON-LD contexts against their recorded
|
|
105
|
+
SHA-256 provenance. The module header of ob3/contexts/__init__.py records a
|
|
106
|
+
digest per pinned @context — the control that makes the fail-closed
|
|
107
|
+
allowlist meaningful, since RDF canonicalization resolves each context and
|
|
108
|
+
the bundled document therefore decides what a Data Integrity proof covers —
|
|
109
|
+
but nothing checked it: the scheduled context-drift job compares against
|
|
110
|
+
UPSTREAM over the network, a different property that cannot run on a PR. Two
|
|
111
|
+
offline gates now assert every bundled file matches its digest and that no
|
|
112
|
+
context ships without one (#266).
|
|
113
|
+
- fix(ob3): raise RegistryCorrupt, not a raw AttributeError, for a status
|
|
114
|
+
registry whose `entries` is not a JSON object. `StatusRegistry.load` guarded
|
|
115
|
+
(KeyError, TypeError, ValueError) but called `.items()` on the value
|
|
116
|
+
unchecked, so `"entries": []` (or a string) escaped as an AttributeError
|
|
117
|
+
past every caller — openbadges-publish, `openbadges status` and the signer
|
|
118
|
+
all trap StatusError/LibOpenBadgesException — turning a corrupt-registry
|
|
119
|
+
message into a traceback. The registry object and its `entries` are now
|
|
120
|
+
shape-checked with named messages, and AttributeError joined the caught
|
|
121
|
+
tuple as belt and braces (#259).
|
|
122
|
+
- docs(api): publish the issuance/verification facades in the API reference.
|
|
123
|
+
`issue_from_conf`, `verify_badge`, `IssuanceError` and `SignResult` were
|
|
124
|
+
listed in the top-level `__all__` "for pdoc" but resolved lazily through
|
|
125
|
+
`__getattr__`, and pdoc reads the module dict — so it warned that each was
|
|
126
|
+
unresolvable and published a content-free stub with no signature, type or
|
|
127
|
+
docstring, while `openbadgeslib.issue` / `.verify` got no page at all. The
|
|
128
|
+
recommended modern API was therefore absent from the canonical reference. All
|
|
129
|
+
four are now imported eagerly and both modules are exported (additions to
|
|
130
|
+
`__all__`, so no contract is broken), which costs ~1.7 ms on an ~80 ms import
|
|
131
|
+
that already pulls ob2/ob3/keys. A gate in tests/test_public_api.py fails if
|
|
132
|
+
an `__all__` name becomes lazily-resolved again (#267).
|
|
133
|
+
|
|
134
|
+
|
|
7
135
|
* v4.0.0 - 2026-07-22
|
|
8
136
|
|
|
9
137
|
Grouped breaking release (#170) — freeze the declared public contract and
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openbadgeslib
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.1.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
|
|
@@ -31,12 +31,12 @@ Description-Content-Type: text/markdown
|
|
|
31
31
|
License-File: LICENSE.txt
|
|
32
32
|
Requires-Dist: pypng>=0.20220715.0
|
|
33
33
|
Requires-Dist: PyJWT[crypto]>=2.13
|
|
34
|
-
Requires-Dist: cryptography>=
|
|
34
|
+
Requires-Dist: cryptography>=45
|
|
35
35
|
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.23; extra == "eudi"
|
|
40
40
|
Provides-Extra: ldp-sd
|
|
41
41
|
Requires-Dist: openvc-core[data-integrity]<2,>=1.21; extra == "ldp-sd"
|
|
42
42
|
Provides-Extra: dev
|
|
@@ -66,7 +66,7 @@ with W3C Bitstring Status Lists and `did:web`. It also ships strict
|
|
|
66
66
|
**OpenBadges 2.0** (JWS / hosted assertions) and a frozen **OpenBadges 1.0**
|
|
67
67
|
legacy format, selected with `-V {1,2,3}` (default `3`).
|
|
68
68
|
|
|
69
|
-
> **
|
|
69
|
+
> **4.0.0 is released** (2026-07-22). It groups the breaking changes so upgrades stay rare: every CLI unifies on one `0/1/2` exit-code contract (a valid-but-untrusted badge now exits `2`, and the 255s are gone), and the legacy pycryptodome/python-ecdsa compat shim is removed — a live key **object** from either is no longer accepted, though key *files* are plain PEM and round-trip unchanged. OpenBadges 1.0 and the `openbadges-<command>` scripts stay, and a new unified `openbadges <command>` front-end joins them. **Python 3.10 is still supported** (`requires-python >= 3.10`, tested in CI on 3.10–3.14); dropping it is deferred to its end of life. See **[Upgrading to 4.0](https://github.com/luisgf/openbadgeslib/wiki/Upgrading-to-4.0)** before pinning a major.
|
|
70
70
|
|
|
71
71
|
## Features
|
|
72
72
|
|
|
@@ -13,7 +13,7 @@ with W3C Bitstring Status Lists and `did:web`. It also ships strict
|
|
|
13
13
|
**OpenBadges 2.0** (JWS / hosted assertions) and a frozen **OpenBadges 1.0**
|
|
14
14
|
legacy format, selected with `-V {1,2,3}` (default `3`).
|
|
15
15
|
|
|
16
|
-
> **
|
|
16
|
+
> **4.0.0 is released** (2026-07-22). It groups the breaking changes so upgrades stay rare: every CLI unifies on one `0/1/2` exit-code contract (a valid-but-untrusted badge now exits `2`, and the 255s are gone), and the legacy pycryptodome/python-ecdsa compat shim is removed — a live key **object** from either is no longer accepted, though key *files* are plain PEM and round-trip unchanged. OpenBadges 1.0 and the `openbadges-<command>` scripts stay, and a new unified `openbadges <command>` front-end joins them. **Python 3.10 is still supported** (`requires-python >= 3.10`, tested in CI on 3.10–3.14); dropping it is deferred to its end of life. See **[Upgrading to 4.0](https://github.com/luisgf/openbadgeslib/wiki/Upgrading-to-4.0)** before pinning a major.
|
|
17
17
|
|
|
18
18
|
## Features
|
|
19
19
|
|
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
from typing import Any
|
|
25
25
|
|
|
26
26
|
# ── Submodule entry points ───────────────────────────────────────────────────
|
|
27
|
-
# Bind ob2 / ob3 / errors as attributes so they are part of the
|
|
28
|
-
# surface (and `from openbadgeslib import *`): ob2 (strict
|
|
29
|
-
# incl. ob3.eudi and ob3.OB3Ldp*),
|
|
30
|
-
# LibOpenBadgesException.
|
|
31
|
-
from . import ob2, ob3, errors # noqa: F401
|
|
27
|
+
# Bind ob2 / ob3 / errors / issue / verify as attributes so they are part of the
|
|
28
|
+
# explicit public surface (and `from openbadgeslib import *`): ob2 (strict
|
|
29
|
+
# OB 2.0), ob3 (OB 3.0, incl. ob3.eudi and ob3.OB3Ldp*), the errors hierarchy
|
|
30
|
+
# rooted at LibOpenBadgesException, and the issuance/verification facades.
|
|
31
|
+
from . import ob2, ob3, errors, issue, verify # noqa: F401
|
|
32
32
|
|
|
33
33
|
# ── OpenBadges 2.0 (strict) ──────────────────────────────────────────────────
|
|
34
34
|
from .ob2 import ( # noqa: F401
|
|
@@ -55,13 +55,20 @@ from ._ob1_api import OB1_API as _OB1_API # noqa: F401
|
|
|
55
55
|
# ── Issuance / verification API ──────────────────────────────────────────────
|
|
56
56
|
# "Issue badge X to Y per config" / "verify this badge" as library calls — the
|
|
57
57
|
# orchestration the CLIs wrap, returning a SignResult / VerifyResult instead of
|
|
58
|
-
# doing I/O (openbadgeslib.issue, openbadgeslib.verify).
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
58
|
+
# doing I/O (openbadgeslib.issue, openbadgeslib.verify).
|
|
59
|
+
#
|
|
60
|
+
# Imported eagerly, unlike the OB1 names below. These were resolved lazily (PEP
|
|
61
|
+
# 562) to keep a bare `import openbadgeslib` light, and listed in __all__ "for
|
|
62
|
+
# pdoc" — but pdoc reads the module dict, so it could not resolve them and
|
|
63
|
+
# published each as a content-free stub with no signature, type or docstring,
|
|
64
|
+
# while openbadgeslib.issue / .verify got no page at all (#267). The laziness
|
|
65
|
+
# bought ~1.7 ms against an ~80 ms import that already pulls ob2, ob3 and keys
|
|
66
|
+
# (hence cryptography) — inside the run-to-run noise — so the documentation of
|
|
67
|
+
# the recommended modern API is worth more than it.
|
|
68
|
+
from .issue import ( # noqa: F401
|
|
69
|
+
IssuanceError, SignResult, issue_from_conf,
|
|
70
|
+
)
|
|
71
|
+
from .verify import verify_badge # noqa: F401
|
|
65
72
|
|
|
66
73
|
|
|
67
74
|
# ── OpenBadges 1.0 (legacy) ──────────────────────────────────────────────────
|
|
@@ -75,12 +82,6 @@ _ISSUE_API = {
|
|
|
75
82
|
|
|
76
83
|
|
|
77
84
|
def __getattr__(name: str) -> Any:
|
|
78
|
-
if name in _ISSUE_API:
|
|
79
|
-
# The modern issuance / verification API — lazy so a bare import stays
|
|
80
|
-
# lightweight, but warning-free (unlike the OB1 names below).
|
|
81
|
-
import importlib
|
|
82
|
-
return getattr(
|
|
83
|
-
importlib.import_module('.' + _ISSUE_API[name], __name__), name)
|
|
84
85
|
module = _OB1_API.get(name)
|
|
85
86
|
if module is None:
|
|
86
87
|
raise AttributeError(
|
|
@@ -99,12 +100,12 @@ def __getattr__(name: str) -> Any:
|
|
|
99
100
|
# The stable, supported top-level surface (the "public contract" — see the
|
|
100
101
|
# "Stable API vs internals" wiki/doc page). Everything else (the ob1.* legacy
|
|
101
102
|
# names resolved lazily above, and any underscore-prefixed name) is internal.
|
|
102
|
-
#
|
|
103
|
-
#
|
|
103
|
+
# Every name here is a real module attribute, so pdoc documents all of them —
|
|
104
|
+
# see tests/test_public_api.py, which fails if one becomes unresolvable again.
|
|
104
105
|
__all__ = [
|
|
105
106
|
'__version__',
|
|
106
|
-
# Subpackage entry points.
|
|
107
|
-
'ob2', 'ob3', 'errors',
|
|
107
|
+
# Subpackage / module entry points.
|
|
108
|
+
'ob2', 'ob3', 'errors', 'issue', 'verify',
|
|
108
109
|
# OpenBadges 2.0 (strict).
|
|
109
110
|
'OB2Signer', 'OB2Verifier', 'OB2VerificationError',
|
|
110
111
|
# OpenBadges 3.0.
|
|
@@ -112,6 +113,6 @@ __all__ = [
|
|
|
112
113
|
'OpenBadgeCredential', 'Achievement', 'Issuer',
|
|
113
114
|
# Keys (KeyEd25519 is the recommended OB 3.0 / LDP key type).
|
|
114
115
|
'KeyFactory', 'KeyRSA', 'KeyECC', 'KeyEd25519',
|
|
115
|
-
# Programmatic issue / verify facades
|
|
116
|
+
# Programmatic issue / verify facades.
|
|
116
117
|
'issue_from_conf', 'verify_badge', 'IssuanceError', 'SignResult',
|
|
117
118
|
]
|
|
@@ -45,6 +45,12 @@ revocationList = revoked.json
|
|
|
45
45
|
; issuer. 'auto': derive a did:web DID from publish_url (openbadges-publish
|
|
46
46
|
; -V 3 generates the matching did.json). An explicit did:web:... is used as-is.
|
|
47
47
|
;did = auto
|
|
48
|
+
; EUDI SD-JWT VC track (optional): an issuer-hosted vct URL, which must live
|
|
49
|
+
; under publish_url. openbadges-publish -V 3 then writes its SD-JWT VC Type
|
|
50
|
+
; Metadata into the published folder so wallets can resolve and validate
|
|
51
|
+
; SD-JWT badges. Unset: nothing is published and badges use the default
|
|
52
|
+
; imsglobal vct.
|
|
53
|
+
;sd_jwt_vct = ${publish_url}vct/openbadge
|
|
48
54
|
|
|
49
55
|
;Badge configuration sections.
|
|
50
56
|
[badge_1]
|
|
@@ -53,6 +59,10 @@ description = Given to any user that install this library
|
|
|
53
59
|
local_image = image_badge1.svg
|
|
54
60
|
image = https://www.issuer.badge/issuer/badge_1/badge1.svg
|
|
55
61
|
criteria = https://www.issuer.badge/issuer/badge_1/criteria.html
|
|
62
|
+
; OpenBadges 3.0 achievement criteria narrative (free text). Falls back to the
|
|
63
|
+
; 'criteria' URL above when unset, which is rarely what you want in a
|
|
64
|
+
; credential a human reads.
|
|
65
|
+
;criteria_narrative = Awarded to anyone who installs this library
|
|
56
66
|
verify_key = https://www.issuer.badge/issuer/badge_1/verify_rsa_key.pem
|
|
57
67
|
badge = https://www.issuer.badge/issuer/badge_1/badge.json
|
|
58
68
|
; OpenBadges 2.0 (-V 2): URL of this badge's CryptographicKey JSON-LD document
|
|
@@ -88,8 +98,6 @@ key_type = RSA
|
|
|
88
98
|
; lapses; pick a cadence you can sustain (e.g. 7 => weekly). Unset = no bound
|
|
89
99
|
; (offline-friendly, but publishes a loud warning).
|
|
90
100
|
;status_validity_days = 7
|
|
91
|
-
;alignement =
|
|
92
|
-
;tags =
|
|
93
101
|
;mail = ${paths:base}/badge_1_mail.txt
|
|
94
102
|
|
|
95
103
|
[badge_2]
|
|
@@ -105,6 +113,4 @@ hosted_assertions_base = https://www.issuer.badge/issuer/badge_2/assertions/
|
|
|
105
113
|
private_key = ${paths:base_key}/sign_rsa_key_2.pem
|
|
106
114
|
public_key = ${paths:base_key}/verify_rsa_key_2.pem
|
|
107
115
|
key_type = RSA
|
|
108
|
-
;alignement =
|
|
109
|
-
;tags =
|
|
110
116
|
;mail = ${paths:base}/badge_2_mail.txt
|
|
@@ -29,7 +29,9 @@
|
|
|
29
29
|
# ``key_to_pem`` now takes only ``cryptography`` key objects and PEM bytes/str.
|
|
30
30
|
|
|
31
31
|
from typing import Any, Optional, Tuple, Union, cast
|
|
32
|
-
from .errors import
|
|
32
|
+
from .errors import (
|
|
33
|
+
GenPrivateKeyError, PrivateKeyReadError, PublicKeyReadError, UnknownKeyType)
|
|
34
|
+
from cryptography.exceptions import UnsupportedAlgorithm
|
|
33
35
|
from cryptography.hazmat.primitives import serialization as _crypto_serialization
|
|
34
36
|
from cryptography.hazmat.primitives.asymmetric import ec, rsa
|
|
35
37
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
@@ -38,6 +40,32 @@ from enum import Enum
|
|
|
38
40
|
import logging
|
|
39
41
|
logger = logging.getLogger(__name__)
|
|
40
42
|
|
|
43
|
+
# What a failed PEM load raises depends on the `cryptography` version: up to v46
|
|
44
|
+
# an unsupported key algorithm came back as ValueError; **v47 changed the
|
|
45
|
+
# contract** to UnsupportedAlgorithm (which is NOT a ValueError). Our floor
|
|
46
|
+
# spans that change, so both are caught in one place and mapped onto the
|
|
47
|
+
# library's own taxonomy — a caller must never have to know which cryptography
|
|
48
|
+
# it resolved, and a raw crypto exception must never escape as a traceback.
|
|
49
|
+
_PEM_LOAD_ERRORS = (ValueError, TypeError, UnsupportedAlgorithm)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _load_private_pem(data: bytes) -> Any:
|
|
53
|
+
"""Load a private key PEM, mapping any load failure to PrivateKeyReadError."""
|
|
54
|
+
try:
|
|
55
|
+
return _crypto_serialization.load_pem_private_key(data, password=None)
|
|
56
|
+
except _PEM_LOAD_ERRORS as exc:
|
|
57
|
+
raise PrivateKeyReadError(
|
|
58
|
+
'Unable to read private key PEM: %s' % exc) from exc
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _load_public_pem(data: bytes) -> Any:
|
|
62
|
+
"""Load a public key PEM, mapping any load failure to PublicKeyReadError."""
|
|
63
|
+
try:
|
|
64
|
+
return _crypto_serialization.load_pem_public_key(data)
|
|
65
|
+
except _PEM_LOAD_ERRORS as exc:
|
|
66
|
+
raise PublicKeyReadError(
|
|
67
|
+
'Unable to read public key PEM: %s' % exc) from exc
|
|
68
|
+
|
|
41
69
|
|
|
42
70
|
class KeyType(Enum):
|
|
43
71
|
RSA = 'RSA 2048'
|
|
@@ -100,20 +128,26 @@ class KeyRSA(KeyBase):
|
|
|
100
128
|
|
|
101
129
|
def generate_keypair(self) -> Tuple[bytes, bytes]:
|
|
102
130
|
""" Generate a RSA Key, returning in PEM Format """
|
|
103
|
-
|
|
104
|
-
|
|
131
|
+
try:
|
|
132
|
+
self.priv_key = rsa.generate_private_key(
|
|
133
|
+
public_exponent=65537, key_size=self._key_size)
|
|
134
|
+
except (ValueError, UnsupportedAlgorithm) as exc:
|
|
135
|
+
# cryptography v48 enforces a 1024-bit floor on key_size; below it
|
|
136
|
+
# (and on any other refused parameter) this must be a library error,
|
|
137
|
+
# not a raw traceback out of the CLI.
|
|
138
|
+
raise GenPrivateKeyError(
|
|
139
|
+
'Unable to generate a %d-bit RSA key: %s'
|
|
140
|
+
% (self._key_size, exc)) from exc
|
|
105
141
|
self.pub_key = self.priv_key.public_key()
|
|
106
142
|
return self.get_priv_key_pem(), self.get_pub_key_pem()
|
|
107
143
|
|
|
108
144
|
def read_private_key(self, key_pem: Any = None) -> None:
|
|
109
145
|
""" Read the private key from param in PEM format """
|
|
110
|
-
self.priv_key =
|
|
111
|
-
_pem_bytes(key_pem), password=None)
|
|
146
|
+
self.priv_key = _load_private_pem(_pem_bytes(key_pem))
|
|
112
147
|
|
|
113
148
|
def read_public_key(self, key_pem: Any = None) -> None:
|
|
114
149
|
""" Read the public key from param in PEM format """
|
|
115
|
-
self.pub_key =
|
|
116
|
-
_pem_bytes(key_pem))
|
|
150
|
+
self.pub_key = _load_public_pem(_pem_bytes(key_pem))
|
|
117
151
|
|
|
118
152
|
def get_priv_key_pem(self) -> bytes:
|
|
119
153
|
return _private_pem(self.priv_key)
|
|
@@ -137,13 +171,11 @@ class KeyECC(KeyBase):
|
|
|
137
171
|
|
|
138
172
|
def read_private_key(self, key_pem: Any = None) -> None:
|
|
139
173
|
""" Read the private key from param in PEM format """
|
|
140
|
-
self.priv_key =
|
|
141
|
-
_pem_bytes(key_pem), password=None)
|
|
174
|
+
self.priv_key = _load_private_pem(_pem_bytes(key_pem))
|
|
142
175
|
|
|
143
176
|
def read_public_key(self, key_pem: Any = None) -> None:
|
|
144
177
|
""" Read the public key from param in PEM format """
|
|
145
|
-
self.pub_key =
|
|
146
|
-
_pem_bytes(key_pem))
|
|
178
|
+
self.pub_key = _load_public_pem(_pem_bytes(key_pem))
|
|
147
179
|
|
|
148
180
|
def get_priv_key_pem(self) -> bytes:
|
|
149
181
|
return _private_pem(self.priv_key)
|
|
@@ -155,7 +187,7 @@ class KeyECC(KeyBase):
|
|
|
155
187
|
def _load_ed25519_private_key(key_pem: Optional[Union[str, bytes]]) -> Ed25519PrivateKey:
|
|
156
188
|
if key_pem is None:
|
|
157
189
|
raise UnknownKeyType('No Ed25519 private key PEM provided')
|
|
158
|
-
key =
|
|
190
|
+
key = _load_private_pem(_pem_bytes(key_pem))
|
|
159
191
|
if not isinstance(key, Ed25519PrivateKey):
|
|
160
192
|
raise UnknownKeyType('PEM is not an Ed25519 private key')
|
|
161
193
|
return key
|
|
@@ -164,7 +196,7 @@ def _load_ed25519_private_key(key_pem: Optional[Union[str, bytes]]) -> Ed25519Pr
|
|
|
164
196
|
def _load_ed25519_public_key(key_pem: Optional[Union[str, bytes]]) -> Ed25519PublicKey:
|
|
165
197
|
if key_pem is None:
|
|
166
198
|
raise UnknownKeyType('No Ed25519 public key PEM provided')
|
|
167
|
-
key =
|
|
199
|
+
key = _load_public_pem(_pem_bytes(key_pem))
|
|
168
200
|
if not isinstance(key, Ed25519PublicKey):
|
|
169
201
|
raise UnknownKeyType('PEM is not an Ed25519 public key')
|
|
170
202
|
return key
|
|
@@ -249,7 +281,7 @@ def public_jwk_from_pem(pubkey_pem: Union[str, bytes]) -> dict[str, Any]:
|
|
|
249
281
|
import json
|
|
250
282
|
from jwt.algorithms import ECAlgorithm, OKPAlgorithm, RSAAlgorithm
|
|
251
283
|
try:
|
|
252
|
-
pub =
|
|
284
|
+
pub = _load_public_pem(_pem_bytes(pubkey_pem))
|
|
253
285
|
if isinstance(pub, rsa.RSAPublicKey):
|
|
254
286
|
jwk_json = RSAAlgorithm.to_jwk(pub)
|
|
255
287
|
elif isinstance(pub, ec.EllipticCurvePublicKey):
|
|
@@ -277,12 +309,9 @@ def ec_curve_from_pem(pem_data: Union[str, bytes]) -> Optional[str]:
|
|
|
277
309
|
fixes the JOSE algorithm.
|
|
278
310
|
"""
|
|
279
311
|
data = _pem_bytes(pem_data)
|
|
280
|
-
for load in (
|
|
281
|
-
lambda: _crypto_serialization.load_pem_public_key(data),
|
|
282
|
-
lambda: _crypto_serialization.load_pem_private_key(data, password=None),
|
|
283
|
-
):
|
|
312
|
+
for load in (_load_public_pem, _load_private_pem):
|
|
284
313
|
try:
|
|
285
|
-
key = load()
|
|
314
|
+
key = load(data)
|
|
286
315
|
except Exception:
|
|
287
316
|
continue
|
|
288
317
|
if isinstance(key, (ec.EllipticCurvePublicKey,
|
|
@@ -302,12 +331,9 @@ def detect_key_type(pem_data: Union[str, bytes]) -> 'KeyType':
|
|
|
302
331
|
"""
|
|
303
332
|
data = _pem_bytes(pem_data)
|
|
304
333
|
key: Any = None
|
|
305
|
-
for load in (
|
|
306
|
-
lambda: _crypto_serialization.load_pem_public_key(data),
|
|
307
|
-
lambda: _crypto_serialization.load_pem_private_key(data, password=None),
|
|
308
|
-
):
|
|
334
|
+
for load in (_load_public_pem, _load_private_pem):
|
|
309
335
|
try:
|
|
310
|
-
key = load()
|
|
336
|
+
key = load(data)
|
|
311
337
|
break
|
|
312
338
|
except Exception:
|
|
313
339
|
continue
|
|
@@ -60,7 +60,8 @@ class OB2Verifier:
|
|
|
60
60
|
* **HostedBadge** — the assertion is fetched over HTTPS from its own ``id``;
|
|
61
61
|
that retrieval (scoped to the issuer's origin) is the trust anchor, per
|
|
62
62
|
the OB 2.0 hosted model. The baked JWS is verified as non-gating
|
|
63
|
-
defence-in-depth only
|
|
63
|
+
defence-in-depth only, so the fetched document — not the baked one — is
|
|
64
|
+
what every check runs on and what ``verify()`` returns.
|
|
64
65
|
|
|
65
66
|
Args:
|
|
66
67
|
pubkey_pem: Optional PEM-encoded trusted public key. When supplied it is
|
|
@@ -86,7 +87,11 @@ class OB2Verifier:
|
|
|
86
87
|
"""Verify a compact-JWS OB 2.0 assertion token.
|
|
87
88
|
|
|
88
89
|
Returns the decoded :class:`~openbadgeslib.ob2.models.Assertion` on
|
|
89
|
-
success; raises :class:`OB2VerificationError` on any failure.
|
|
90
|
+
success; raises :class:`OB2VerificationError` on any failure. For a
|
|
91
|
+
HostedBadge the returned assertion is the one **fetched from its id**
|
|
92
|
+
(the trust anchor), not the copy baked into the image — so a member the
|
|
93
|
+
baked copy alone carried, such as ``evidence`` or ``narrative``, never
|
|
94
|
+
reaches the caller.
|
|
90
95
|
|
|
91
96
|
``expected_recipient`` (an email) additionally binds the assertion to a
|
|
92
97
|
recipient by re-hashing it with the embedded salt. ``check_revocation``
|
|
@@ -100,7 +105,10 @@ class OB2Verifier:
|
|
|
100
105
|
raise OB2VerificationError("Malformed OB 2.0 assertion: %s" % exc) from exc
|
|
101
106
|
|
|
102
107
|
if assertion.verification.type == "HostedBadge":
|
|
103
|
-
|
|
108
|
+
# The fetched copy is the trust anchor, so it replaces the local one:
|
|
109
|
+
# every check below (and the returned object) must speak for the
|
|
110
|
+
# document the issuer serves, not the one baked into the image.
|
|
111
|
+
assertion = self._verify_hosted(assertion, token)
|
|
104
112
|
else:
|
|
105
113
|
self._verify_signed(assertion, token)
|
|
106
114
|
|
|
@@ -164,14 +172,25 @@ class OB2Verifier:
|
|
|
164
172
|
|
|
165
173
|
# ── hosted path ──────────────────────────────────────────────────────────────
|
|
166
174
|
|
|
167
|
-
def _verify_hosted(self, assertion: Assertion, token: str) ->
|
|
168
|
-
"""Verify a HostedBadge assertion by fetching its ``id`` over HTTPS
|
|
175
|
+
def _verify_hosted(self, assertion: Assertion, token: str) -> Assertion:
|
|
176
|
+
"""Verify a HostedBadge assertion by fetching its ``id`` over HTTPS and
|
|
177
|
+
return the AUTHORITATIVE assertion parsed from that fetch.
|
|
169
178
|
|
|
170
179
|
The fetched document is the authoritative copy; its origin must fall
|
|
171
180
|
within the issuer's scope (default: same origin as the issuer Profile
|
|
172
181
|
``id``; or the issuer's ``verification.startsWith`` / ``allowedOrigins``
|
|
173
182
|
when declared). The baked JWS is verified only as non-gating
|
|
174
183
|
defence-in-depth.
|
|
184
|
+
|
|
185
|
+
Returning the fetched assertion — rather than reconciling a fixed list
|
|
186
|
+
of members and keeping the local one — is what makes "the hosted copy is
|
|
187
|
+
the trust anchor" true for the *whole* document. Only id/recipient/badge/
|
|
188
|
+
issuedOn/expires used to be compared, so ``image``, ``evidence`` and
|
|
189
|
+
``narrative`` stayed holder-controlled on a badge reported valid and
|
|
190
|
+
trusted (the baked JWS is non-gating here, so a re-baked local copy costs
|
|
191
|
+
the holder nothing). The equality checks below stay: they still catch a
|
|
192
|
+
local copy claiming an ``id`` that legitimately hosts something else.
|
|
193
|
+
Consequence: the served document must itself be a valid OB 2.0 Assertion.
|
|
175
194
|
"""
|
|
176
195
|
assert assertion.id is not None
|
|
177
196
|
fetched = self._fetch_json(assertion.id, "hosted assertion")
|
|
@@ -217,22 +236,36 @@ class OB2Verifier:
|
|
|
217
236
|
"Hosted assertion at %s does not match the badge's local "
|
|
218
237
|
"claims (field 'expires' differs)" % (assertion.id,))
|
|
219
238
|
|
|
220
|
-
|
|
239
|
+
# Everything reconciled: adopt the served document as the assertion this
|
|
240
|
+
# verification speaks for. It must parse as a strict OB 2.0 Assertion —
|
|
241
|
+
# it IS the assertion, not a copy of it.
|
|
242
|
+
try:
|
|
243
|
+
authoritative = Assertion.from_dict(fetched)
|
|
244
|
+
except ValueError as exc:
|
|
245
|
+
raise OB2VerificationError(
|
|
246
|
+
"Hosted assertion at %s is not a valid OpenBadges 2.0 Assertion: %s"
|
|
247
|
+
% (assertion.id, exc)) from exc
|
|
248
|
+
|
|
249
|
+
issuer = self._fetch_issuer(authoritative)
|
|
250
|
+
# assertion.id is the fetched id (proven equal above) and is known
|
|
251
|
+
# non-None, which authoritative.id is only by from_dict's contract.
|
|
221
252
|
self._check_hosted_scope(assertion.id, issuer)
|
|
222
253
|
|
|
223
254
|
# Defence-in-depth: if the baked token also carries a resolvable key,
|
|
224
255
|
# verify it, but never fail the hosted verdict on it (a hosted badge is
|
|
225
|
-
# not required to be signed at all).
|
|
256
|
+
# not required to be signed at all). The creator comes from the
|
|
257
|
+
# authoritative copy, so this never dereferences a holder-chosen URL.
|
|
226
258
|
try:
|
|
227
259
|
if self.trusted_pubkey_pem is not None:
|
|
228
260
|
self._verify_jws(token, self.trusted_pubkey_pem)
|
|
229
|
-
elif
|
|
230
|
-
key = self._resolve_creator(
|
|
261
|
+
elif authoritative.verification.creator:
|
|
262
|
+
key = self._resolve_creator(authoritative.verification.creator)
|
|
231
263
|
self._verify_jws(token, key.public_key_pem.encode('utf-8'))
|
|
232
264
|
except OB2VerificationError as exc:
|
|
233
265
|
logger.debug("Hosted assertion %s: baked JWS did not verify (%s); "
|
|
234
266
|
"hosted trust comes from the HTTPS fetch, not the signature.",
|
|
235
267
|
assertion.id, exc)
|
|
268
|
+
return authoritative
|
|
236
269
|
|
|
237
270
|
def _check_hosted_scope(self, assertion_id: str, issuer: dict[str, Any]) -> None:
|
|
238
271
|
"""Enforce the OB 2.0 hosted-verification scope for ``assertion_id``."""
|