2025, Oct 31 09:47
Подпись XML без префикса ds: почему SignXML падает и как работает xmlsec
Разбираем, почему проверка XML Digital Signature без префикса ds в SignXML ломается, и показываем решение на xmlsec: пример кода, подпись и верификация.
Подпись XML без префикса ds: почему проверка в SignXML ломается и как заставить её работать с xmlsec
Некоторые legacy‑системы ожидают, что элементы XML Digital Signature находятся в корректном пространстве имён, но без префикса ds:. Иными словами, Signature и все дочерние элементы должны жить в http://www.w3.org/2000/09/xmldsig# с пространством имён по умолчанию, но без явного префикса у тегов. Сгенерировать такую подпись в SignXML несложно — достаточно переопределить пространства имён, — однако на этапе проверки может произойти сбой. Ниже — практическое пошаговое руководство, которое воспроизводит проблему и показывает рабочую альтернативу на xmlsec.
Воспроизводимый пример с SignXML
Следующий фрагмент кода подписывает XML дважды: сначала с поведением по умолчанию в SignXML (префикс ds: присутствует), а затем с установленным пространством имён по умолчанию (префикс ds: убран). Неудача происходит при второй проверке.
"""
Настройка тестового сертификата
Выполните команды ниже, чтобы сгенерировать самоподписанный сертификат и приватный ключ:
.. 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
KEY_FILE = "/app/private.key"
CERT_FILE = "/app/certificate.crt"
def load_cert_and_pubkey(path: str):
    with open(path, "rb") as fh:
        raw = fh.read()
    cert = x509.load_pem_x509_certificate(raw, default_backend())
    return cert, cert.public_key()
def load_privkey(path: str, password=None):
    with open(path, "rb") as fh:
        raw = fh.read()
    return serialization.load_pem_private_key(raw, password=password, backend=default_backend())
def build_source_xml():
    root = Element("ROOT")
    data_node = SubElement(root, "DATA")
    id_node = SubElement(data_node, "ID")
    id_node.text = "encrypted_some_data_data"
    key_node = SubElement(data_node, "SESSION_KEY")
    key_node.text = "encrypted_session_key"
    return tostring(root, encoding="unicode")
private_key_obj = load_privkey(KEY_FILE)
cert_obj, pubkey_obj = load_cert_and_pubkey(CERT_FILE)
def sign_with_ds_prefix_and_verify():
    xml_payload = build_source_xml()
    doc = lxml.etree.fromstring(xml_payload.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_doc = signer.sign(
        doc,
        key=private_key_obj,
        exclude_c14n_transform_element=True,
    )
    serialized = lxml.etree.tostring(signed_doc, encoding="utf-8")
    parsed = lxml.etree.fromstring(serialized)
    signxml.XMLVerifier().verify(parsed, x509_cert=cert_obj)
    # Проверка здесь проходит успешно.
def sign_without_ds_prefix_and_verify():
    xml_payload = build_source_xml()
    doc = lxml.etree.fromstring(xml_payload.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",
    )
    # Принудительно используем пространство имён по умолчанию xmldsig, убирая префикс ds
    signer.namespaces = {None: signxml.namespaces.ds}
    signed_doc = signer.sign(
        doc,
        key=private_key_obj,
        exclude_c14n_transform_element=True,
    )
    serialized = lxml.etree.tostring(signed_doc, encoding="utf-8")
    parsed = lxml.etree.fromstring(serialized)
    # В этом случае этот вызов приводит к исключению:
    # signxml.exceptions.InvalidInput: Expected to find XML element DigestMethod in {http://www.w3.org/2000/09/xmldsig#}Reference
    signxml.XMLVerifier().verify(parsed, x509_cert=cert_obj)
Что именно ломается при проверке
Проверка падает только тогда, когда префикс ds: удалён назначением пространства имён Digital Signature в качестве пространства по умолчанию. Сообщение говорит, что проверяющий ожидает обнаружить DigestMethod под пространством имён ds. По стек‑трейсу видно, что поиск выполняется по ds:DigestMethod, и, несмотря на то что элементы действительно принадлежат нужному пространству, в такой конфигурации поиск не срабатывает.
signxml.exceptions.InvalidInput: Expected to find XML element DigestMethod in {http://www.w3.org/2000/09/xmldsig#}Reference
Подписание с префиксом ds: работает и успешно проверяется в той же среде. Тот же документ без префикса — не проходит проверку.
Рабочая альтернатива: xmlsec без префикса ds
Создать подписанный XML без префикса ds с помощью SignXML удалось, но проверить его в этих условиях — нет. Пакет давно не обновлялся, поэтому переход на xmlsec решает проблему. С xmlsec и подпись, и верификация без префикса ds работают как требуется, и библиотека корректно сочетается с актуальными версиями lxml.
Пример ниже собирает тот же XML, подписывает его «вложенной» подписью с пространством имён xmldsig по умолчанию (без ds:) и затем проверяет.
"""
Настройка тестового сертификата
Выполните команды ниже, чтобы сгенерировать самоподписанный сертификат и приватный ключ:
.. 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 LX
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
PEM_KEY = "/app/tmp/private.key"
PEM_CERT = "/app/tmp/certificate.crt"
def read_pem_cert(p: str) -> bytes:
    with open(p, "rb") as f:
        return f.read()
def read_pem_key(p: str) -> bytes:
    with open(p, "rb") as f:
        return f.read()
def make_xml_payload() -> str:
    root = Element("ROOT")
    sec = SubElement(root, "DATA")
    rid = SubElement(sec, "ID")
    rid.text = "encrypted_some_data_data"
    skey = SubElement(sec, "SESSION_KEY")
    skey.text = "encrypted_session_key"
    return tostring(root, encoding="unicode")
priv_pem = read_pem_key(PEM_KEY)
cert_pem = read_pem_cert(PEM_CERT)
def sign_and_verify_with_xmlsec():
    xml_str = make_xml_payload()
    doc = LX.fromstring(xml_str.encode("utf-8"))
    # Создаём шаблон подписи с пространством имён по умолчанию (ns=None)
    sig_node = xmlsec.template.create(
        doc,
        c14n_method=xmlsec.Transform.C14N,
        sign_method=xmlsec.Transform.RSA_SHA256,
        ns=None,
    )
    # Добавляем узел подписи под корень документа
    doc.append(sig_node)
    # Ссылаемся на весь документ и добавляем трансформацию enveloped
    ref = xmlsec.template.add_reference(sig_node, xmlsec.Transform.SHA256, uri="")
    xmlsec.template.add_transform(ref, xmlsec.Transform.ENVELOPED)
    # Подпись
    ctx = xmlsec.SignatureContext()
    ctx.key = xmlsec.Key.from_memory(priv_pem, xmlsec.KeyFormat.PEM, None)
    ctx.sign(sig_node)
    # Повторный разбор и проверка
    reparsed = LX.fromstring(LX.tostring(doc))
    sig_for_verify = xmlsec.tree.find_node(reparsed, xmlsec.Node.SIGNATURE)
    vctx = xmlsec.SignatureContext()
    vctx.key = xmlsec.Key.from_memory(cert_pem, xmlsec.KeyFormat.CERT_PEM, None)
    vctx.verify(sig_for_verify)
Почему это важно
Интеграция с наследуемыми системами часто требует строгого соответствия форме XML, выходя за рамки одной лишь корректности схемы. Даже если элементы находятся в правильном пространстве имён, наличие или отсутствие префикса ds: может решить исход совместимости. Инструменты, которые нативно поддерживают пространство имён по умолчанию для xmldsig и умеют проверять такие подписи, избавляют от хрупких обходных решений и снижают риски при передаче данных.
Практические выводы
Если целевая система требует элементы XML Digital Signature без префикса ds:, учтите, что SignXML в таком режиме может не пройти проверку. В отличие от него, xmlsec уверенно подписывает и проверяет такие документы и хорошо стыкуется с текущими зависимостями. Выбор библиотеки, соответствующей этим ограничениям, — самый короткий путь к стабильной реализации.