1"""Functions for WS-Security (WSSE) signature creation and verification.
2
3Heavily based on test examples in https://github.com/mehcode/python-xmlsec as
4well as the xmlsec documentation at https://www.aleksey.com/xmlsec/.
5
6Reading the xmldsig, xmlenc, and ws-security standards documents, though
7admittedly painful, will likely assist in understanding the code in this
8module.
9
10"""
11from lxml import etree
12from lxml.etree import QName
13
14from zeep import ns
15from zeep.exceptions import SignatureVerificationFailed
16from zeep.utils import detect_soap_env
17from zeep.wsse.utils import ensure_id, get_security_header
18
19try:
20    import xmlsec
21except ImportError:
22    xmlsec = None
23
24
25# SOAP envelope
26SOAP_NS = "http://schemas.xmlsoap.org/soap/envelope/"
27
28
29def _read_file(f_name):
30    with open(f_name, "rb") as f:
31        return f.read()
32
33
34def _make_sign_key(key_data, cert_data, password):
35    key = xmlsec.Key.from_memory(key_data, xmlsec.KeyFormat.PEM, password)
36    key.load_cert_from_memory(cert_data, xmlsec.KeyFormat.PEM)
37    return key
38
39
40def _make_verify_key(cert_data):
41    key = xmlsec.Key.from_memory(cert_data, xmlsec.KeyFormat.CERT_PEM, None)
42    return key
43
44
45class MemorySignature:
46    """Sign given SOAP envelope with WSSE sig using given key and cert."""
47
48    def __init__(
49        self,
50        key_data,
51        cert_data,
52        password=None,
53        signature_method=None,
54        digest_method=None,
55    ):
56        check_xmlsec_import()
57
58        self.key_data = key_data
59        self.cert_data = cert_data
60        self.password = password
61        self.digest_method = digest_method
62        self.signature_method = signature_method
63
64    def apply(self, envelope, headers):
65        key = _make_sign_key(self.key_data, self.cert_data, self.password)
66        _sign_envelope_with_key(
67            envelope, key, self.signature_method, self.digest_method
68        )
69        return envelope, headers
70
71    def verify(self, envelope):
72        key = _make_verify_key(self.cert_data)
73        _verify_envelope_with_key(envelope, key)
74        return envelope
75
76
77class Signature(MemorySignature):
78    """Sign given SOAP envelope with WSSE sig using given key file and cert file."""
79
80    def __init__(
81        self,
82        key_file,
83        certfile,
84        password=None,
85        signature_method=None,
86        digest_method=None,
87    ):
88        super().__init__(
89            _read_file(key_file),
90            _read_file(certfile),
91            password,
92            signature_method,
93            digest_method,
94        )
95
96
97class BinarySignature(Signature):
98    """Sign given SOAP envelope with WSSE sig using given key file and cert file.
99
100    Place the key information into BinarySecurityElement."""
101
102    def apply(self, envelope, headers):
103        key = _make_sign_key(self.key_data, self.cert_data, self.password)
104        _sign_envelope_with_key_binary(
105            envelope, key, self.signature_method, self.digest_method
106        )
107        return envelope, headers
108
109
110def check_xmlsec_import():
111    if xmlsec is None:
112        raise ImportError(
113            "The xmlsec module is required for wsse.Signature()\n"
114            + "You can install xmlsec with: pip install xmlsec\n"
115            + "or install zeep via: pip install zeep[xmlsec]\n"
116        )
117
118
119def sign_envelope(
120    envelope,
121    keyfile,
122    certfile,
123    password=None,
124    signature_method=None,
125    digest_method=None,
126):
127    """Sign given SOAP envelope with WSSE sig using given key and cert.
128
129    Sign the wsu:Timestamp node in the wsse:Security header and the soap:Body;
130    both must be present.
131
132    Add a ds:Signature node in the wsse:Security header containing the
133    signature.
134
135    Use EXCL-C14N transforms to normalize the signed XML (so that irrelevant
136    whitespace or attribute ordering changes don't invalidate the
137    signature). Use SHA1 signatures.
138
139    Expects to sign an incoming document something like this (xmlns attributes
140    omitted for readability):
141
142    <soap:Envelope>
143      <soap:Header>
144        <wsse:Security mustUnderstand="true">
145          <wsu:Timestamp>
146            <wsu:Created>2015-06-25T21:53:25.246276+00:00</wsu:Created>
147            <wsu:Expires>2015-06-25T21:58:25.246276+00:00</wsu:Expires>
148          </wsu:Timestamp>
149        </wsse:Security>
150      </soap:Header>
151      <soap:Body>
152        ...
153      </soap:Body>
154    </soap:Envelope>
155
156    After signing, the sample document would look something like this (note the
157    added wsu:Id attr on the soap:Body and wsu:Timestamp nodes, and the added
158    ds:Signature node in the header, with ds:Reference nodes with URI attribute
159    referencing the wsu:Id of the signed nodes):
160
161    <soap:Envelope>
162      <soap:Header>
163        <wsse:Security mustUnderstand="true">
164          <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
165            <SignedInfo>
166              <CanonicalizationMethod
167                  Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
168              <SignatureMethod
169                  Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
170              <Reference URI="#id-d0f9fd77-f193-471f-8bab-ba9c5afa3e76">
171                <Transforms>
172                  <Transform
173                      Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
174                </Transforms>
175                <DigestMethod
176                    Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
177                <DigestValue>nnjjqTKxwl1hT/2RUsBuszgjTbI=</DigestValue>
178              </Reference>
179              <Reference URI="#id-7c425ac1-534a-4478-b5fe-6cae0690f08d">
180                <Transforms>
181                  <Transform
182                      Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
183                </Transforms>
184                <DigestMethod
185                    Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
186                <DigestValue>qAATZaSqAr9fta9ApbGrFWDuCCQ=</DigestValue>
187              </Reference>
188            </SignedInfo>
189            <SignatureValue>Hz8jtQb...bOdT6ZdTQ==</SignatureValue>
190            <KeyInfo>
191              <wsse:SecurityTokenReference>
192                <X509Data>
193                  <X509Certificate>MIIDnzC...Ia2qKQ==</X509Certificate>
194                  <X509IssuerSerial>
195                    <X509IssuerName>...</X509IssuerName>
196                    <X509SerialNumber>...</X509SerialNumber>
197                  </X509IssuerSerial>
198                </X509Data>
199              </wsse:SecurityTokenReference>
200            </KeyInfo>
201          </Signature>
202          <wsu:Timestamp wsu:Id="id-7c425ac1-534a-4478-b5fe-6cae0690f08d">
203            <wsu:Created>2015-06-25T22:00:29.821700+00:00</wsu:Created>
204            <wsu:Expires>2015-06-25T22:05:29.821700+00:00</wsu:Expires>
205          </wsu:Timestamp>
206        </wsse:Security>
207      </soap:Header>
208      <soap:Body wsu:Id="id-d0f9fd77-f193-471f-8bab-ba9c5afa3e76">
209        ...
210      </soap:Body>
211    </soap:Envelope>
212
213    """
214    # Load the signing key and certificate.
215    key = _make_sign_key(_read_file(keyfile), _read_file(certfile), password)
216    return _sign_envelope_with_key(envelope, key, signature_method, digest_method)
217
218
219def _signature_prepare(envelope, key, signature_method, digest_method):
220    """Prepare envelope and sign."""
221    soap_env = detect_soap_env(envelope)
222
223    # Create the Signature node.
224    signature = xmlsec.template.create(
225        envelope,
226        xmlsec.Transform.EXCL_C14N,
227        signature_method or xmlsec.Transform.RSA_SHA1,
228    )
229
230    # Add a KeyInfo node with X509Data child to the Signature. XMLSec will fill
231    # in this template with the actual certificate details when it signs.
232    key_info = xmlsec.template.ensure_key_info(signature)
233    x509_data = xmlsec.template.add_x509_data(key_info)
234    xmlsec.template.x509_data_add_issuer_serial(x509_data)
235    xmlsec.template.x509_data_add_certificate(x509_data)
236
237    # Insert the Signature node in the wsse:Security header.
238    security = get_security_header(envelope)
239    security.insert(0, signature)
240
241    # Perform the actual signing.
242    ctx = xmlsec.SignatureContext()
243    ctx.key = key
244    _sign_node(ctx, signature, envelope.find(QName(soap_env, "Body")), digest_method)
245    timestamp = security.find(QName(ns.WSU, "Timestamp"))
246    if timestamp != None:
247        _sign_node(ctx, signature, timestamp, digest_method)
248    ctx.sign(signature)
249
250    # Place the X509 data inside a WSSE SecurityTokenReference within
251    # KeyInfo. The recipient expects this structure, but we can't rearrange
252    # like this until after signing, because otherwise xmlsec won't populate
253    # the X509 data (because it doesn't understand WSSE).
254    sec_token_ref = etree.SubElement(key_info, QName(ns.WSSE, "SecurityTokenReference"))
255    return security, sec_token_ref, x509_data
256
257
258def _sign_envelope_with_key(envelope, key, signature_method, digest_method):
259    _, sec_token_ref, x509_data = _signature_prepare(
260        envelope, key, signature_method, digest_method
261    )
262    sec_token_ref.append(x509_data)
263
264
265def _sign_envelope_with_key_binary(envelope, key, signature_method, digest_method):
266    security, sec_token_ref, x509_data = _signature_prepare(
267        envelope, key, signature_method, digest_method
268    )
269    ref = etree.SubElement(
270        sec_token_ref,
271        QName(ns.WSSE, "Reference"),
272        {
273            "ValueType": "http://docs.oasis-open.org/wss/2004/01/"
274            "oasis-200401-wss-x509-token-profile-1.0#X509v3"
275        },
276    )
277    bintok = etree.Element(
278        QName(ns.WSSE, "BinarySecurityToken"),
279        {
280            "ValueType": "http://docs.oasis-open.org/wss/2004/01/"
281            "oasis-200401-wss-x509-token-profile-1.0#X509v3",
282            "EncodingType": "http://docs.oasis-open.org/wss/2004/01/"
283            "oasis-200401-wss-soap-message-security-1.0#Base64Binary",
284        },
285    )
286    ref.attrib["URI"] = "#" + ensure_id(bintok)
287    bintok.text = x509_data.find(QName(ns.DS, "X509Certificate")).text
288    security.insert(1, bintok)
289    x509_data.getparent().remove(x509_data)
290
291
292def verify_envelope(envelope, certfile):
293    """Verify WS-Security signature on given SOAP envelope with given cert.
294
295    Expects a document like that found in the sample XML in the ``sign()``
296    docstring.
297
298    Raise SignatureVerificationFailed on failure, silent on success.
299
300    """
301    key = _make_verify_key(_read_file(certfile))
302    return _verify_envelope_with_key(envelope, key)
303
304
305def _verify_envelope_with_key(envelope, key):
306    soap_env = detect_soap_env(envelope)
307
308    header = envelope.find(QName(soap_env, "Header"))
309    if header is None:
310        raise SignatureVerificationFailed()
311
312    security = header.find(QName(ns.WSSE, "Security"))
313    signature = security.find(QName(ns.DS, "Signature"))
314
315    ctx = xmlsec.SignatureContext()
316
317    # Find each signed element and register its ID with the signing context.
318    refs = signature.xpath("ds:SignedInfo/ds:Reference", namespaces={"ds": ns.DS})
319    for ref in refs:
320        # Get the reference URI and cut off the initial '#'
321        referenced_id = ref.get("URI")[1:]
322        referenced = envelope.xpath(
323            "//*[@wsu:Id='%s']" % referenced_id, namespaces={"wsu": ns.WSU}
324        )[0]
325        ctx.register_id(referenced, "Id", ns.WSU)
326
327    ctx.key = key
328
329    try:
330        ctx.verify(signature)
331    except xmlsec.Error:
332        # Sadly xmlsec gives us no details about the reason for the failure, so
333        # we have nothing to pass on except that verification failed.
334        raise SignatureVerificationFailed()
335
336
337def _sign_node(ctx, signature, target, digest_method=None):
338    """Add sig for ``target`` in ``signature`` node, using ``ctx`` context.
339
340    Doesn't actually perform the signing; ``ctx.sign(signature)`` should be
341    called later to do that.
342
343    Adds a Reference node to the signature with URI attribute pointing to the
344    target node, and registers the target node's ID so XMLSec will be able to
345    find the target node by ID when it signs.
346
347    """
348
349    # Ensure the target node has a wsu:Id attribute and get its value.
350    node_id = ensure_id(target)
351
352    # Unlike HTML, XML doesn't have a single standardized Id. WSSE suggests the
353    # use of the wsu:Id attribute for this purpose, but XMLSec doesn't
354    # understand that natively. So for XMLSec to be able to find the referenced
355    # node by id, we have to tell xmlsec about it using the register_id method.
356    ctx.register_id(target, "Id", ns.WSU)
357
358    # Add reference to signature with URI attribute pointing to that ID.
359    ref = xmlsec.template.add_reference(
360        signature, digest_method or xmlsec.Transform.SHA1, uri="#" + node_id
361    )
362    # This is an XML normalization transform which will be performed on the
363    # target node contents before signing. This ensures that changes to
364    # irrelevant whitespace, attribute ordering, etc won't invalidate the
365    # signature.
366    xmlsec.template.add_transform(ref, xmlsec.Transform.EXCL_C14N)
367