|
| 1 | +"""Build a WS-Security (WSSE) secured SOAP request: UsernameToken + Sign + Encrypt. |
| 2 | +
|
| 3 | +xmlsec exposes XML-DSig and XML-Enc primitives, but WS-Security profiles |
| 4 | +build a specific *combination* of them inside a SOAP ``<wsse:Security>`` |
| 5 | +header that xmlsec does not template for you. This example shows one |
| 6 | +common, interoperable combination: |
| 7 | +
|
| 8 | +1. A UsernameToken (plain-text password) in the Security header. |
| 9 | +2. An enveloped-style XML signature over the SOAP Body, referenced by an |
| 10 | + ``Id`` attribute (WS-Security "Reference" pattern, not the enveloped |
| 11 | + XPath transform). |
| 12 | +3. A "detached" ``xenc:EncryptedKey`` living directly under |
| 13 | + ``wsse:Security`` (not nested inside the ``EncryptedData`` it protects, |
| 14 | + which is where ``encrypt_xml``/``encrypt_binary`` would normally put |
| 15 | + it). This is the shape most SOAP stacks expect for WS-Security 1.1. |
| 16 | +
|
| 17 | +Because that detached ``EncryptedKey`` is not part of the ``EncryptedData`` |
| 18 | +tree, xmlsec's ``EncryptionContext.encrypt_binary``/``encrypt_xml`` cannot |
| 19 | +fill it in directly -- both require an ``xenc:EncryptedData`` node as the |
| 20 | +target template (see ``xmlSecEncCtxEncDataNodeRead`` in xmlsec1, which |
| 21 | +rejects an ``EncryptedKey`` node with "invalid node"). So this example |
| 22 | +wraps the AES session key with RSA-OAEP using the ``cryptography`` package |
| 23 | +and places the result in the ``EncryptedKey``'s ``CipherValue`` by hand, |
| 24 | +while still using xmlsec's own template helpers to build the element and |
| 25 | +xmlsec's ``EncryptionContext`` to encrypt the Body itself. |
| 26 | +
|
| 27 | +Order matters: this example signs the Body *before* encrypting it, so the |
| 28 | +signature covers the plaintext. A receiver must therefore decrypt first |
| 29 | +and verify second -- see ``wsse_incoming.py``. If you instead need to |
| 30 | +encrypt before signing (e.g. because a peer's stack requires the signature |
| 31 | +to cover ciphertext), reverse the two steps below and sign the |
| 32 | +``EncryptedData``'s ``Id`` instead of the Body's. |
| 33 | +""" |
| 34 | + |
| 35 | +import base64 |
| 36 | +import os |
| 37 | + |
| 38 | +from cryptography import x509 |
| 39 | +from cryptography.hazmat.primitives import hashes |
| 40 | +from cryptography.hazmat.primitives.asymmetric import padding |
| 41 | +from lxml import etree |
| 42 | + |
| 43 | +import xmlsec |
| 44 | + |
| 45 | +NS = { |
| 46 | + 'soapenv': 'http://schemas.xmlsoap.org/soap/envelope/', |
| 47 | + 'wsse': 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd', |
| 48 | + 'wsu': 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd', |
| 49 | + 'ds': 'http://www.w3.org/2000/09/xmldsig#', |
| 50 | + 'xenc': 'http://www.w3.org/2001/04/xmlenc#', |
| 51 | +} |
| 52 | + |
| 53 | +# Recommended defaults. Some legacy WS-Security 1.0-era stacks only accept |
| 54 | +# rsa-sha1 / tripledes-cbc / rsa-1_5 -- swap the constants below (and the |
| 55 | +# `cryptography` padding/hash objects to match) if you must interop with |
| 56 | +# one of those, but prefer these unless you are told otherwise. |
| 57 | +SIGNATURE_TRANSFORM = xmlsec.constants.TransformRsaSha256 |
| 58 | +DIGEST_TRANSFORM = xmlsec.constants.TransformSha256 |
| 59 | +BODY_ENCRYPTION_TRANSFORM = xmlsec.constants.TransformAes128Cbc |
| 60 | +KEY_WRAP_HASH = hashes.SHA256() |
| 61 | + |
| 62 | + |
| 63 | +def add_security_header(envelope): |
| 64 | + header = envelope.find('soapenv:Header', NS) |
| 65 | + if header is None: |
| 66 | + header = etree.SubElement(envelope, f'{{{NS["soapenv"]}}}Header') |
| 67 | + envelope.insert(0, header) |
| 68 | + return etree.SubElement( |
| 69 | + header, |
| 70 | + f'{{{NS["wsse"]}}}Security', |
| 71 | + nsmap={'wsse': NS['wsse'], 'wsu': NS['wsu']}, |
| 72 | + ) |
| 73 | + |
| 74 | + |
| 75 | +def add_username_token(security, username, password): |
| 76 | + token = etree.SubElement(security, f'{{{NS["wsse"]}}}UsernameToken') |
| 77 | + etree.SubElement(token, f'{{{NS["wsse"]}}}Username').text = username |
| 78 | + password_elem = etree.SubElement(token, f'{{{NS["wsse"]}}}Password') |
| 79 | + password_elem.set( |
| 80 | + 'Type', |
| 81 | + 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText', |
| 82 | + ) |
| 83 | + password_elem.text = password |
| 84 | + return token |
| 85 | + |
| 86 | + |
| 87 | +def sign_body(envelope, security, signing_key_file, body_id='body'): |
| 88 | + body = envelope.find('soapenv:Body', NS) |
| 89 | + body.set(f'{{{NS["wsu"]}}}Id', body_id) |
| 90 | + xmlsec.tree.add_ids(envelope, [f'{{{NS["wsu"]}}}Id', 'Id', 'id']) |
| 91 | + |
| 92 | + signature_node = xmlsec.template.create(envelope, xmlsec.constants.TransformExclC14N, SIGNATURE_TRANSFORM) |
| 93 | + security.append(signature_node) |
| 94 | + |
| 95 | + reference = xmlsec.template.add_reference(signature_node, DIGEST_TRANSFORM, uri='#' + body_id) |
| 96 | + xmlsec.template.add_transform(reference, xmlsec.constants.TransformExclC14N) |
| 97 | + |
| 98 | + ctx = xmlsec.SignatureContext() |
| 99 | + ctx.key = xmlsec.Key.from_file(signing_key_file, xmlsec.constants.KeyDataFormatPem) |
| 100 | + ctx.sign(signature_node) |
| 101 | + return signature_node |
| 102 | + |
| 103 | + |
| 104 | +def encrypt_body(envelope, security, recipient_cert_file, data_id='body-data', key_id='body-key'): |
| 105 | + body = envelope.find('soapenv:Body', NS) |
| 106 | + |
| 107 | + with open(recipient_cert_file, 'rb') as fp: |
| 108 | + recipient_cert = x509.load_pem_x509_certificate(fp.read()) |
| 109 | + |
| 110 | + # 1. Generate a random session key and use it to encrypt the Body |
| 111 | + # content with xmlsec, exactly like the plain `encrypt.py` example. |
| 112 | + session_key_bytes = os.urandom(16) # 16 bytes = AES-128 |
| 113 | + |
| 114 | + enc_data_template = xmlsec.template.encrypted_data_create( |
| 115 | + body, |
| 116 | + BODY_ENCRYPTION_TRANSFORM, |
| 117 | + type=xmlsec.constants.TypeEncContent, |
| 118 | + ns='xenc', |
| 119 | + ) |
| 120 | + enc_data_template.set('Id', data_id) |
| 121 | + xmlsec.template.encrypted_data_ensure_cipher_value(enc_data_template) |
| 122 | + |
| 123 | + enc_ctx = xmlsec.EncryptionContext() |
| 124 | + enc_ctx.key = xmlsec.Key.from_binary_data(xmlsec.constants.KeyDataAes, session_key_bytes) |
| 125 | + enc_ctx.encrypt_xml(enc_data_template, body) |
| 126 | + |
| 127 | + # 2. Wrap the session key with the recipient's RSA public key ourselves |
| 128 | + # (see module docstring for why xmlsec can't do this part for a |
| 129 | + # detached EncryptedKey), and place it directly under Security. |
| 130 | + wrapped_key = recipient_cert.public_key().encrypt( |
| 131 | + session_key_bytes, |
| 132 | + padding.OAEP(mgf=padding.MGF1(algorithm=KEY_WRAP_HASH), algorithm=KEY_WRAP_HASH, label=None), |
| 133 | + ) |
| 134 | + |
| 135 | + enc_key_node = xmlsec.template.add_encrypted_key(security, xmlsec.constants.TransformRsaOaep, id=key_id) |
| 136 | + xmlsec.template.encrypted_data_ensure_cipher_value(enc_key_node).text = base64.b64encode(wrapped_key).decode() |
| 137 | + |
| 138 | + reference_list = etree.SubElement(enc_key_node, f'{{{NS["xenc"]}}}ReferenceList') |
| 139 | + etree.SubElement(reference_list, f'{{{NS["xenc"]}}}DataReference').set('URI', '#' + data_id) |
| 140 | + |
| 141 | + # A detached EncryptedKey must come before anything that references it, |
| 142 | + # so it is expected as the first child of Security by most consumers. |
| 143 | + security.remove(enc_key_node) |
| 144 | + security.insert(0, enc_key_node) |
| 145 | + |
| 146 | + |
| 147 | +if __name__ == '__main__': |
| 148 | + with open('wsse-tmpl.xml') as fp: |
| 149 | + envelope = etree.parse(fp, etree.XMLParser(remove_blank_text=True)).getroot() |
| 150 | + |
| 151 | + security = add_security_header(envelope) |
| 152 | + add_username_token(security, username='demo-user', password='demo-password') |
| 153 | + sign_body(envelope, security, signing_key_file='wssekey.pem') |
| 154 | + encrypt_body(envelope, security, recipient_cert_file='wssecert.pem') |
| 155 | + |
| 156 | + # NOTE: do not pretty-print before/after signing -- inserting |
| 157 | + # whitespace-only text nodes changes what exclusive C14N canonicalizes, |
| 158 | + # so a pretty-printed copy would no longer verify. |
| 159 | + print(etree.tostring(envelope).decode()) |
0 commit comments