openbadgeslib 4.4.3__tar.gz → 4.4.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/Changelog.txt +26 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/PKG-INFO +1 -1
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/baking.py +66 -1
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/confparser.py +9 -1
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/mail.py +21 -1
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/ldp.py +29 -1
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/errors.py +4 -1
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/handler.py +7 -4
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_signer.py +26 -4
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/util.py +81 -36
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib.egg-info/PKG-INFO +1 -1
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_baking.py +32 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_cli_smoke.py +26 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_mail.py +16 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_ldp_ecdsa_sd.py +64 -5
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_oid4vci_config.py +10 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_oid4vci_protocol.py +7 -2
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_util.py +26 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/CONTRIBUTING.md +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/GOVERNANCE.md +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/LICENSE.txt +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/MANIFEST.in +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/README.md +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/SECURITY.md +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/docs/README.md +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/__init__.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/_jsonmodel.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/_jws/__init__.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/_jws/exceptions.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/_jws/utils.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/_ob1_api.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/badge_model.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/cli_common.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/config.ini.example +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/errors.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/issue.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/keys.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/logs.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob1/__init__.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob1/badge.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob1/signer.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob1/verifier.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob2/__init__.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob2/models.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob2/signer.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob2/verifier.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/__init__.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/__init__.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/ob-v3p0-extensions.json +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/credential.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/did.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/eudi.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/publish.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/signer.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/status.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/status_list.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/status_registry.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/verifier.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/__init__.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/codes.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/formats.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/memory_store.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/metadata.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/nonce.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/offer.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/reconcile.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/sqlite_store.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/store.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_cli.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_init.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_keygenerator.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_publish.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_status.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_verifier.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/py.typed +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/verify.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib.egg-info/SOURCES.txt +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib.egg-info/dependency_links.txt +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib.egg-info/entry_points.txt +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib.egg-info/requires.txt +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib.egg-info/top_level.txt +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/pyproject.toml +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/setup.cfg +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/config1.ini +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/conformance/Dockerfile.ob3 +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/conformance/README.md +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/conformance/conftest.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/conformance/docker-compose.yml +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/conformance/test_official_validators.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/conftest.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/fixtures/ob_schemas/README.md +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/fixtures/ob_schemas/refresh.sh +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/fixtures/vc_di_eddsa/README.md +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/images/sample1.png +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/images/sample1.svg +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/images/userimage01.svg +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/ldp_helpers.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/logo Python Espan/314/203a.svg" +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/logo Python Espa/303/261a.svg" +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_cli_json.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_confparser.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_docs.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_eddsa.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_errors.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_examples.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_issue.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_issue_credential.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_jsonmodel.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_jws.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_key_operation.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_keys.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob1_badge_io.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob1_deprecation.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob1_signer.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob1_verifier.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob2_cli.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob2_error_branches.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob2_models.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob2_signer.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob2_verifier.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_conformance_schema.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_contexts.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_credential.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_did.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_did_doc.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_endorsement.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_eudi_oid4vci.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_eudi_sd_jwt.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_eudi_type_metadata.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_eudi_x5c.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_ldp.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_ldp_cli.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_ldp_errors.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_ldp_sign.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_publish_cli.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_signer.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_signer_cli_ldp.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_status.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_status_list.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_status_registry.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_verifier.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_oid4vci_reservations.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_oid4vci_store.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_openbadges_cli.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_public_api.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_sign_ecc.pem +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_sign_rsa.pem +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_signer_batch.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_verifier_cli_coverage.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_verify.py +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_verify_ecc.pem +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_verify_rsa.pem +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/withoutxmlheader.svg +0 -0
- {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/withxmlheader.svg +0 -0
|
@@ -4,6 +4,32 @@ OpenBadgesLib - Changelog
|
|
|
4
4
|
Newest first. Dates are ISO 8601 (YYYY-MM-DD).
|
|
5
5
|
|
|
6
6
|
|
|
7
|
+
* v4.4.4 - 2026-09-10
|
|
8
|
+
|
|
9
|
+
- security(baking): SVG baking strips ``<script>`` / ``foreignObject`` /
|
|
10
|
+
event-handler attributes and ``javascript:`` / hostile ``data:`` URLs
|
|
11
|
+
from the carrier so a source image cannot turn a baked badge into an
|
|
12
|
+
XSS gadget.
|
|
13
|
+
- security(ob3): ``ecdsa-sd-2023`` presentations that omit ``validUntil``
|
|
14
|
+
or ``credentialStatus`` fail closed — a holder can no longer hide expiry
|
|
15
|
+
or revocation behind selective disclosure.
|
|
16
|
+
- security(oid4vci): explicit ``credential_endpoint`` / ``token_endpoint``
|
|
17
|
+
/ ``nonce_endpoint`` overrides must be absolute ``https://`` URLs, not
|
|
18
|
+
plaintext HTTP.
|
|
19
|
+
- security(cli): ``signer.log`` is created and kept at mode ``0600``
|
|
20
|
+
(recipient emails); a file inherited from a permissive umask is
|
|
21
|
+
tightened on the next append.
|
|
22
|
+
- security(ci): wiki-sync authenticates clone/push with an HTTP extraheader
|
|
23
|
+
instead of embedding ``GITHUB_TOKEN`` in the git remote URL.
|
|
24
|
+
- security(mail): SMTP connections time out after 30 s; plaintext SMTP to
|
|
25
|
+
a non-loopback host is upgraded with STARTTLS (validating context).
|
|
26
|
+
- security(util): ``allow_insecure=True`` HTTP downloads pin the connect
|
|
27
|
+
IP the same way HTTPS already did (DNS rebinding).
|
|
28
|
+
- security(oid4vci): a live PIN-gated offer presented without ``tx_code``
|
|
29
|
+
returns the same ``invalid_grant`` as an unknown code, so the token
|
|
30
|
+
endpoint is not an oracle for which offers are PIN-gated.
|
|
31
|
+
|
|
32
|
+
|
|
7
33
|
* v4.4.3 - 2026-08-18
|
|
8
34
|
|
|
9
35
|
- security(oid4vci): an issuer that advertises
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openbadgeslib
|
|
3
|
-
Version: 4.4.
|
|
3
|
+
Version: 4.4.4
|
|
4
4
|
Summary: Production-ready Python library & CLI for Open Badges 3.0 (W3C Verifiable Credentials): issue, verify, and revoke JWT-VC credentials with Bitstring Status Lists and did:web — plus strict OB 2.0 and legacy OB 1.0.
|
|
5
5
|
Author-email: Luis González Fernández <luisgf@luisgf.es>, Jesús Cea Avión <jcea@jcea.es>
|
|
6
6
|
License: LGPLv3
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
from struct import pack
|
|
30
30
|
from zlib import crc32
|
|
31
31
|
|
|
32
|
-
from typing import List, Optional, Tuple, Union, cast
|
|
32
|
+
from typing import Any, List, Optional, Tuple, Union, cast
|
|
33
33
|
|
|
34
34
|
from defusedxml.minidom import parseString
|
|
35
35
|
from png import Reader, signature as _png_signature
|
|
@@ -76,6 +76,66 @@ def _bounded_inflate(data: bytes, limit: int = MAX_ITXT_DECOMPRESSED) -> bytes:
|
|
|
76
76
|
|
|
77
77
|
# ── SVG ─────────────────────────────────────────────────────────────────────
|
|
78
78
|
|
|
79
|
+
# Active content stripped from the carrier at bake time. A baked badge is an
|
|
80
|
+
# image a recipient may open in a browser; leaving <script>, on* handlers or
|
|
81
|
+
# javascript: URLs in the source SVG would execute in that context (#328).
|
|
82
|
+
# `opacity` is the one SVG presentation attribute whose local name starts
|
|
83
|
+
# with "on" and is not an event handler.
|
|
84
|
+
_ACTIVE_SVG_ELEMENTS = frozenset({
|
|
85
|
+
'script', 'foreignobject', 'iframe', 'embed', 'object', 'handler',
|
|
86
|
+
})
|
|
87
|
+
_URL_ATTRS = frozenset({'href', 'src', 'action'})
|
|
88
|
+
_SAFE_DATA_IMAGE_TYPES = (
|
|
89
|
+
'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _svg_local_name(node: Any) -> str:
|
|
94
|
+
return str(node.tagName).split(':')[-1].lower()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _iter_svg_elements(node: Any) -> List[Any]:
|
|
98
|
+
found: List[Any] = []
|
|
99
|
+
if getattr(node, 'nodeType', None) == node.ELEMENT_NODE:
|
|
100
|
+
found.append(node)
|
|
101
|
+
for child in list(node.childNodes):
|
|
102
|
+
found.extend(_iter_svg_elements(child))
|
|
103
|
+
return found
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _is_active_url(value: str) -> bool:
|
|
107
|
+
"""True for javascript:/vbscript: and for data: that is not a raster image."""
|
|
108
|
+
lowered = value.strip().lower()
|
|
109
|
+
if lowered.startswith('javascript:') or lowered.startswith('vbscript:'):
|
|
110
|
+
return True
|
|
111
|
+
if not lowered.startswith('data:'):
|
|
112
|
+
return False
|
|
113
|
+
payload = lowered[5:]
|
|
114
|
+
return not any(payload.startswith(kind) for kind in _SAFE_DATA_IMAGE_TYPES)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _strip_svg_active_content(root: Any) -> None:
|
|
118
|
+
"""Drop script-class elements, on* handlers and active URLs under *root*."""
|
|
119
|
+
if root is None:
|
|
120
|
+
return
|
|
121
|
+
for el in _iter_svg_elements(root):
|
|
122
|
+
if _svg_local_name(el) in _ACTIVE_SVG_ELEMENTS:
|
|
123
|
+
parent = el.parentNode
|
|
124
|
+
if parent is not None:
|
|
125
|
+
parent.removeChild(el)
|
|
126
|
+
for el in _iter_svg_elements(root):
|
|
127
|
+
attrs = el.attributes
|
|
128
|
+
if attrs is None:
|
|
129
|
+
continue
|
|
130
|
+
for name in list(attrs.keys()):
|
|
131
|
+
local = name.split(':')[-1].lower()
|
|
132
|
+
if local.startswith('on') and local != 'opacity':
|
|
133
|
+
el.removeAttribute(name)
|
|
134
|
+
continue
|
|
135
|
+
if local in _URL_ATTRS and _is_active_url(attrs[name].value):
|
|
136
|
+
el.removeAttribute(name)
|
|
137
|
+
|
|
138
|
+
|
|
79
139
|
def bake_svg(image_bytes: bytes, token: str, comment: Optional[str] = None, *,
|
|
80
140
|
element: str = SVG_ELEMENT, namespace: str = SVG_NS,
|
|
81
141
|
as_text: bool = False) -> bytes:
|
|
@@ -87,10 +147,15 @@ def bake_svg(image_bytes: bytes, token: str, comment: Optional[str] = None, *,
|
|
|
87
147
|
instead of the ``verify`` attribute — the OB 3.0 carrier for credentials
|
|
88
148
|
secured with a Data Integrity proof, whose payload is a JSON document
|
|
89
149
|
rather than a compact JWT (OB 3.0 §5.3).
|
|
150
|
+
|
|
151
|
+
Active content in the source SVG (``<script>``, event-handler attributes,
|
|
152
|
+
``javascript:`` / hostile ``data:`` URLs) is stripped before the token is
|
|
153
|
+
attached, so the baked carrier is not an XSS gadget (#328).
|
|
90
154
|
"""
|
|
91
155
|
svg_doc = parseString(image_bytes)
|
|
92
156
|
try:
|
|
93
157
|
svg_tag = svg_doc.getElementsByTagName('svg').item(0)
|
|
158
|
+
_strip_svg_active_content(svg_tag)
|
|
94
159
|
node = svg_doc.createElement(element)
|
|
95
160
|
node.attributes['xmlns:openbadges'] = namespace
|
|
96
161
|
if as_text:
|
|
@@ -305,7 +305,15 @@ def oid4vci_config(conf: ConfigParser) -> OID4VCIConfig:
|
|
|
305
305
|
|
|
306
306
|
def endpoint(key: str, default_path: str) -> str:
|
|
307
307
|
explicit = (section.get(key) or '').strip() if section else ''
|
|
308
|
-
|
|
308
|
+
url = explicit or urljoin(endpoint_base, default_path)
|
|
309
|
+
# Derived endpoints inherit https from credential_issuer; an explicit
|
|
310
|
+
# override can be any string, and a plaintext http URL would advertise
|
|
311
|
+
# a token/credential/nonce endpoint wallets POST secrets to (#328).
|
|
312
|
+
if not url.startswith('https://'):
|
|
313
|
+
raise ConfigError(
|
|
314
|
+
"[oid4vci] %s must be an absolute https URL, got %r"
|
|
315
|
+
% (key, url))
|
|
316
|
+
return url
|
|
309
317
|
|
|
310
318
|
store_path = (section.get('store_path') if section else None) or \
|
|
311
319
|
os.path.join(conf['paths']['base'], 'oid4vci.sqlite3')
|
|
@@ -33,6 +33,18 @@ from email.header import Header
|
|
|
33
33
|
from .badge_model import BadgeImgType # version-neutral model (was ob1.badge)
|
|
34
34
|
from .errors import BadgeImgFormatUnsupported
|
|
35
35
|
|
|
36
|
+
#: Per-operation socket timeout for SMTP connect/read/write. smtplib's
|
|
37
|
+
#: default is None (block forever).
|
|
38
|
+
SMTP_TIMEOUT_SECONDS = 30
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _smtp_is_loopback(host: str) -> bool:
|
|
42
|
+
"""True for the hostnames/addresses operators use for a local MTA."""
|
|
43
|
+
name = host.strip().lower()
|
|
44
|
+
if name.startswith('[') and name.endswith(']'):
|
|
45
|
+
name = name[1:-1]
|
|
46
|
+
return name in ('localhost', '127.0.0.1', '::1')
|
|
47
|
+
|
|
36
48
|
|
|
37
49
|
class BadgeMail():
|
|
38
50
|
def __init__(self, smtp_server: str = 'localhost', smtp_port: int = 25,
|
|
@@ -83,9 +95,17 @@ class BadgeMail():
|
|
|
83
95
|
# CERT_NONE), which would let an on-path attacker intercept the
|
|
84
96
|
# connection and capture the AUTH credentials sent below.
|
|
85
97
|
smtp = SMTP_SSL(self.smtp_server, self.smtp_port,
|
|
98
|
+
timeout=SMTP_TIMEOUT_SECONDS,
|
|
86
99
|
context=ssl.create_default_context())
|
|
87
100
|
else:
|
|
88
|
-
smtp = SMTP(self.smtp_server, self.smtp_port
|
|
101
|
+
smtp = SMTP(self.smtp_server, self.smtp_port,
|
|
102
|
+
timeout=SMTP_TIMEOUT_SECONDS)
|
|
103
|
+
# Plain SMTP to a non-loopback host is upgraded with STARTTLS
|
|
104
|
+
# so the message (and any later AUTH) is not sent in the
|
|
105
|
+
# clear. localhost is left alone: a local MTA often has no
|
|
106
|
+
# TLS listener on :25 (#328).
|
|
107
|
+
if not _smtp_is_loopback(self.smtp_server):
|
|
108
|
+
smtp.starttls(context=ssl.create_default_context())
|
|
89
109
|
|
|
90
110
|
if self.username:
|
|
91
111
|
smtp.login(self.username, self.password)
|
|
@@ -453,6 +453,29 @@ def _reject_unsigned_ldp_aliases(doc: dict[str, Any]) -> None:
|
|
|
453
453
|
"not sign; use BitstringStatusListEntry")
|
|
454
454
|
|
|
455
455
|
|
|
456
|
+
def _reject_hidden_sd_lifecycle(doc: dict[str, Any], proof: dict[str, Any]) -> None:
|
|
457
|
+
"""Fail closed when an ecdsa-sd-2023 presentation omits lifecycle claims.
|
|
458
|
+
|
|
459
|
+
Selective disclosure lets a holder drop statements the issuer signed.
|
|
460
|
+
``validUntil`` and ``credentialStatus`` are the ones that bound a badge's
|
|
461
|
+
life: hiding them makes an expired or revoked credential look perpetual.
|
|
462
|
+
eddsa-rdfc-2022 signs the whole document, so this only applies to
|
|
463
|
+
ecdsa-sd-2023. The low-level ``verify_data_integrity_proof`` does not
|
|
464
|
+
enforce it (it checks the proof alone); ``OB3LdpVerifier.verify`` does
|
|
465
|
+
(#328).
|
|
466
|
+
"""
|
|
467
|
+
if proof.get('cryptosuite') != 'ecdsa-sd-2023':
|
|
468
|
+
return
|
|
469
|
+
if 'validUntil' not in doc:
|
|
470
|
+
raise OB3VerificationError(
|
|
471
|
+
"ecdsa-sd-2023 presentation omitted validUntil, which a holder "
|
|
472
|
+
"could hide to un-expire the badge; disclose validUntil")
|
|
473
|
+
if 'credentialStatus' not in doc:
|
|
474
|
+
raise OB3VerificationError(
|
|
475
|
+
"ecdsa-sd-2023 presentation omitted credentialStatus, which a "
|
|
476
|
+
"holder could hide to un-revoke the badge; disclose credentialStatus")
|
|
477
|
+
|
|
478
|
+
|
|
456
479
|
class OB3LdpVerifier:
|
|
457
480
|
"""Verifies OpenBadges 3.0 credentials secured with a Data Integrity
|
|
458
481
|
(Linked Data Proof) embedded proof — cryptosuites eddsa-rdfc-2022 and,
|
|
@@ -511,7 +534,9 @@ class OB3LdpVerifier:
|
|
|
511
534
|
own signature is verified too (reusing a pinned key, or resolving the
|
|
512
535
|
issuer DID), unless ``verify_status_list=False``. ``download`` resolves
|
|
513
536
|
a did:web verificationMethod and status list issuer DID (injectable for
|
|
514
|
-
testing).
|
|
537
|
+
testing). An ``ecdsa-sd-2023`` presentation that omits ``validUntil``
|
|
538
|
+
or ``credentialStatus`` is rejected so a holder cannot hide expiry or
|
|
539
|
+
revocation (#328).
|
|
515
540
|
"""
|
|
516
541
|
doc = self._parse_document(document)
|
|
517
542
|
|
|
@@ -528,6 +553,9 @@ class OB3LdpVerifier:
|
|
|
528
553
|
|
|
529
554
|
proof = _select_proof(doc)
|
|
530
555
|
_validate_proof(proof, 'assertionMethod')
|
|
556
|
+
# Before resolving keys or running crypto: an SD presentation that hid
|
|
557
|
+
# expiry/revocation is already a fail, regardless of the proof.
|
|
558
|
+
_reject_hidden_sd_lifecycle(doc, proof)
|
|
531
559
|
|
|
532
560
|
vm = proof['verificationMethod']
|
|
533
561
|
vm_did = vm.partition('#')[0]
|
|
@@ -76,7 +76,10 @@ INVALID_NONCE = 'invalid_nonce'
|
|
|
76
76
|
INVALID_ENCRYPTION_PARAMETERS = 'invalid_encryption_parameters'
|
|
77
77
|
#: The pre-authorized code was unknown, expired, already redeemed or killed.
|
|
78
78
|
INVALID_GRANT = 'invalid_grant'
|
|
79
|
-
#: The token request itself was malformed (
|
|
79
|
+
#: The token request itself was malformed (bad grant type, empty code).
|
|
80
|
+
#: A live PIN-gated offer missing tx_code is invalid_grant, not this — the
|
|
81
|
+
#: distinct invalid_request was an oracle that the offer exists and is
|
|
82
|
+
#: PIN-gated (#328).
|
|
80
83
|
INVALID_REQUEST = 'invalid_request'
|
|
81
84
|
#: The access token was missing, unknown or expired. Served as HTTP 401 with a
|
|
82
85
|
#: ``WWW-Authenticate: Bearer`` header, per RFC 6750 — not 400.
|
|
@@ -132,10 +132,11 @@ def handle_token_request(conf: configparser.ConfigParser, *, code: str,
|
|
|
132
132
|
asking them to rebuild the part that is easy to get wrong.
|
|
133
133
|
|
|
134
134
|
Raises :class:`OID4VCIError`. Unknown, expired, already-redeemed and
|
|
135
|
-
invalidated codes
|
|
135
|
+
invalidated codes, and a live PIN-gated offer presented without
|
|
136
|
+
``tx_code``, all produce the SAME ``invalid_grant`` with the same
|
|
136
137
|
message and, by way of :func:`~openbadgeslib.oid4vci.codes.
|
|
137
138
|
dummy_tx_verification`, in the same time — otherwise the endpoint becomes
|
|
138
|
-
an oracle for which codes exist.
|
|
139
|
+
an oracle for which codes exist and which of them are PIN-gated.
|
|
139
140
|
"""
|
|
140
141
|
moment = now or datetime.now(tz=timezone.utc)
|
|
141
142
|
cfg = oid4vci_config(conf)
|
|
@@ -167,8 +168,10 @@ def handle_token_request(conf: configparser.ConfigParser, *, code: str,
|
|
|
167
168
|
|
|
168
169
|
if grant.requires_tx_code:
|
|
169
170
|
if not tx_code:
|
|
170
|
-
|
|
171
|
-
|
|
171
|
+
# Same invalid_grant as an unknown code: answering invalid_request
|
|
172
|
+
# here advertised that this live offer is PIN-gated (#328).
|
|
173
|
+
dummy_tx_verification()
|
|
174
|
+
raise OID4VCIError(INVALID_GRANT, _GRANT_REJECTED)
|
|
172
175
|
assert grant.tx_code_kdf is not None
|
|
173
176
|
assert grant.tx_code_salt is not None and grant.tx_code_digest is not None
|
|
174
177
|
if not verify_tx_code(tx_code, grant.tx_code_kdf, grant.tx_code_salt,
|
|
@@ -58,6 +58,30 @@ from .cli_common import (config_parser, debug_parser, json_parser,
|
|
|
58
58
|
|
|
59
59
|
logger = logging.getLogger(__name__)
|
|
60
60
|
|
|
61
|
+
|
|
62
|
+
def _append_signer_log(path: str, line: str) -> None:
|
|
63
|
+
"""Append *line* to the signer audit log with owner-only permissions.
|
|
64
|
+
|
|
65
|
+
signer.log names recipients. Creating it with the process umask (often
|
|
66
|
+
0o022 → 0644) would leave those addresses group/world readable. ``O_CREAT``
|
|
67
|
+
uses 0o600 and ``fchmod`` tightens an already-existing file. Windows has
|
|
68
|
+
no ``fchmod`` and NTFS ACLs are the control there, so the dance is
|
|
69
|
+
skipped — same stance as ``_write_pem_file`` (#328).
|
|
70
|
+
"""
|
|
71
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND
|
|
72
|
+
fd = os.open(path, flags, 0o600)
|
|
73
|
+
try:
|
|
74
|
+
if hasattr(os, 'fchmod'):
|
|
75
|
+
os.fchmod(fd, 0o600)
|
|
76
|
+
with os.fdopen(fd, 'a') as f:
|
|
77
|
+
fd = -1
|
|
78
|
+
f.write(line + '\n')
|
|
79
|
+
except Exception:
|
|
80
|
+
if fd >= 0:
|
|
81
|
+
os.close(fd)
|
|
82
|
+
raise
|
|
83
|
+
|
|
84
|
+
|
|
61
85
|
# Entry Point
|
|
62
86
|
|
|
63
87
|
|
|
@@ -253,8 +277,7 @@ def _write_badge_and_log(conf: configparser.ConfigParser, badge_file_out: str,
|
|
|
253
277
|
# KeyError, and the badge is already on disk — a log failure must be
|
|
254
278
|
# reported, not turned into a traceback that loses the written badge.
|
|
255
279
|
sign_log = os.path.join(conf['paths']['base_log'], conf['logs']['signer'])
|
|
256
|
-
|
|
257
|
-
file.write(msg + '\n')
|
|
280
|
+
_append_signer_log(sign_log, msg)
|
|
258
281
|
except (OSError, KeyError) as err:
|
|
259
282
|
print('[!] Could not write sign log: %s' % err)
|
|
260
283
|
print('%s at: %s' % (msg, badge_file_out))
|
|
@@ -368,8 +391,7 @@ def _sign_ob1(args: argparse.Namespace, conf: configparser.ConfigParser, badge:
|
|
|
368
391
|
% (datetime.today().isoformat(), badge,
|
|
369
392
|
badge_signed.get_identity(), badge_signed.get_serial_num())
|
|
370
393
|
try:
|
|
371
|
-
|
|
372
|
-
file.write(msg + '\n')
|
|
394
|
+
_append_signer_log(sign_log, msg)
|
|
373
395
|
except OSError as err:
|
|
374
396
|
print('[!] Could not write sign log: %s' % err)
|
|
375
397
|
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
License along with this library.
|
|
22
22
|
"""
|
|
23
23
|
|
|
24
|
-
__version__ = '4.4.
|
|
24
|
+
__version__ = '4.4.4'
|
|
25
25
|
|
|
26
26
|
import contextlib
|
|
27
27
|
import hashlib
|
|
@@ -220,6 +220,43 @@ class _HTTPSOnlyRedirectHandler(request.HTTPRedirectHandler):
|
|
|
220
220
|
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
221
221
|
|
|
222
222
|
|
|
223
|
+
@contextlib.contextmanager
|
|
224
|
+
def _pinned_dial(conn: http.client.HTTPConnection, default_port: int,
|
|
225
|
+
allow_private: bool) -> Any:
|
|
226
|
+
"""Resolve *conn.host*, SSRF-validate, and dial the validated IP.
|
|
227
|
+
|
|
228
|
+
Shared by the HTTPS and (allow_insecure) HTTP connection classes so a
|
|
229
|
+
name that answers public at the pre-check and private at connect (DNS
|
|
230
|
+
rebinding) is caught on both schemes (#328).
|
|
231
|
+
"""
|
|
232
|
+
port = conn.port or default_port
|
|
233
|
+
try:
|
|
234
|
+
addrs = _resolve_host(conn.host, port)
|
|
235
|
+
except OSError as exc:
|
|
236
|
+
raise ValueError('Could not resolve host %r: %s'
|
|
237
|
+
% (conn.host, exc)) from exc
|
|
238
|
+
if not addrs:
|
|
239
|
+
raise ValueError('Could not resolve host %r' % conn.host)
|
|
240
|
+
if not allow_private:
|
|
241
|
+
for ip_str in addrs:
|
|
242
|
+
if _ip_is_blocked(ip_str):
|
|
243
|
+
raise ValueError(
|
|
244
|
+
'Refusing to connect to %r: resolves to non-public '
|
|
245
|
+
'address %s (possible SSRF / DNS rebinding).'
|
|
246
|
+
% (conn.host, ip_str))
|
|
247
|
+
pinned = addrs[0]
|
|
248
|
+
original = getattr(conn, '_create_connection')
|
|
249
|
+
|
|
250
|
+
def _dial_pinned(address: Any, *a: Any, **k: Any) -> Any:
|
|
251
|
+
return original((pinned, address[1]), *a, **k)
|
|
252
|
+
|
|
253
|
+
setattr(conn, '_create_connection', _dial_pinned)
|
|
254
|
+
try:
|
|
255
|
+
yield
|
|
256
|
+
finally:
|
|
257
|
+
setattr(conn, '_create_connection', original)
|
|
258
|
+
|
|
259
|
+
|
|
223
260
|
class _PinnedHTTPSConnection(http.client.HTTPSConnection):
|
|
224
261
|
"""An HTTPSConnection that resolves and SSRF-validates the host *at connect
|
|
225
262
|
time* and dials the validated IP, while still presenting the original
|
|
@@ -239,37 +276,26 @@ class _PinnedHTTPSConnection(http.client.HTTPSConnection):
|
|
|
239
276
|
self._allow_private = allow_private
|
|
240
277
|
|
|
241
278
|
def connect(self) -> None:
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
# attribute precisely so it can be swapped like this.
|
|
262
|
-
pinned = addrs[0]
|
|
263
|
-
original = self._create_connection # type: ignore[has-type]
|
|
264
|
-
|
|
265
|
-
def _dial_pinned(address: Any, *a: Any, **k: Any) -> Any:
|
|
266
|
-
return original((pinned, address[1]), *a, **k)
|
|
267
|
-
|
|
268
|
-
self._create_connection = _dial_pinned
|
|
269
|
-
try:
|
|
279
|
+
with _pinned_dial(self, 443, self._allow_private):
|
|
280
|
+
super().connect()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class _PinnedHTTPConnection(http.client.HTTPConnection):
|
|
284
|
+
"""HTTP twin of :class:`_PinnedHTTPSConnection` for ``allow_insecure=True``.
|
|
285
|
+
|
|
286
|
+
Without this, an http:// download skipped the connect-time pin and
|
|
287
|
+
re-resolved at dial, reopening the DNS-rebinding window the HTTPS path
|
|
288
|
+
closed (#328).
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
def __init__(self, host: str, *args: Any,
|
|
292
|
+
allow_private: bool = False, **kwargs: Any) -> None:
|
|
293
|
+
super().__init__(host, *args, **kwargs)
|
|
294
|
+
self._allow_private = allow_private
|
|
295
|
+
|
|
296
|
+
def connect(self) -> None:
|
|
297
|
+
with _pinned_dial(self, 80, self._allow_private):
|
|
270
298
|
super().connect()
|
|
271
|
-
finally:
|
|
272
|
-
self._create_connection = original
|
|
273
299
|
|
|
274
300
|
|
|
275
301
|
class _PinnedHTTPSHandler(request.HTTPSHandler):
|
|
@@ -287,6 +313,18 @@ class _PinnedHTTPSHandler(request.HTTPSHandler):
|
|
|
287
313
|
_PinnedHTTPSConnection, req, allow_private=self._allow_private)
|
|
288
314
|
|
|
289
315
|
|
|
316
|
+
class _PinnedHTTPHandler(request.HTTPHandler):
|
|
317
|
+
"""Route plain-HTTP connections through :class:`_PinnedHTTPConnection`."""
|
|
318
|
+
|
|
319
|
+
def __init__(self, allow_private: bool = False) -> None:
|
|
320
|
+
super().__init__()
|
|
321
|
+
self._allow_private = allow_private
|
|
322
|
+
|
|
323
|
+
def http_open(self, req: Any) -> Any:
|
|
324
|
+
return self.do_open(
|
|
325
|
+
_PinnedHTTPConnection, req, allow_private=self._allow_private)
|
|
326
|
+
|
|
327
|
+
|
|
290
328
|
#: Verify keys, issuer documents, and revocation lists are all small JSON/PEM
|
|
291
329
|
#: payloads; cap reads well above any legitimate size to bound memory use
|
|
292
330
|
#: against an attacker-influenced URL serving an oversized/streamed response.
|
|
@@ -314,8 +352,10 @@ def download_file(url: str, allow_insecure: bool = False,
|
|
|
314
352
|
list, an OB2 badge/issuer/revocationList URL), fetching a private/loopback/
|
|
315
353
|
link-local target would be a server-side request forgery (SSRF) sink. Pass
|
|
316
354
|
``allow_private=True`` to permit internal hosts (e.g. a private deployment).
|
|
317
|
-
The check is re-applied to redirect targets.
|
|
318
|
-
|
|
355
|
+
The check is re-applied to redirect targets. HTTPS always, and HTTP when
|
|
356
|
+
``allow_insecure=True``, pin the socket to the validated IP (DNS
|
|
357
|
+
rebinding). The response body is bounded to MAX_DOWNLOAD_SIZE to limit
|
|
358
|
+
memory use.
|
|
319
359
|
"""
|
|
320
360
|
u = urlparse(url)
|
|
321
361
|
|
|
@@ -331,11 +371,16 @@ def download_file(url: str, allow_insecure: bool = False,
|
|
|
331
371
|
_assert_public_host(url)
|
|
332
372
|
|
|
333
373
|
# _PinnedHTTPSHandler pins the connection to the validated IP (defeats DNS
|
|
334
|
-
# rebinding); the
|
|
374
|
+
# rebinding); the HTTP twin does the same when allow_insecure permits
|
|
375
|
+
# plain HTTP. The redirect handler re-applies the scheme/host checks to any
|
|
335
376
|
# 30x target (which then also connects through the pinned handler).
|
|
336
|
-
|
|
377
|
+
handlers: List[Any] = [
|
|
337
378
|
_PinnedHTTPSHandler(allow_private),
|
|
338
|
-
_HTTPSOnlyRedirectHandler(allow_insecure, allow_private)
|
|
379
|
+
_HTTPSOnlyRedirectHandler(allow_insecure, allow_private),
|
|
380
|
+
]
|
|
381
|
+
if allow_insecure:
|
|
382
|
+
handlers.insert(0, _PinnedHTTPHandler(allow_private))
|
|
383
|
+
opener = request.build_opener(*handlers)
|
|
339
384
|
deadline = time.monotonic() + MAX_DOWNLOAD_SECONDS
|
|
340
385
|
with opener.open(url, timeout=30) as response:
|
|
341
386
|
chunks = []
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openbadgeslib
|
|
3
|
-
Version: 4.4.
|
|
3
|
+
Version: 4.4.4
|
|
4
4
|
Summary: Production-ready Python library & CLI for Open Badges 3.0 (W3C Verifiable Credentials): issue, verify, and revoke JWT-VC credentials with Bitstring Status Lists and did:web — plus strict OB 2.0 and legacy OB 1.0.
|
|
5
5
|
Author-email: Luis González Fernández <luisgf@luisgf.es>, Jesús Cea Avión <jcea@jcea.es>
|
|
6
6
|
License: LGPLv3
|
|
@@ -52,6 +52,38 @@ class TestSvg:
|
|
|
52
52
|
with pytest.raises(Exception):
|
|
53
53
|
extract_svg(b'<svg><unclosed>')
|
|
54
54
|
|
|
55
|
+
def test_script_and_handlers_are_stripped_from_the_carrier(self):
|
|
56
|
+
# A baked badge is opened as an image; leftover active content in the
|
|
57
|
+
# source SVG would run in the recipient's browser (#328).
|
|
58
|
+
src = (
|
|
59
|
+
b'<svg xmlns="http://www.w3.org/2000/svg">'
|
|
60
|
+
b'<script>alert(1)</script>'
|
|
61
|
+
b'<rect id="mark" onclick="alert(1)" opacity="0.5" width="1" height="1"/>'
|
|
62
|
+
b'<a href="javascript:alert(1)">x</a>'
|
|
63
|
+
b'<image href="data:image/png;base64,AAAA"/>'
|
|
64
|
+
b'</svg>'
|
|
65
|
+
)
|
|
66
|
+
baked = bake_svg(src, TOKEN)
|
|
67
|
+
assert b'<script' not in baked.lower()
|
|
68
|
+
assert b'onclick' not in baked.lower()
|
|
69
|
+
assert b'javascript:' not in baked.lower()
|
|
70
|
+
assert b'opacity="0.5"' in baked
|
|
71
|
+
assert b'data:image/png;base64,AAAA' in baked
|
|
72
|
+
assert extract_svg(baked) == TOKEN
|
|
73
|
+
|
|
74
|
+
def test_foreignobject_and_data_html_are_stripped(self):
|
|
75
|
+
src = (
|
|
76
|
+
b'<svg xmlns="http://www.w3.org/2000/svg">'
|
|
77
|
+
b'<foreignObject><body xmlns="http://www.w3.org/1999/xhtml">'
|
|
78
|
+
b'<p>x</p></body></foreignObject>'
|
|
79
|
+
b'<a href="data:text/html,%3Cscript%3Ealert(1)%3C/script%3E">x</a>'
|
|
80
|
+
b'</svg>'
|
|
81
|
+
)
|
|
82
|
+
baked = bake_svg(src, TOKEN)
|
|
83
|
+
assert b'foreignObject' not in baked and b'foreignobject' not in baked.lower()
|
|
84
|
+
assert b'data:text/html' not in baked.lower()
|
|
85
|
+
assert extract_svg(baked) == TOKEN
|
|
86
|
+
|
|
55
87
|
|
|
56
88
|
# ── PNG ──────────────────────────────────────────────────────────────────────
|
|
57
89
|
|
|
@@ -892,6 +892,32 @@ def test_signer_ob2_writes_file_and_log(tmp_path, capsys):
|
|
|
892
892
|
assert (tmp_path / 'log' / 'signer.log').read_text().strip() != ''
|
|
893
893
|
|
|
894
894
|
|
|
895
|
+
def test_signer_log_is_owner_only_under_permissive_umask(tmp_path):
|
|
896
|
+
# signer.log names recipients; creating it with umask 0 must still yield
|
|
897
|
+
# 0600, and an already-world-readable file must be tightened (#328).
|
|
898
|
+
import os
|
|
899
|
+
import stat
|
|
900
|
+
import pytest
|
|
901
|
+
if os.name == 'nt' or not hasattr(os, 'fchmod'):
|
|
902
|
+
pytest.skip('POSIX file-mode semantics; Windows uses ACLs')
|
|
903
|
+
from openbadgeslib import openbadges_signer
|
|
904
|
+
cfg = _write_ob2_sign_config(tmp_path)
|
|
905
|
+
log = tmp_path / 'log' / 'signer.log'
|
|
906
|
+
log.write_text('prior\n')
|
|
907
|
+
os.chmod(log, 0o644)
|
|
908
|
+
argv = ['openbadges-signer', '-c', str(cfg), '-b', '1',
|
|
909
|
+
'-r', 'recipient@example.com', '-o', str(tmp_path), '-V', '1', '-E']
|
|
910
|
+
old = os.umask(0)
|
|
911
|
+
try:
|
|
912
|
+
with patch('openbadgeslib.badge_model.download_file', return_value=b'data'), \
|
|
913
|
+
patch.object(sys, 'argv', argv):
|
|
914
|
+
openbadges_signer.main()
|
|
915
|
+
finally:
|
|
916
|
+
os.umask(old)
|
|
917
|
+
mode = stat.S_IMODE(log.stat().st_mode)
|
|
918
|
+
assert mode == 0o600, 'signer.log has mode %o, expected 0600' % mode
|
|
919
|
+
|
|
920
|
+
|
|
895
921
|
def test_signer_ob2_mail_badge_sends(tmp_path):
|
|
896
922
|
from unittest.mock import MagicMock
|
|
897
923
|
from openbadgeslib import openbadges_signer
|
|
@@ -41,14 +41,29 @@ class TestSend:
|
|
|
41
41
|
smtp = smtp_cls.return_value
|
|
42
42
|
mail.send(_badge())
|
|
43
43
|
smtp.login.assert_not_called()
|
|
44
|
+
smtp.starttls.assert_not_called() # localhost: no STARTTLS
|
|
44
45
|
smtp.sendmail.assert_called_once()
|
|
45
46
|
smtp.quit.assert_called_once()
|
|
47
|
+
assert smtp_cls.call_args.kwargs.get('timeout') == 30
|
|
46
48
|
frm, to, body = smtp.sendmail.call_args.args
|
|
47
49
|
assert frm == 'issuer@example.com' and to == 'r@example.com'
|
|
48
50
|
assert 'Content-Disposition: attachment' in body
|
|
49
51
|
assert 'filename="badge_1_r.svg"' in body or "filename=badge_1_r.svg" in body
|
|
50
52
|
assert '\nBcc:' not in body
|
|
51
53
|
|
|
54
|
+
def test_non_loopback_plain_smtp_upgrades_with_starttls(self):
|
|
55
|
+
import ssl as ssl_mod
|
|
56
|
+
mail = _ready_mail(smtp_server='mail.example.com')
|
|
57
|
+
with patch('openbadgeslib.mail.SMTP') as smtp_cls:
|
|
58
|
+
smtp = smtp_cls.return_value
|
|
59
|
+
mail.send(_badge())
|
|
60
|
+
smtp.starttls.assert_called_once()
|
|
61
|
+
ctx = smtp.starttls.call_args.kwargs.get('context')
|
|
62
|
+
assert ctx is not None
|
|
63
|
+
assert ctx.check_hostname is True
|
|
64
|
+
assert ctx.verify_mode == ssl_mod.CERT_REQUIRED
|
|
65
|
+
smtp.sendmail.assert_called_once()
|
|
66
|
+
|
|
52
67
|
def test_ssl_with_auth_logs_in(self):
|
|
53
68
|
mail = _ready_mail(use_ssl=True, username='u', password='p')
|
|
54
69
|
with patch('openbadgeslib.mail.SMTP_SSL') as ssl_cls, \
|
|
@@ -71,6 +86,7 @@ class TestSend:
|
|
|
71
86
|
assert ctx is not None
|
|
72
87
|
assert ctx.check_hostname is True
|
|
73
88
|
assert ctx.verify_mode == ssl_mod.CERT_REQUIRED
|
|
89
|
+
assert ssl_cls.call_args.kwargs.get('timeout') == 30
|
|
74
90
|
|
|
75
91
|
def test_smtp_error_is_reported_not_raised(self, capsys):
|
|
76
92
|
from smtplib import SMTPException
|