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.
Files changed (167) hide show
  1. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/Changelog.txt +26 -0
  2. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/PKG-INFO +1 -1
  3. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/baking.py +66 -1
  4. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/confparser.py +9 -1
  5. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/mail.py +21 -1
  6. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/ldp.py +29 -1
  7. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/errors.py +4 -1
  8. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/handler.py +7 -4
  9. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_signer.py +26 -4
  10. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/util.py +81 -36
  11. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib.egg-info/PKG-INFO +1 -1
  12. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_baking.py +32 -0
  13. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_cli_smoke.py +26 -0
  14. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_mail.py +16 -0
  15. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_ldp_ecdsa_sd.py +64 -5
  16. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_oid4vci_config.py +10 -0
  17. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_oid4vci_protocol.py +7 -2
  18. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_util.py +26 -0
  19. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/CONTRIBUTING.md +0 -0
  20. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/GOVERNANCE.md +0 -0
  21. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/LICENSE.txt +0 -0
  22. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/MANIFEST.in +0 -0
  23. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/README.md +0 -0
  24. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/SECURITY.md +0 -0
  25. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/docs/README.md +0 -0
  26. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/__init__.py +0 -0
  27. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/_jsonmodel.py +0 -0
  28. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/_jws/__init__.py +0 -0
  29. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/_jws/exceptions.py +0 -0
  30. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/_jws/utils.py +0 -0
  31. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/_ob1_api.py +0 -0
  32. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/badge_model.py +0 -0
  33. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/cli_common.py +0 -0
  34. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/config.ini.example +0 -0
  35. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/errors.py +0 -0
  36. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/issue.py +0 -0
  37. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/keys.py +0 -0
  38. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/logs.py +0 -0
  39. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob1/__init__.py +0 -0
  40. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob1/badge.py +0 -0
  41. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob1/signer.py +0 -0
  42. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob1/verifier.py +0 -0
  43. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob2/__init__.py +0 -0
  44. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob2/models.py +0 -0
  45. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob2/signer.py +0 -0
  46. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob2/verifier.py +0 -0
  47. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/__init__.py +0 -0
  48. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/__init__.py +0 -0
  49. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
  50. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
  51. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
  52. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
  53. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/ob-v3p0-extensions.json +0 -0
  54. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
  55. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
  56. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/credential.py +0 -0
  57. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/did.py +0 -0
  58. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/eudi.py +0 -0
  59. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/publish.py +0 -0
  60. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/signer.py +0 -0
  61. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/status.py +0 -0
  62. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/status_list.py +0 -0
  63. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/status_registry.py +0 -0
  64. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/ob3/verifier.py +0 -0
  65. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/__init__.py +0 -0
  66. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/codes.py +0 -0
  67. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/formats.py +0 -0
  68. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/memory_store.py +0 -0
  69. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/metadata.py +0 -0
  70. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/nonce.py +0 -0
  71. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/offer.py +0 -0
  72. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/reconcile.py +0 -0
  73. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/sqlite_store.py +0 -0
  74. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/oid4vci/store.py +0 -0
  75. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_cli.py +0 -0
  76. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_init.py +0 -0
  77. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_keygenerator.py +0 -0
  78. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_publish.py +0 -0
  79. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_status.py +0 -0
  80. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/openbadges_verifier.py +0 -0
  81. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/py.typed +0 -0
  82. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib/verify.py +0 -0
  83. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib.egg-info/SOURCES.txt +0 -0
  84. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib.egg-info/dependency_links.txt +0 -0
  85. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib.egg-info/entry_points.txt +0 -0
  86. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib.egg-info/requires.txt +0 -0
  87. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/openbadgeslib.egg-info/top_level.txt +0 -0
  88. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/pyproject.toml +0 -0
  89. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/setup.cfg +0 -0
  90. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/config1.ini +0 -0
  91. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/conformance/Dockerfile.ob3 +0 -0
  92. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/conformance/README.md +0 -0
  93. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/conformance/conftest.py +0 -0
  94. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/conformance/docker-compose.yml +0 -0
  95. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/conformance/test_official_validators.py +0 -0
  96. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/conftest.py +0 -0
  97. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/fixtures/ob_schemas/README.md +0 -0
  98. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
  99. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
  100. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/fixtures/ob_schemas/refresh.sh +0 -0
  101. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/fixtures/vc_di_eddsa/README.md +0 -0
  102. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
  103. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
  104. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
  105. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
  106. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
  107. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/images/sample1.png +0 -0
  108. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/images/sample1.svg +0 -0
  109. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/images/userimage01.svg +0 -0
  110. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/ldp_helpers.py +0 -0
  111. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/logo Python Espan/314/203a.svg" +0 -0
  112. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/logo Python Espa/303/261a.svg" +0 -0
  113. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_cli_json.py +0 -0
  114. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_confparser.py +0 -0
  115. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_docs.py +0 -0
  116. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_eddsa.py +0 -0
  117. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_errors.py +0 -0
  118. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_examples.py +0 -0
  119. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_issue.py +0 -0
  120. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_issue_credential.py +0 -0
  121. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_jsonmodel.py +0 -0
  122. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_jws.py +0 -0
  123. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_key_operation.py +0 -0
  124. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_keys.py +0 -0
  125. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob1_badge_io.py +0 -0
  126. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob1_deprecation.py +0 -0
  127. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob1_signer.py +0 -0
  128. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob1_verifier.py +0 -0
  129. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob2_cli.py +0 -0
  130. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob2_error_branches.py +0 -0
  131. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob2_models.py +0 -0
  132. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob2_signer.py +0 -0
  133. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob2_verifier.py +0 -0
  134. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_conformance_schema.py +0 -0
  135. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_contexts.py +0 -0
  136. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_credential.py +0 -0
  137. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_did.py +0 -0
  138. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_did_doc.py +0 -0
  139. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_endorsement.py +0 -0
  140. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_eudi_oid4vci.py +0 -0
  141. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_eudi_sd_jwt.py +0 -0
  142. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_eudi_type_metadata.py +0 -0
  143. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_eudi_x5c.py +0 -0
  144. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_ldp.py +0 -0
  145. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_ldp_cli.py +0 -0
  146. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_ldp_errors.py +0 -0
  147. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_ldp_sign.py +0 -0
  148. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_publish_cli.py +0 -0
  149. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_signer.py +0 -0
  150. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_signer_cli_ldp.py +0 -0
  151. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_status.py +0 -0
  152. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_status_list.py +0 -0
  153. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_status_registry.py +0 -0
  154. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_ob3_verifier.py +0 -0
  155. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_oid4vci_reservations.py +0 -0
  156. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_oid4vci_store.py +0 -0
  157. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_openbadges_cli.py +0 -0
  158. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_public_api.py +0 -0
  159. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_sign_ecc.pem +0 -0
  160. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_sign_rsa.pem +0 -0
  161. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_signer_batch.py +0 -0
  162. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_verifier_cli_coverage.py +0 -0
  163. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_verify.py +0 -0
  164. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_verify_ecc.pem +0 -0
  165. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/test_verify_rsa.pem +0 -0
  166. {openbadgeslib-4.4.3 → openbadgeslib-4.4.4}/tests/withoutxmlheader.svg +0 -0
  167. {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
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
- return explicit or urljoin(endpoint_base, default_path)
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 (missing tx_code, bad grant type).
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 all produce the SAME ``invalid_grant`` with the same
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
- raise OID4VCIError(INVALID_REQUEST,
171
- 'this offer requires a tx_code')
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
- with open(sign_log, 'a') as file:
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
- with open(sign_log, 'a') as file:
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.3'
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
- port = self.port or 443
243
- try:
244
- addrs = _resolve_host(self.host, port)
245
- except OSError as exc:
246
- raise ValueError('Could not resolve host %r: %s'
247
- % (self.host, exc)) from exc
248
- if not addrs:
249
- raise ValueError('Could not resolve host %r' % self.host)
250
- if not self._allow_private:
251
- for ip_str in addrs:
252
- if _ip_is_blocked(ip_str):
253
- raise ValueError(
254
- 'Refusing to connect to %r: resolves to non-public '
255
- 'address %s (possible SSRF / DNS rebinding).'
256
- % (self.host, ip_str))
257
- # Dial the validated IP directly (no second resolution) by pointing the
258
- # connection's socket factory at it, then delegate to the parent connect
259
- # — which still uses self.host for TLS SNI, certificate verification and
260
- # the Host header. urllib exposes _create_connection as an instance
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. The response body is bounded to
318
- MAX_DOWNLOAD_SIZE to limit memory use.
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 redirect handler re-applies the scheme/host checks to any
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
- opener = request.build_opener(
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
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