pyFF 3.0.0__tar.gz → 3.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.
Files changed (119) hide show
  1. {pyff-3.0.0 → pyff-3.1.0}/PKG-INFO +5 -5
  2. {pyff-3.0.0 → pyff-3.1.0}/README.rst +2 -2
  3. {pyff-3.0.0 → pyff-3.1.0}/pyproject.toml +3 -3
  4. {pyff-3.0.0 → pyff-3.1.0}/pyproject.toml.orig +3 -3
  5. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/builtins.py +5 -4
  6. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/resource.py +9 -3
  7. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/samlmd.py +7 -7
  8. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/metadata/edugain-trustinfo-2.0.xml +1 -1
  9. pyff-3.1.0/src/pyff/test/data/metadata/test07-xsi-type-middleware.xml +86 -0
  10. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/test_pipeline.py +117 -0
  11. pyff-3.1.0/src/pyff/test/test_xslt_namespaces.py +45 -0
  12. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/utils.py +74 -23
  13. {pyff-3.0.0 → pyff-3.1.0}/LICENSE +0 -0
  14. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/__init__.py +0 -0
  15. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/api.py +0 -0
  16. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/constants.py +0 -0
  17. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/decorators.py +0 -0
  18. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/exceptions.py +0 -0
  19. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/fetch.py +0 -0
  20. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/locks.py +0 -0
  21. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/logs.py +0 -0
  22. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/md.py +0 -0
  23. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/mdq.py +0 -0
  24. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/merge_strategies.py +0 -0
  25. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/parse.py +0 -0
  26. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/pipes.py +0 -0
  27. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/repo.py +0 -0
  28. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/MetadataExchange.xsd +0 -0
  29. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/XMLSchema.xsd +0 -0
  30. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/atom.xsd +0 -0
  31. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/cs-sstc-schema-assertion-1.1.xsd +0 -0
  32. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/oasis-200401-wss-wssecurity-secext-1.0.xsd +0 -0
  33. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/oasis-200401-wss-wssecurity-utility-1.0.xsd +0 -0
  34. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/privacy.xsd +0 -0
  35. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/saml-metadata-rpi-v1.0.xsd +0 -0
  36. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/saml-metadata-trustinfo-v1.0.xsd +0 -0
  37. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/saml-schema-assertion-2.0.xsd +0 -0
  38. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/saml-schema-metadata-2.0.xsd +0 -0
  39. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/saml-schema-protocol-2.0.xsd +0 -0
  40. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/schema.xsd +0 -0
  41. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/shibboleth-metadata-1.0.xsd +0 -0
  42. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/shibboleth-trust-1.0.xsd +0 -0
  43. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/shibboleth.xsd +0 -0
  44. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/sstc-metadata-attr.xsd +0 -0
  45. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/sstc-saml-idp-discovery.xsd +0 -0
  46. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/sstc-saml-metadata-algsupport.xsd +0 -0
  47. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/sstc-saml-metadata-ui-v1.0.xsd +0 -0
  48. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/sstc-saml-schema-assertion-2.0.xsd +0 -0
  49. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/sstc-saml-schema-metadata-2.0.xsd +0 -0
  50. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/ws-addr.xsd +0 -0
  51. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/ws-authorization.xsd +0 -0
  52. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/ws-federation.xsd +0 -0
  53. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/ws-securitypolicy-1.2.xsd +0 -0
  54. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/xenc-schema.xsd +0 -0
  55. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/xml.xsd +0 -0
  56. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/xmldsig-core-schema.xsd +0 -0
  57. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/schema/xrd-1.0-os.xsd +0 -0
  58. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/store.py +0 -0
  59. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/__init__.py +0 -0
  60. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/bad_metadata/stockholmstad-c1107b3ceb78c5e9.xml +0 -0
  61. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/eidas/countries/FR.xml +0 -0
  62. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/eidas/countries/GR.xml +0 -0
  63. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/eidas/countries/fr-connector.xml +0 -0
  64. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/eidas/countries/fr-proxy.xml +0 -0
  65. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/eidas/countries/gr-connector.xml +0 -0
  66. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/eidas/countries/gr-proxy.xml +0 -0
  67. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/eidas/eidas.xml +0 -0
  68. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/empty/placeholder +0 -0
  69. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/empty.txt +0 -0
  70. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/images/BFC-Logo-80x60.gif +0 -0
  71. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/images/FHP_logo.svg +0 -0
  72. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/images/New_GEANT_logo.jpg +0 -0
  73. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/images/favicon.ico +0 -0
  74. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/images/sunet256.png +0 -0
  75. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/mdx/mdx.fd +0 -0
  76. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/metadata/not-metadata.xml +0 -0
  77. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/metadata/sharav.abes.fr.xml +0 -0
  78. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/metadata/swamid-2.0-test.xml +0 -0
  79. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/metadata/test-sp-trustinfo-in-attr-broken-base64.xml +0 -0
  80. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/metadata/test-sp-trustinfo-in-attr.xml +0 -0
  81. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/metadata/test-sp.xml +0 -0
  82. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/metadata/test01.xml +0 -0
  83. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/metadata/test02-invalid.xml +0 -0
  84. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/metadata/test02-sp.xml +0 -0
  85. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/metadata/test03-invalid.xml +0 -0
  86. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/metadata/test04-trailing-slash-sp.xml +0 -0
  87. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/metadata/test05-xsd-prefix-idp.xml +0 -0
  88. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/metadata/test06-xs-and-xsd-declared.xml +0 -0
  89. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/metadata/wayf-edugain-metadata.xml +0 -0
  90. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/simple-pipeline/bad.fd +0 -0
  91. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/simple-pipeline/certreport-swamid.fd +0 -0
  92. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/simple-pipeline/certreport.fd +0 -0
  93. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/simple-pipeline/idp.aco.net.xml +0 -0
  94. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/simple-pipeline/signer.fd +0 -0
  95. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/simple-pipeline/skriptenforum.net.xml +0 -0
  96. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/data/simple-pipeline/validator.fd +0 -0
  97. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/test_decorators.py +0 -0
  98. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/test_log.py +0 -0
  99. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/test_md_api.py +0 -0
  100. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/test_mdsl.py +0 -0
  101. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/test_repo.py +0 -0
  102. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/test_rwlock.py +0 -0
  103. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/test_simple_pipeline.py +0 -0
  104. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/test_store.py +0 -0
  105. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/test_time.py +0 -0
  106. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/test/test_utils.py +0 -0
  107. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/tools.py +0 -0
  108. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/wsgi.py +0 -0
  109. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/xslt/atom.xsl +0 -0
  110. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/xslt/atom2html.xsl +0 -0
  111. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/xslt/discojson.xsl +0 -0
  112. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/xslt/eidas-cleanup.xsl +0 -0
  113. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/xslt/entity2html.xsl +0 -0
  114. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/xslt/kalmar2.xsl +0 -0
  115. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/xslt/pp.xsl +0 -0
  116. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/xslt/pubinfo.xsl +0 -0
  117. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/xslt/regauth.xsl +0 -0
  118. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/xslt/tidy.xsl +0 -0
  119. {pyff-3.0.0 → pyff-3.1.0}/src/pyff/xslt/unsign.xsl +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pyFF
