pythonpython-3.xxml-signature

SignXML Verifier Fails When XML Signature Uses Default Namespace Without ds: Prefix


I'm working on generating a signed XML document where the element (and related elements like , , etc.) must not include the ds: prefix. This is because the legacy application I’m integrating with expects the XML Digital Signature block to be present without any namespace prefixes—only the Signature tag and children in the correct namespace (i.e., http://www.w3.org/2000/09/xmldsig#), but without the ds: prefix.

I’m using signxml to generate the signature. Signing works fine when I override the namespace as:

signer.namespaces = {None: signxml.namespaces.ds}

This gives me the desired XML structure (no ds:), and the output looks valid. However, when I try to verify the signature using signxml.XMLVerifier().verify(...), it fails. The same test passes if I don't override the namespace and let SignXML add the ds: prefix.

Test Result

self = <signxml.verifier.XMLVerifier object at 0xffffadce0c50>, element = <Element {http://www.w3.org/2000/09/xmldsig#}Reference at 0xffffacd2e9c0>, query = 'DigestMethod'
require = True, xpath = ''

    def _find(self, element, query, require=True, xpath=""):
        namespace = "ds"
        if ":" in query:
            namespace, _, query = query.partition(":")
        result = element.find(f"{xpath}{namespace}:{query}", namespaces=namespaces)
    
        if require and result is None:
>           raise InvalidInput(f"Expected to find XML element {query} in {element.tag}")
E           signxml.exceptions.InvalidInput: Expected to find XML element DigestMethod in {http://www.w3.org/2000/09/xmldsig#}Reference

/py/lib/python3.12/site-packages/signxml/processor.py:98: InvalidInput
================================================================================ short test summary info ================================================================================
FAILED test_signxml.py::test_signed_without_ns_success - signxml.exceptions.InvalidInput: Expected to find XML element DigestMethod in {http://www.w3.org/2000/09/xmldsig#}Reference
============================================================================== 1 failed, 1 passed in 0.21s ==============================================================================

Test Code

"""Test Certificate SetUp

.. code-block:: bash

openssl genrsa -out private.key 4096
openssl req -new -key private.key -out request.csr
openssl x509 -req -days 365 -in request.csr -signkey private.key -out certificate.crt


"""

from xml.etree.ElementTree import Element, SubElement, tostring

import lxml
import signxml
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization

PRIVATE_KEY_PATH = "/app/private.key"
CERTIFICATE_PATH = "/app/certificate.crt"


def load_cert_pub(cert_path: str):
    with open(cert_path, "rb") as cert_file:
        cert_data = cert_file.read()
    cert = x509.load_pem_x509_certificate(cert_data, default_backend())
    return cert, cert.public_key()


def load_private_key(key_path: str, password=None):
    with open(key_path, "rb") as key_file:
        key_data = key_file.read()
    key = serialization.load_pem_private_key(
        key_data, password=password, backend=default_backend()
    )
    return key


def create_sample_xml():
    root = Element("ROOT")

    # Section Data
    data_sec = SubElement(root, "DATA")

    pid_elem = SubElement(data_sec, "ID")
    pid_elem.text = "encrypted_some_data_data"

    session_key_elem = SubElement(data_sec, "SESSION_KEY")
    session_key_elem.text = "encrypted_session_key"

    return tostring(root, encoding="unicode")


private_key = load_private_key(PRIVATE_KEY_PATH)
cert, public_key = load_cert_pub(CERTIFICATE_PATH)


def test_signed_with_default_ns_success():
    xml_str = create_sample_xml()
    root = lxml.etree.fromstring(xml_str.encode("utf-8"))

    signer = signxml.XMLSigner(
        method=signxml.methods.enveloped,
        signature_algorithm="rsa-sha256",
        digest_algorithm="sha256",
        c14n_algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315",
    )

    signed_root = signer.sign(
        root,
        key=private_key,
        exclude_c14n_transform_element=True,
    )

    signed_xml_str = lxml.etree.tostring(signed_root, encoding="utf-8")

    # Verifying
    root = lxml.etree.fromstring(signed_xml_str)

    result = signxml.XMLVerifier().verify(root, x509_cert=cert)

    # No exceptions, so this is okay...


def test_signed_without_ns_success():
    # This test is failing...
    xml_str = create_sample_xml()
    root = lxml.etree.fromstring(xml_str.encode("utf-8"))

    signer = signxml.XMLSigner(
        method=signxml.methods.enveloped,
        signature_algorithm="rsa-sha256",
        digest_algorithm="sha256",
        c14n_algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315",
    )

    signer.namespaces = {None: signxml.namespaces.ds}
    # signer.signature_annotators = []  # remove key info from payload

    signed_root = signer.sign(
        root,
        key=private_key,
        exclude_c14n_transform_element=True,
    )

    signed_xml_str = lxml.etree.tostring(signed_root, encoding="utf-8")

    # Verifying
    root = lxml.etree.fromstring(signed_xml_str)
    # This going to raise:
    # signxml.exceptions.InvalidInput: Expected to find XML element DigestMethod in {http://www.w3.org/2000/09/xmldsig#}Reference
    result = signxml.XMLVerifier().verify(root, x509_cert=cert)


Side Note: I’m new to XML digital signatures, and trying to get this working based on the requirements of the external (legacy) application. If I’m missing something or doing in a wrong way, I'd appreciate your help on the correct approach.


Solution

  • Creating a signed XML file without the ds prefix using the SignXML library was unsuccessful in my case, as the verification consistently failed. Since the package hasn’t received recent updates, I decided to switch to the xmlsec library instead.

    With xmlsec, both signing and verification worked correctly without the ds prefix, and it also has more frequent updates and better compatibility with the latest versions of lxml.

    Here’s a test sample I used to create and verify the XML signature using xmlsec.

    """
    Test Certificate Setup
    
    Run the following commands to generate self-signed cert and private key:
    
    .. code-block:: bash
    
        openssl genrsa -out private.key 4096
        openssl req -new -key private.key -out request.csr
        openssl x509 -req -days 365 -in request.csr -signkey private.key -out certificate.crt
    
    """
    
    import lxml.etree as ET
    import xmlsec
    from xml.etree.ElementTree import Element, SubElement, tostring
    
    from cryptography import x509
    from cryptography.hazmat.primitives import serialization
    from cryptography.hazmat.backends import default_backend
    
    
    PRIVATE_KEY_PATH = "/app/tmp/private.key"
    CERTIFICATE_PATH = "/app/tmp/certificate.crt"
    
    
    def load_cert_pem(cert_path: str) -> bytes:
        """Loads PEM-formatted certificate from file."""
        with open(cert_path, "rb") as f:
            return f.read()
    
    
    def load_key_pem(key_path: str) -> bytes:
        """Loads PEM-formatted private key from file."""
        with open(key_path, "rb") as f:
            return f.read()
    
    
    def create_sample_xml() -> str:
        """Generates minimal XML structure to sign."""
        root = Element("ROOT")
    
        data_sec = SubElement(root, "DATA")
    
        pid_elem = SubElement(data_sec, "ID")
        pid_elem.text = "encrypted_some_data_data"
    
        session_key_elem = SubElement(data_sec, "SESSION_KEY")
        session_key_elem.text = "encrypted_session_key"
    
        return tostring(root, encoding="unicode")
    
    
    # Load key and cert PEM strings (not as cryptography objects)
    private_key_pem = load_key_pem(PRIVATE_KEY_PATH)
    cert_pem = load_cert_pem(CERTIFICATE_PATH)
    
    
    def test_signed_with_default_ns_success():
        xml_str = create_sample_xml()
        root = ET.fromstring(xml_str.encode("utf-8"))
    
        # === Create the signature template ===
        signature_node = xmlsec.template.create(
            root,
            c14n_method=xmlsec.Transform.C14N,
            sign_method=xmlsec.Transform.RSA_SHA256,
            ns=None,
        )
        # root.insert(0, signature_node)
        root.append(signature_node)
    
        ref = xmlsec.template.add_reference(signature_node, xmlsec.Transform.SHA256, uri="")
        xmlsec.template.add_transform(ref, xmlsec.Transform.ENVELOPED)
        # xmlsec.template.add_transform(ref, xmlsec.Transform.C14N)  # no need to add this as well
    
        # KeyInfo not required
        # key_info = xmlsec.template.ensure_key_info(signature_node)
        # xmlsec.template.add_x509_data(key_info)
    
        # === Signing ===
        ctx = xmlsec.SignatureContext()
        ctx.key = xmlsec.Key.from_memory(private_key_pem, xmlsec.KeyFormat.PEM, None)
        ctx.sign(signature_node)
    
        signed_xml_pretty = ET.tostring(root, pretty_print=True, encoding="utf-8").decode()
        print("Signing completed... with ds: \n", signed_xml_pretty)
    
        # === Verifying ===
        root = ET.fromstring(ET.tostring(root))  # Re-parse to simulate real scenario
        signature_node = xmlsec.tree.find_node(root, xmlsec.Node.SIGNATURE)
    
        verify_ctx = xmlsec.SignatureContext()
        verify_ctx.key = xmlsec.Key.from_memory(cert_pem, xmlsec.KeyFormat.CERT_PEM, None)
        verify_ctx.verify(signature_node)
    
        print("Signature verified successfully.")