3
- Version: 3.0.0
3
+ Version: 3.1.0
4
4
  Summary: Federation Feeder
5
5
  Author: Leif Johansson, Fredrik Thulin, Enrique Pérez Arnaud, Mikael Frykholm
6
6
  Author-email: Leif Johansson <leifj@sunet.se>, Fredrik Thulin <redrik@thulin.net>, Mikael Frykholm <mifr@sunet.se>
@@ -40,8 +40,8 @@ Requires-Dist: pyconfig>=3.3.0
40
40
  Requires-Dist: pydantic>=2.8
41
41
  Requires-Dist: pyramid>=2.1
42
42
  Requires-Dist: pytz>=2025.2
43
- Requires-Dist: pyuppsala>=0.13.0
44
- Requires-Dist: pybergshamra>=0.11.0
43
+ Requires-Dist: pyuppsala>=0.13.1
44
+ Requires-Dist: pybergshamra>=0.11.1
45
45
  Requires-Dist: pyyaml>=3.10
46
46
  Requires-Dist: redis>=5.3.0
47
47
  Requires-Dist: redis-collections>=0.13.0
@@ -101,8 +101,8 @@ Features
101
101
  Dependencies
102
102
  ============
103
103
 
104
- * pybergshamra >= 0.11.0 (XML signatures and PKCS#11 support)
105
- * pyuppsala >= 0.11.0 (XML processing)
104
+ * pybergshamra >= 0.11.1 (XML signatures and PKCS#11 support)
105
+ * pyuppsala >= 0.13.1 (XML processing)
106
106
  * pygments
107
107
  * gunicorn (for the standalone pyffd server)
108
108
  * ... cf pyproject.toml
@@ -41,8 +41,8 @@ Features
41
41
  Dependencies
42
42
  ============
43
43
 
44
- * pybergshamra >= 0.11.0 (XML signatures and PKCS#11 support)
45
- * pyuppsala >= 0.11.0 (XML processing)
44
+ * pybergshamra >= 0.11.1 (XML signatures and PKCS#11 support)
45
+ * pyuppsala >= 0.13.1 (XML processing)
46
46
  * pygments
47
47
  * gunicorn (for the standalone pyffd server)
48
48
  * ... cf pyproject.toml
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyFF"
3
- version = "3.0.0"
3
+ version = "3.1.0"
4
4
  readme = "README.rst"
5
5
  description = "Federation Feeder"
6
6
  requires-python = ">=3.11"
@@ -15,8 +15,8 @@ dependencies = [
15
15
  "pydantic>=2.8",
16
16
  "pyramid>=2.1",
17
17
  "pytz>=2025.2",
18
- "pyuppsala>=0.13.0",
19
- "pybergshamra>=0.11.0",
18
+ "pyuppsala>=0.13.1",
19
+ "pybergshamra>=0.11.1",
20
20
  "pyyaml>=3.10",
21
21
  "redis>=5.3.0",
22
22
  "redis-collections>=0.13.0",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyFF"
3
- version = "3.0.0"
3
+ version = "3.1.0"
4
4
  readme = "README.rst"
5
5
  description = "Federation Feeder"
6
6
  requires-python = ">=3.11"
@@ -26,8 +26,8 @@ dependencies = [
26
26
  "pydantic>=2.8",
27
27
  "pyramid>=2.1",
28
28
  "pytz>=2025.2",
29
- "pyuppsala>=0.13.0",
30
- "pybergshamra>=0.11.0",
29
+ "pyuppsala>=0.13.1",
30
+ "pybergshamra>=0.11.1",
31
31
  "pyyaml>=3.10",
32
32
  "redis>=5.3.0",
33
33
  "redis-collections>=0.13.0",
@@ -693,7 +693,8 @@ def load(req: Plumbing.Request, *opts):
693
693
  - ``as``: name the resource, e.g. to refer to it in later pipeline steps.
694
694
  - ``verify``: check the XML signature of the resource, using either a PEM certificate file or a SHA-1
695
695
  fingerprint of the signing certificate. With a fingerprint, the certificate embedded in the signature is
696
- used and the fingerprint pins it.
696
+ used and the fingerprint pins it. ``verify`` may be repeated to support key rollovers; a signature by any
697
+ one of the given certificates (or fingerprints) is accepted. Certificates and fingerprints cannot be mixed.
697
698
  - ``via``: name of a ``when`` clause in the same pipeline that is applied to the loaded metadata, for
698
699
  example to remove entities with ``fork merge remove``.
699
700
  - ``cleanup``: name of a ``when`` clause used to pre-process the parsed metadata before it is validated.
@@ -718,7 +719,7 @@ def load(req: Plumbing.Request, *opts):
718
719
  log.debug(f"load parsing '{stripped}'")
719
720
  r = stripped.split()
720
721
 
721
- assert len(r) in range(1, 8), PipeException(
722
+ assert len(r) in range(1, 12), PipeException(
722
723
  "Usage: load resource [as url] [[verify] verification] [via pipeline] [cleanup pipeline]"
723
724
  )
724
725
 
@@ -736,7 +737,7 @@ def load(req: Plumbing.Request, *opts):
736
737
  if elt == "as":
737
738
  child_opts.alias = value
738
739
  elif elt == "verify":
739
- child_opts.verify = value
740
+ child_opts.verify = [*(child_opts.verify or []), value]
740
741
  elif elt == "via":
741
742
  child_opts.via.append(PipelineCallback(value, req, store=req.md.store))
742
743
  elif elt == "cleanup":
@@ -748,7 +749,7 @@ def load(req: Plumbing.Request, *opts):
748
749
  "Usage: load resource [as url] [[verify] verification] [via pipeline]* [cleanup pipeline]*"
749
750
  )
750
751
  else:
751
- child_opts.verify = elt
752
+ child_opts.verify = [*(child_opts.verify or []), elt]
752
753
 
753
754
  # override anything in child_opts with what is in opts
754
755
  child_opts = child_opts.model_copy(update=_opts)
@@ -18,7 +18,7 @@ from typing import TYPE_CHECKING, Any, Callable
18
18
  from urllib.parse import quote as urlescape
19
19
 
20
20
  import requests
21
- from pydantic import BaseModel, ConfigDict, Field
21
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
22
22
  from pyuppsala.etree import ElementTree
23
23
  from requests.adapters import Response
24
24
 
@@ -204,8 +204,9 @@ class _NativeResponse:
204
204
 
205
205
  class ResourceOpts(BaseModel):
206
206
  alias: str | None = Field(None, alias='as') # TODO: Rename to 'name'?
207
- # a certificate (file) or a SHA1 fingerprint to use for signature verification
208
- verify: str | None = None
207
+ # certificates (file or PEM text) or SHA1 fingerprints to use for signature verification; a signature by any
208
+ # one of them is accepted, which allows key rollovers
209
+ verify: list[str] | None = None
209
210
  # TODO: move classes to make the correct typing work: Iterable[Union[Lambda, PipelineCallback]] = Field([])
210
211
  via: list[Callable] = Field([])
211
212
  # A list of callbacks that can be used to pre-process parsed metadata before validation. Use as a clue-bat.
@@ -219,6 +220,11 @@ class ResourceOpts(BaseModel):
219
220
  verify_tls: bool = False
220
221
  model_config = ConfigDict(arbitrary_types_allowed=True)
221
222
 
223
+ @field_validator('verify', mode='before')
224
+ @classmethod
225
+ def _wrap_single_verify(cls, value):
226
+ return [value] if isinstance(value, str) else value
227
+
222
228
  def to_dict(self) -> dict[str, Any]:
223
229
  res = self.model_dump()
224
230
  # Compensate for the 'alias' field options
@@ -20,6 +20,7 @@ from pyff.parse import ParserInfo, PyffParser, add_parser
20
20
  from pyff.utils import (
21
21
  Lambda,
22
22
  b2u,
23
+ b64_to_pem,
23
24
  cert_dict,
24
25
  check_signature,
25
26
  collect_attr,
@@ -112,7 +113,7 @@ def parse_saml_metadata(
112
113
 
113
114
  expire_time_offset = metadata_expiration(t)
114
115
 
115
- t = check_signature(t, opts.verify)
116
+ t = check_signature(t, opts.verify, source=base_url)
116
117
 
117
118
  trust_info = None
118
119
  extensions = t.find('{{{}}}Extensions'.format(NS['md']))
@@ -305,23 +306,22 @@ class MDServiceListParser(PyffParser):
305
306
  if location:
306
307
  certs = cert_dict(ml)
307
308
  fingerprints = list(certs.keys())
308
- fp = None
309
- if len(fingerprints) > 0:
310
- fp = fingerprints[0]
311
309
 
312
310
  ep = ml.find("{{{}}}Endpoint".format(NS['ser']))
313
- if ep is not None and fp is not None:
311
+ if ep is not None and fingerprints:
314
312
  args = dict(
315
313
  country_code=mdl.get('Territory'),
316
314
  hide_from_discovery=str2bool(ep.get('HideFromDiscovery', 'false')),
317
315
  )
318
316
  log.debug(
319
317
  "MDSL[{}]: {} verified by {} for country {}".format(
320
- info.scheme_territory, location, fp, args.get('country_code')
318
+ info.scheme_territory, location, ', '.join(fingerprints), args.get('country_code')
321
319
  )
322
320
  )
323
321
  child_opts = resource.opts.model_copy(update={'alias': None}, deep=True)
324
- child_opts.verify = fp
322
+ # The listed certificates are the trusted signers; all of them are accepted so that a
323
+ # key rollover can list both the old and the new certificate.
324
+ child_opts.verify = [b64_to_pem(cert_b64) for cert_b64 in certs.values()]
325
325
  r = resource.add_child(location, child_opts)
326
326
 
327
327
  # this is specific post-processing for MDSL files
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
- name: pyff-3.0.0/src/pyff/test/data/metadata/edugain-trustinfo-2.0.xml
2
+ name: pyff-3.1.0/src/pyff/test/data/metadata/edugain-trustinfo-2.0.xml
3
3
  size: 83107927 bytes
4
4
  sha256: 9646f2c1428ee2522e2c8f493daa3b80d11825e23d827a2d6e16dabdc58ca466
@@ -0,0 +1,86 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:alg="urn:oasis:names:tc:SAML:metadata:algsupport" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:eidas="http://eidas.europa.eu/saml-extensions" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" ID="_eumiddleware" entityID="https://test.demw.eidas.swedenconnect.se/eidas-middleware/Metadata" validUntil="2026-12-09T09:46:59.594Z">
3
+ <ds:Signature>
4
+ <ds:SignedInfo>
5
+ <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
6
+ <ds:SignatureMethod Algorithm="http://www.w3.org/2007/05/xmldsig-more#sha256-rsa-MGF1"/>
7
+ <ds:Reference URI="#_eumiddleware">
8
+ <ds:Transforms>
9
+ <ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
10
+ <ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#">
11
+ <ec:InclusiveNamespaces xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#" PrefixList="xsd"/>
12
+ </ds:Transform>
13
+ </ds:Transforms>
14
+ <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
15
+ <ds:DigestValue>gVjWffnS2gYWlNyqxkCbQynU425D1pqZiHJvNQr1xtU=</ds:DigestValue>
16
+ </ds:Reference>
17
+ </ds:SignedInfo>
18
+ <ds:SignatureValue>MgE/0IKAPG2+TkzbVOj6NZpcMMMJVN+1pqdqF6nKPNCE0Lfy4vLdap9qxtzDpbQpl7Ece0zYQnHYn6J/x9FjRm6N/jBth+YNxwmNtyE5GyxsVmNIAhSvMkerKqgHBxKmkTrp17au7bQzxHexvGEnkA0OzmuHstO0cKiCZ7B6ykCCOnh8ydSXkUeUAFfSzpoBraIz0FqcSnoK5LfxzZfSiA3Q1JzaPkmdLf5tIzw1OxwR1ggyIXLsh3q8buWV4otEFRS6cPF9iRvwlIIEqLn8klIlzFTNC5mVcoVUNuShdEsU03xkTATfnJMKdbFa4P3ArIMDtEV3KEoi5SY5mnpXSPtfstYyVn0Y0nDnBhWpDCdy/2xEq/0B6JxMlqwuWlI8XP+ayn5c7pw7Y2m/3tBZIr8IQftQBYGBrpGQk1eYJmKH9SeT6iX5mXi9ElqGee5UPIb/ogAMdSBWpp2wQwcPC+Xe3vNlt8o1P4XAehElzBhj7loMvqbC+BUVMCLDHJ58Zvy8bE5d54mb4M+tlpZnYU+sgqqm3DHmCZJw4sQ0odXoYQFsQeQOlMXFxN+mMZvDNi0eOoirN8euwLWy/sSbhpP0zp6ELT/kurmqxx//QGfGWHANu8Ab+qhmBm374frL4ehd8dLx28JYCP+0It5br0pJrNi3uaLNKhGexslyCOk=</ds:SignatureValue>
19
+ <ds:KeyInfo>
20
+ <ds:X509Data>
21
+ <ds:X509Certificate>MIIE5TCCAs2gAwIBAgIBfTANBgkqhkiG9w0BAQsFADAsMQ8wDQYDVQQKEwZTQ1RFU1QxGTAXBgNVBAMTEFNBTUwgU2lnbmVyIFRlc3QwHhcNMjMxMjEzMDAwMDAwWhcNMjcxMjEyMDAwMDAwWjAsMQ8wDQYDVQQKEwZTQ1RFU1QxGTAXBgNVBAMTEFNBTUwgU2lnbmVyIFRlc3QwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCpRC2dXoyg26aWMvWshKF6LEGBgti6frNnBDehY/xPtSsaJgfA3Ba413GgGbFS6sxYUlzLR1XljnNvOaPIMfENYVN/JxMCtLZs2/UiqD06F7PwGQzargHgTWxfApEyJFxl08IkDYNILxd7r7kFOiDEAYhHOFqtaCAFz1w8xAAGq5M499TfyPTkbXp/hgBLu2OcNqwjztV3W8WIp8XwwLS7iBW5yZOzTHlWy/SHpl9N1tZscDVPIAumLrDAGHOxrKgVKdRmUqscpe+XoqpubE0841C/Bt/3YQbXxYLTn7R5nqHi59y5MzmlokIQYJ4cS90OQeJZ10yXXuQSyWDBUEKqi3KMzPpQCs0Bnn8yHEtDjFdkpkTVf/iHupsvb4cPwt/XDt0z0pzRXIA+/jFfI/UrvjHskaoBJvBc/Nhi9sisHKtoIuLWfbVQGkkjZgTaIkUCB2X192cuPTNDnwPoHjHYmI364rQ99oWuxAt/U6hs+ipnOP6U2CBW+4+ynqu9GZYji5SR9RgKdG/j5e9uVhe0eKT2rAN88TfIfBz2fRzNU4HV2jExIb6L13SAyO/9WyNC5uv7cJA6VYHG8ygZjoY79HfnZb3wPW3W7a32hr4YD36vKXi+6exFVel1uJNWu3rdGRdTL6OiVF2HW1Gl1iqHwdvh7OR8Upv6TnnlnxlodwIDAQABoxIwEDAOBgNVHQ8BAf8EBAMCAgQwDQYJKoZIhvcNAQELBQADggIBAEXwkfny+YEBO9ALqTrTUK/1baIKi0CLQ/CZ0We/5BFjIp6KQphTGy5lzqtBG3Y68FO6JjOHbcDPZJfyjfniYjuRWh4bHgaBdu7NSdHZW9t6PeT14by6r9/pSjx9llDKchG2gSObuMuH5uI4t3c/hNhW17gv8m5/MzxxBJrR5lULPvMC3+v0Uy60MnycxcPDAi4HQfNkdHf+t3MLwH/HF60OAZ+pXHkahEJn3/F4nKQR8j8rdrn2ZT5mbYwjG27MZ7bhmFLfBtV6jXfQZqDfcxvOP8waEyxyMNah/a5LVQz+PJT+RHF4wqsigiMa248z8YCiz3oj6irpW4Ln/7YnJ8UTatRIOP7xK8hR2gUGtYxHxuGHASqn7tOGRlIdIZFROd28y2HSqgfApg5KUh2eupWMAnXlkcpxiLPz4rx+FX3kditH1z24HzcH2g3ytvL90j/7Gh1cp7BD3e3lf76wzLHUlIH+O573V8XPe1fAyutxsaHIY5S2VHv8fmFODqAS7uPyuZ1pc8gVAJEERGzbLL5WwZETXONrYpRoVIX8ojAU4sOCN8RRnrF5k1obgYj0B72ziXXZ8D/or9AP1BE7npmURLZ2qKM+FeTInZxKcYyLrvpkqVgyX6YM6cV+/XlG3LwS5D8gpKvD5+DIcQMwKXVEXNg3xncodVl2tdPeM8/D</ds:X509Certificate>
22
+ </ds:X509Data>
23
+ </ds:KeyInfo>
24
+ </ds:Signature>
25
+ <md:Extensions>
26
+ <mdattr:EntityAttributes xmlns:mdattr="urn:oasis:names:tc:SAML:metadata:attribute">
27
+ <saml2:Attribute Name="urn:oasis:names:tc:SAML:attribute:assurance-certification" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
28
+ <saml2:AttributeValue>http://eidas.europa.eu/LoA/high</saml2:AttributeValue>
29
+ </saml2:Attribute>
30
+ <saml2:Attribute Name="http://eidas.europa.eu/entity-attributes/protocol-version" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
31
+ <saml2:AttributeValue xsi:type="xsd:string">1.4.1</saml2:AttributeValue>
32
+ </saml2:Attribute>
33
+ <saml2:Attribute Name="http://eidas.europa.eu/entity-attributes/application-identifier" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
34
+ <saml2:AttributeValue xsi:type="xsd:string">German eIDAS Middleware version: 4.1.0</saml2:AttributeValue>
35
+ </saml2:Attribute>
36
+ <saml2:Attribute Name="http://macedir.org/entity-category" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
37
+ <saml2:AttributeValue>http://eidas.europa.eu/entity-attributes/termsofaccess/requesterid</saml2:AttributeValue>
38
+ </saml2:Attribute>
39
+ </mdattr:EntityAttributes>
40
+ <alg:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
41
+ <alg:SigningMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256" MinKeySize="256"/>
42
+ <alg:SigningMethod Algorithm="http://www.w3.org/2007/05/xmldsig-more#sha256-rsa-MGF1" MaxKeySize="4096" MinKeySize="3072"/>
43
+ </md:Extensions>
44
+ <md:IDPSSODescriptor WantAuthnRequestsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
45
+ <md:Extensions>
46
+ <eidas:NodeCountry>DE</eidas:NodeCountry>
47
+ </md:Extensions>
48
+ <md:KeyDescriptor use="signing">
49
+ <ds:KeyInfo>
50
+ <ds:X509Data>
51
+ <ds:X509Certificate>MIIE5TCCAs2gAwIBAgIBfTANBgkqhkiG9w0BAQsFADAsMQ8wDQYDVQQKEwZTQ1RFU1QxGTAXBgNVBAMTEFNBTUwgU2lnbmVyIFRlc3QwHhcNMjMxMjEzMDAwMDAwWhcNMjcxMjEyMDAwMDAwWjAsMQ8wDQYDVQQKEwZTQ1RFU1QxGTAXBgNVBAMTEFNBTUwgU2lnbmVyIFRlc3QwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCpRC2dXoyg26aWMvWshKF6LEGBgti6frNnBDehY/xPtSsaJgfA3Ba413GgGbFS6sxYUlzLR1XljnNvOaPIMfENYVN/JxMCtLZs2/UiqD06F7PwGQzargHgTWxfApEyJFxl08IkDYNILxd7r7kFOiDEAYhHOFqtaCAFz1w8xAAGq5M499TfyPTkbXp/hgBLu2OcNqwjztV3W8WIp8XwwLS7iBW5yZOzTHlWy/SHpl9N1tZscDVPIAumLrDAGHOxrKgVKdRmUqscpe+XoqpubE0841C/Bt/3YQbXxYLTn7R5nqHi59y5MzmlokIQYJ4cS90OQeJZ10yXXuQSyWDBUEKqi3KMzPpQCs0Bnn8yHEtDjFdkpkTVf/iHupsvb4cPwt/XDt0z0pzRXIA+/jFfI/UrvjHskaoBJvBc/Nhi9sisHKtoIuLWfbVQGkkjZgTaIkUCB2X192cuPTNDnwPoHjHYmI364rQ99oWuxAt/U6hs+ipnOP6U2CBW+4+ynqu9GZYji5SR9RgKdG/j5e9uVhe0eKT2rAN88TfIfBz2fRzNU4HV2jExIb6L13SAyO/9WyNC5uv7cJA6VYHG8ygZjoY79HfnZb3wPW3W7a32hr4YD36vKXi+6exFVel1uJNWu3rdGRdTL6OiVF2HW1Gl1iqHwdvh7OR8Upv6TnnlnxlodwIDAQABoxIwEDAOBgNVHQ8BAf8EBAMCAgQwDQYJKoZIhvcNAQELBQADggIBAEXwkfny+YEBO9ALqTrTUK/1baIKi0CLQ/CZ0We/5BFjIp6KQphTGy5lzqtBG3Y68FO6JjOHbcDPZJfyjfniYjuRWh4bHgaBdu7NSdHZW9t6PeT14by6r9/pSjx9llDKchG2gSObuMuH5uI4t3c/hNhW17gv8m5/MzxxBJrR5lULPvMC3+v0Uy60MnycxcPDAi4HQfNkdHf+t3MLwH/HF60OAZ+pXHkahEJn3/F4nKQR8j8rdrn2ZT5mbYwjG27MZ7bhmFLfBtV6jXfQZqDfcxvOP8waEyxyMNah/a5LVQz+PJT+RHF4wqsigiMa248z8YCiz3oj6irpW4Ln/7YnJ8UTatRIOP7xK8hR2gUGtYxHxuGHASqn7tOGRlIdIZFROd28y2HSqgfApg5KUh2eupWMAnXlkcpxiLPz4rx+FX3kditH1z24HzcH2g3ytvL90j/7Gh1cp7BD3e3lf76wzLHUlIH+O573V8XPe1fAyutxsaHIY5S2VHv8fmFODqAS7uPyuZ1pc8gVAJEERGzbLL5WwZETXONrYpRoVIX8ojAU4sOCN8RRnrF5k1obgYj0B72ziXXZ8D/or9AP1BE7npmURLZ2qKM+FeTInZxKcYyLrvpkqVgyX6YM6cV+/XlG3LwS5D8gpKvD5+DIcQMwKXVEXNg3xncodVl2tdPeM8/D</ds:X509Certificate>
52
+ </ds:X509Data>
53
+ </ds:KeyInfo>
54
+ </md:KeyDescriptor>
55
+ <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>
56
+ <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://test.demw.eidas.swedenconnect.se/eidas-middleware/RequestReceiver"/>
57
+ <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://test.demw.eidas.swedenconnect.se/eidas-middleware/RequestReceiver"/>
58
+ <saml2:Attribute FriendlyName="FamilyName" Name="http://eidas.europa.eu/attributes/naturalperson/CurrentFamilyName"/>
59
+ <saml2:Attribute FriendlyName="FirstName" Name="http://eidas.europa.eu/attributes/naturalperson/CurrentGivenName"/>
60
+ <saml2:Attribute FriendlyName="CurrentAddress" Name="http://eidas.europa.eu/attributes/naturalperson/CurrentAddress"/>
61
+ <saml2:Attribute FriendlyName="PersonIdentifier" Name="http://eidas.europa.eu/attributes/naturalperson/PersonIdentifier"/>
62
+ <saml2:Attribute FriendlyName="BirthName" Name="http://eidas.europa.eu/attributes/naturalperson/BirthName"/>
63
+ <saml2:Attribute FriendlyName="PlaceOfBirth" Name="http://eidas.europa.eu/attributes/naturalperson/PlaceOfBirth"/>
64
+ <saml2:Attribute FriendlyName="DateOfBirth" Name="http://eidas.europa.eu/attributes/naturalperson/DateOfBirth"/>
65
+ <saml2:Attribute FriendlyName="Nationality" Name="http://eidas.europa.eu/attributes/naturalperson/Nationality"/>
66
+ </md:IDPSSODescriptor>
67
+ <md:Organization>
68
+ <md:OrganizationName xml:lang="sv">Sweden Connect</md:OrganizationName>
69
+ <md:OrganizationDisplayName xml:lang="sv">Sweden Connect</md:OrganizationDisplayName>
70
+ <md:OrganizationURL xml:lang="sv">https://swedenconnect.se</md:OrganizationURL>
71
+ </md:Organization>
72
+ <md:ContactPerson contactType="technical">
73
+ <md:Company>Sweden Connect</md:Company>
74
+ <md:GivenName>Sweden Connect</md:GivenName>
75
+ <md:SurName>Operations</md:SurName>
76
+ <md:EmailAddress>operations@swedenconnect.se</md:EmailAddress>
77
+ <md:TelephoneNumber>+46105742100</md:TelephoneNumber>
78
+ </md:ContactPerson>
79
+ <md:ContactPerson contactType="support">
80
+ <md:Company>Sweden Connect</md:Company>
81
+ <md:GivenName>Sweden Connect</md:GivenName>
82
+ <md:SurName>Operations</md:SurName>
83
+ <md:EmailAddress>operations@swedenconnect.se</md:EmailAddress>
84
+ <md:TelephoneNumber>+46105742100</md:TelephoneNumber>
85
+ </md:ContactPerson>
86
+ </md:EntityDescriptor>
@@ -770,6 +770,85 @@ class SigningTest(PipeLineTest):
770
770
  with pytest.raises(OSError):
771
771
  self.exec_pipeline("- read_xml:\n file: /nonexistent.xml\n")
772
772
 
773
+ def test_verify_with_multiple_certificates_and_fingerprints(self):
774
+ """A key rollover lists several signers; any one of them must verify."""
775
+ import datetime
776
+ import hashlib
777
+
778
+ from cryptography import x509
779
+ from cryptography.hazmat.primitives import hashes, serialization
780
+ from cryptography.hazmat.primitives.asymmetric import rsa
781
+ from cryptography.x509.oid import NameOID
782
+
783
+ from pyff.constants import NS
784
+ from pyff.exceptions import MetadataException
785
+ from pyff.utils import check_signature
786
+
787
+ other_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
788
+ name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'other')])
789
+ now = datetime.datetime.now(datetime.timezone.utc)
790
+ other_cert = (
791
+ x509.CertificateBuilder()
792
+ .subject_name(name)
793
+ .issuer_name(name)
794
+ .public_key(other_key.public_key())
795
+ .serial_number(x509.random_serial_number())
796
+ .not_valid_before(now)
797
+ .not_valid_after(now + datetime.timedelta(days=2))
798
+ .sign(other_key, hashes.SHA256())
799
+ )
800
+
801
+ with open(self.public_keyspec, 'rb') as fd:
802
+ signer_cert = x509.load_pem_x509_certificate(fd.read())
803
+
804
+ def fingerprint(cert):
805
+ return hashlib.sha1(cert.public_bytes(serialization.Encoding.DER)).hexdigest()
806
+
807
+ tmpdir = tempfile.mkdtemp()
808
+ try:
809
+ src = os.path.join(tmpdir, 'mdsl.xml')
810
+ other_pem = os.path.join(tmpdir, 'other.pem')
811
+ with open(src, 'w') as fd:
812
+ fd.write(
813
+ f'<ser:MetadataServiceList xmlns:ser="{NS["ser"]}" Version="1">'
814
+ '<ser:IssuerName>Test</ser:IssuerName></ser:MetadataServiceList>'
815
+ )
816
+ with open(other_pem, 'wb') as fd:
817
+ fd.write(other_cert.public_bytes(serialization.Encoding.PEM))
818
+ res, _md = self.exec_pipeline(
819
+ f"""
820
+ - read_xml:
821
+ file: {src}
822
+ - sign:
823
+ key: {self.private_keyspec}
824
+ cert: {self.public_keyspec}
825
+ """
826
+ )
827
+
828
+ t = root(res)
829
+ other_pem_text = other_cert.public_bytes(serialization.Encoding.PEM).decode()
830
+ signer_pem_text = signer_cert.public_bytes(serialization.Encoding.PEM).decode()
831
+ for keys in (
832
+ [other_pem, self.public_keyspec],
833
+ [self.public_keyspec, other_pem],
834
+ [other_pem_text, signer_pem_text],
835
+ [signer_pem_text, other_pem_text],
836
+ [fingerprint(other_cert), fingerprint(signer_cert)],
837
+ [fingerprint(signer_cert), fingerprint(other_cert)],
838
+ ):
839
+ check_signature(t, keys, only_one_signature=True)
840
+
841
+ with pytest.raises(MetadataException):
842
+ check_signature(t, other_pem)
843
+ with pytest.raises(MetadataException):
844
+ check_signature(t, [other_pem_text])
845
+ with pytest.raises(MetadataException, match="does not match"):
846
+ check_signature(t, fingerprint(other_cert))
847
+ with pytest.raises(MetadataException, match="cannot be mixed"):
848
+ check_signature(t, [other_pem, fingerprint(signer_cert)])
849
+ finally:
850
+ shutil.rmtree(tmpdir)
851
+
773
852
  def test_signing_and_validation(self):
774
853
  self.output = tempfile.NamedTemporaryFile('w').name
775
854
  _res_s, _md_s, _ctx_s = self.run_pipeline("signer.fd", self)
@@ -1226,6 +1305,44 @@ class SigningTest(PipeLineTest):
1226
1305
  except OSError:
1227
1306
  pass
1228
1307
 
1308
+ def test_xslt_cleanup_keeps_xsi_type_prefixes(self):
1309
+ """XSLT-rewritten metadata must stay well-formed when attributes use xsi:/xsd: prefixes.
1310
+
1311
+ eIDAS middleware metadata (test07-xsi-type-middleware.xml) uses
1312
+ xsi:type="xsd:string". Running it through an xslt step must not produce output
1313
+ with an undeclared prefix ("Undeclared namespace prefix: xsi"), which broke
1314
+ loading in pyuppsala (https://github.com/kushaldas/pyuppsala/issues/36).
1315
+ """
1316
+ entity = 'https://test.demw.eidas.swedenconnect.se/eidas-middleware/Metadata'
1317
+ xsi_type = '{http://www.w3.org/2001/XMLSchema-instance}type'
1318
+ tmpfile = tempfile.NamedTemporaryFile('w').name
1319
+ try:
1320
+ self.exec_pipeline(
1321
+ f"""
1322
+ - when clean:
1323
+ - xslt:
1324
+ stylesheet: tidy.xsl
1325
+ - check_xml_namespaces
1326
+ - break
1327
+ - load:
1328
+ - file://{self.datadir}/metadata/test07-xsi-type-middleware.xml cleanup clean
1329
+ - select
1330
+ - publish: {tmpfile}
1331
+ """
1332
+ )
1333
+ t2 = parse_xml(tmpfile)
1334
+ assert t2 is not None
1335
+ entity_elt = t2.find(".//{{{}}}EntityDescriptor[@entityID='{}']".format(NS['md'], entity))
1336
+ assert entity_elt is not None, "entity was dropped after the xslt step"
1337
+ typed = entity_elt.findall(".//*[@{http://www.w3.org/2001/XMLSchema-instance}type]")
1338
+ assert typed, "xsi:type attributes were lost or lost their namespace"
1339
+ assert all(e.get(xsi_type) == 'xsd:string' for e in typed)
1340
+ finally:
1341
+ try:
1342
+ os.unlink(tmpfile)
1343
+ except OSError:
1344
+ pass
1345
+
1229
1346
  def test_wsfed_type_prefix_survives(self):
1230
1347
  """A complex-type xsi:type prefix (as opposed to a datatype prefix like xs/xsd)
1231
1348
  needs no special-casing: real WS-Fed metadata also carries elements in that
@@ -0,0 +1,45 @@
1
+ from unittest import TestCase
2
+
3
+ from pyuppsala import etree
4
+
5
+ XSI = "http://www.w3.org/2001/XMLSchema-instance"
6
+ XSL_HEAD = b'<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">'
7
+
8
+
9
+ def _transform(stylesheet: bytes, source: bytes):
10
+ result = etree.XSLT(etree.fromstring(stylesheet))(etree.fromstring(source))
11
+ # getroot() reparses the serialized output, so an undeclared prefix raises here
12
+ return result.getroot()
13
+
14
+
15
+ class TestXSLTNamespaceDeclarations(TestCase):
16
+ """Created attributes must carry the namespace declaration they need (XSLT 1.0 7.1.3).
17
+
18
+ Regression tests for https://github.com/kushaldas/pyuppsala/issues/36.
19
+ """
20
+
21
+ def test_attribute_copied_into_literal_result_element(self):
22
+ xsl = (
23
+ XSL_HEAD
24
+ + b"""
25
+ <xsl:template match="/root"><out><xsl:apply-templates select="node()|@*"/></out></xsl:template>
26
+ <xsl:template match="*"><xsl:copy><xsl:apply-templates select="node()|@*"/></xsl:copy></xsl:template>
27
+ <xsl:template match="text()|@*"><xsl:copy/></xsl:template>
28
+ </xsl:stylesheet>"""
29
+ )
30
+ src = b'<root xmlns:xsi="%s" xsi:type="xsd:string"><a xsi:type="x"/></root>' % XSI.encode()
31
+ out = _transform(xsl, src)
32
+ assert out.get('{%s}type' % XSI) == 'xsd:string'
33
+ assert out[0].get('{%s}type' % XSI) == 'x'
34
+
35
+ def test_prefix_declared_only_on_dropped_element(self):
36
+ xsl = (
37
+ XSL_HEAD
38
+ + b"""
39
+ <xsl:template match="/"><out><xsl:copy-of select="//a/@*"/><b><xsl:copy-of select="//a/@*"/></b></out></xsl:template>
40
+ </xsl:stylesheet>"""
41
+ )
42
+ src = b'<root><a xmlns:xsi="%s" xsi:type="t"/></root>' % XSI.encode()
43
+ out = _transform(xsl, src)
44
+ assert out.get('{%s}type' % XSI) == 't'
45
+ assert out[0].get('{%s}type' % XSI) == 't'
@@ -17,7 +17,7 @@ import threading
17
17
  import time
18
18
  import traceback
19
19
  from _collections_abc import Mapping, MutableMapping
20
- from collections.abc import Sequence
20
+ from collections.abc import Iterable, Sequence
21
21
  from copy import copy
22
22
  from datetime import datetime, timedelta, timezone
23
23
  from email.utils import parsedate
@@ -271,6 +271,9 @@ def redis():
271
271
  return thread_data.redis
272
272
 
273
273
 
274
+ PEM_CERT_PREFIX = '-----BEGIN CERTIFICATE-----'
275
+
276
+
274
277
  def _looks_like_sha1_fingerprint(key: str) -> bool:
275
278
  """Return True if ``key`` looks like a SHA1 certificate fingerprint.
276
279
 
@@ -283,39 +286,71 @@ def _looks_like_sha1_fingerprint(key: str) -> bool:
283
286
  return len(candidate) == 40 and all(c in '0123456789abcdefABCDEF' for c in candidate)
284
287
 
285
288
 
286
- def check_signature(t: ElementTree, key: Optional[str], only_one_signature: bool = False) -> ElementTree:
289
+ def check_signature(
290
+ t: ElementTree,
291
+ key: Optional[Union[str, Iterable[str]]],
292
+ only_one_signature: bool = False,
293
+ source: Optional[str] = None,
294
+ ) -> ElementTree:
287
295
  """Verify the XML signature on ``t`` using pybergshamra.
288
296
 
289
- ``key`` is either the path to a PEM-encoded X.509 certificate (the trusted
290
- signer) or a SHA1 fingerprint of the expected signing certificate. When a
291
- fingerprint is supplied the certificate embedded in the signature's KeyInfo
292
- is used to verify, and the fingerprint is checked against it afterwards.
297
+ ``key`` is one verifier or an iterable of them, which allows key rollovers:
298
+ the signature is accepted if it was made by any one of them. A verifier is
299
+ either a path to a PEM-encoded X.509 certificate file, the PEM text of a
300
+ certificate, or a SHA1 fingerprint of the expected signing certificate.
301
+ Fingerprints cannot be mixed with certificates.
302
+
303
+ Certificates are the trusted signing keys. When fingerprints are supplied
304
+ the certificate embedded in the signature's KeyInfo is used to verify, and
305
+ the fingerprint is checked against it afterwards.
306
+
307
+ ``source`` (typically the URL) is only used to identify the signed document in log messages.
293
308
  """
294
309
  if key is None:
295
310
  return t
296
311
 
297
- log.debug(f"verifying signature using {key}")
312
+ of_source = f" of {source}" if source else ""
313
+
314
+ items = [key] if isinstance(key, str) else list(key)
315
+ items = [item.strip() for item in items if item.strip()]
316
+ if not items:
317
+ raise MetadataException("No certificate or fingerprint given to verify the signature")
318
+
319
+ log.debug(f"verifying signature{of_source} using {len(items)} verifier(s)")
298
320
 
299
321
  mgr = pybergshamra.KeysManager()
300
322
  ctx = pybergshamra.DsigContext(mgr)
301
323
 
302
- pinned_fingerprint = None
303
- if _looks_like_sha1_fingerprint(key) and not os.path.exists(key):
324
+ is_fingerprint = [
325
+ not item.startswith(PEM_CERT_PREFIX) and _looks_like_sha1_fingerprint(item) and not os.path.exists(item)
326
+ for item in items
327
+ ]
328
+ if any(is_fingerprint) and not all(is_fingerprint):
329
+ raise MetadataException("Certificates and fingerprints cannot be mixed in a single verification")
330
+
331
+ pinned_fingerprints: set[str] = set()
332
+ if all(is_fingerprint):
304
333
  # Fingerprint pinning: trust the cert carried inside the signature's
305
- # KeyInfo/X509Data, then compare its SHA1 to the pinned fingerprint.
334
+ # KeyInfo/X509Data, then compare its SHA1 to the pinned fingerprints.
306
335
  # The fingerprint is the trust anchor here, so PKI chain validation is
307
336
  # disabled (insecure) - the embedded signing cert is typically
308
337
  # self-signed and would otherwise fail to chain to a trusted root.
309
- pinned_fingerprint = key.replace(':', '').replace(' ', '').strip().lower()
338
+ pinned_fingerprints = {item.replace(':', '').replace(' ', '').lower() for item in items}
310
339
  ctx.enabled_key_data_x509 = True
311
340
  ctx.trusted_keys_only = False
312
341
  ctx.insecure = True
313
342
  else:
314
- # Trusted-cert path: load the PEM certificate and require it as the
315
- # only acceptable signing key (skip inline KeyInfo extraction).
316
- with open(key, 'rb') as fd:
317
- cert_key = pybergshamra.load_x509_cert_pem(fd.read())
318
- mgr.add_key(cert_key)
343
+ # Trusted-cert path: load the PEM certificates and require them as the
344
+ # only acceptable signing keys (skip inline KeyInfo extraction).
345
+ cert_keys = []
346
+ for item in items:
347
+ if item.startswith(PEM_CERT_PREFIX):
348
+ pem = item.encode()
349
+ else:
350
+ with open(item, 'rb') as fd:
351
+ pem = fd.read()
352
+ cert_keys.append(pybergshamra.load_x509_cert_pem(pem))
353
+ mgr.add_keys(cert_keys)
319
354
  ctx.trusted_keys_only = True
320
355
 
321
356
  relt = root(t)
@@ -340,16 +375,25 @@ def check_signature(t: ElementTree, key: Optional[str], only_one_signature: bool
340
375
  "XML metadata contains %d signatures - exactly 1 is required" % len(references)
341
376
  )
342
377
 
343
- if pinned_fingerprint is not None:
378
+ # The leaf certificate that verified the signature: the matching trusted key
379
+ # in certificate mode, the one embedded in KeyInfo in fingerprint mode.
380
+ chain = result.key_info.x509_chain if result.key_info is not None else []
381
+ actual_fingerprint = hashlib.sha1(chain[0]).hexdigest().lower() if chain else None
382
+ if actual_fingerprint is not None:
383
+ log.debug(
384
+ f"signature{of_source} verified by certificate "
385
+ + ":".join(actual_fingerprint[i : i + 2] for i in range(0, len(actual_fingerprint), 2))
386
+ )
387
+
388
+ if pinned_fingerprints:
344
389
  # Confirm the certificate that actually verified the signature matches
345
- # the pinned fingerprint (SHA1 over the DER-encoded leaf certificate).
346
- chain = result.key_info.x509_chain if result.key_info is not None else []
347
- if not chain:
390
+ # one of the pinned fingerprints (SHA1 over the DER-encoded leaf certificate).
391
+ if actual_fingerprint is None:
348
392
  raise MetadataException("No certificate found in signature to match fingerprint")
349
- actual_fingerprint = hashlib.sha1(chain[0]).hexdigest().lower()
350
- if actual_fingerprint != pinned_fingerprint:
393
+ if actual_fingerprint not in pinned_fingerprints:
351
394
  raise MetadataException(
352
- f"Signing certificate fingerprint {actual_fingerprint} does not match expected {pinned_fingerprint}"
395
+ f"Signing certificate fingerprint {actual_fingerprint} does not match expected "
396
+ f"{', '.join(sorted(pinned_fingerprints))}"
353
397
  )
354
398
 
355
399
  # Anti-wrapping: the old code returned the verified reference subtree as the
@@ -396,6 +440,13 @@ def cert_dict(t):
396
440
  return certs
397
441
 
398
442
 
443
+ def b64_to_pem(cert_b64: str) -> str:
444
+ """Wrap the base64 text of a DER certificate (as found in ``X509Certificate``) as PEM."""
445
+ body = "".join(cert_b64.split())
446
+ lines = [body[i : i + 64] for i in range(0, len(body), 64)]
447
+ return "\n".join([PEM_CERT_PREFIX, *lines, "-----END CERTIFICATE-----", ""])
448
+
449
+
399
450
  class CertInfo:
400
451
  """Lightweight wrapper around a parsed X.509 certificate.
401
452
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes