Перейти к содержимому
ecdsa.com
← Все статьи

Проверка подписи SAML: что проверять и почему

Подпись SAML может быть безупречно верной, тогда как удостоверение пользователя, которое ваш код читает из того же документа, ею не охвачено. Ниже — как XML-подпись указывает на то, что она подписывает, каких проверок требуют спецификации SAML, и валидатор, проверенный на ответах с обёрткой, вставленным комментарием, повторной отправкой и чужой подписью.

Коротко. Проверка ответа SAML состоит из двух половин, и большинство инцидентов случается, когда выполняют только первую. Во-первых, проверьте XML-подпись сертификатом поставщика удостоверений (identity provider, IdP) из его метаданных — никогда не сертификатом из KeyInfo в сообщении — и требуйте ровно одну ссылку Reference, которая указывает на ID того Assertion (или Response), с которым вы собираетесь работать. Во-вторых, читайте каждое значение, на которое вы полагаетесь (NameID, атрибуты, Issuer, Audience, Recipient, отметки времени), из элемента, который охвачен этой подписью, а не ищите его в полученном документе: рядом с подписанным элементом XML-документ может содержать похожий на него неподписанный. Затем примените правила SAML: аудитория — это ваш идентификатор сущности (entity ID), SubjectConfirmationData с методом bearer указывает URL вашей службы приёма утверждений (assertion consumer service, ACS) и ваш запрос, ни один срок не истёк, а каждый ID утверждения принимается только один раз.

Как XML-подпись указывает на то, что она подписывает

XML-подпись (XML Signature Syntax and Processing 1.1) подписывает не документ как последовательность байтов, а элемент SignedInfo, в котором перечислены одна или несколько ссылок Reference. У каждой Reference есть URI, который выбирает содержимое, список преобразований Transforms, которые к нему применяются, и DigestValue — дайджест результата. Так выглядит подпись SAML, которую выпустил поставщик удостоверений стенда (он описан ниже); длинные значения base64 сокращены:

Подписанный ответ SAML (вывод show.py, значения base64 сокращены)
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_ef6e7d7e83384141a7a16e6fb9d52d61" Version="2.0" IssueInstant="2026-10-20T13:39:36Z" Destination="https://sp.example/saml/acs" InResponseTo="_req-7d1f">
  <saml:Issuer>https://idp.example/saml</saml:Issuer>
  <samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
  <saml:Assertion ID="_cf908bd7284743f8997ddd7943f6640c" Version="2.0" IssueInstant="2026-10-20T13:39:36Z">
    <saml:Issuer>https://idp.example/saml</saml:Issuer>
    <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256"/><ds:Reference URI="#_cf908bd7284743f8997ddd7943f6640c"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>genlRcOfIZpWA8BHZ0Eo42LeZcRMriskgTApPDxsPI0=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>cPF5v/9Ve6uUmgcn...</ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIIBPDCB4qADAgEC...</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature><saml:Subject>
      <saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">alice@example.com</saml:NameID>
      <saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
        <saml:SubjectConfirmationData NotOnOrAfter="2026-10-20T13:44:36Z" Recipient="https://sp.example/saml/acs" InResponseTo="_req-7d1f"/>
      </saml:SubjectConfirmation>
    </saml:Subject>
    <saml:Conditions NotBefore="2026-10-20T13:39:36Z" NotOnOrAfter="2026-10-20T13:44:36Z">
      <saml:AudienceRestriction><saml:Audience>https://sp.example/saml</saml:Audience></saml:AudienceRestriction>
    </saml:Conditions>
    <saml:AuthnStatement AuthnInstant="2026-10-20T13:39:36Z"><saml:AuthnContext>
      <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
    </saml:AuthnContext></saml:AuthnStatement>
  </saml:Assertion></samlp:Response>

Что именно защищено, определяют три детали:

  • URI="#_cf90…" — ссылка внутри того же документа (same-document reference): она выбирает элемент, у которого атрибут ID имеет это значение, вместе с его потомками (XMLDSig, § 4.4.3.2). Где этот элемент находится в документе, в подпись не входит.
  • Преобразование enveloped-signature перед вычислением дайджеста удаляет сам элемент ds:Signature, поэтому подпись может находиться внутри того, что она подписывает.
  • Исключающая канонизация (exclusive canonicalization, xml-exc-c14n#, здесь без комментариев) превращает выбранный элемент в каноническую последовательность байтов: порядок атрибутов, объявления пространств имён и пробельные символы внутри тегов приводятся к единому виду, а комментарии отбрасываются. Поэтому у разных документов может быть одна и та же каноническая форма.

Проверка состоит из «проверки ссылок» (reference validation: пересчитать каждый дайджест), за которой следует «проверка подписи» (signature validation) над канонизированным SignedInfo (XMLDSig, § 3.2). Она отвечает ровно на один вопрос: подписаны ли эти канонические байты этим ключом. Об остальной части документа она не говорит ничего. Спецификация вынесла это в заголовок раздела — «Only What is Signed is Secure» («защищено только то, что подписано», § 8.1.1) — и добавляет, что приложениям следует работать с данными, «которые были преобразованы (включая канонизацию) и подписаны» (§ 8.1.3).

Чего требуют спецификации SAML

SAML сужает XMLDSig (SAML Core 2.0, § 5.4): подписи ДОЛЖНЫ быть вложенными (enveloped); они ДОЛЖНЫ содержать «единственный <ds:Reference> со ссылкой внутри документа на значение атрибута ID корневого элемента подписываемого утверждения или сообщения протокола»; СЛЕДУЕТ использовать исключающую канонизацию; других преобразований, кроме enveloped-signature и исключающей канонизации, НЕ СЛЕДУЕТ применять. Проверяющие стороны, которые принимают другие преобразования, «ДОЛЖНЫ гарантировать, что никакое содержимое сообщения SAML не исключено из подписи».

Профиль единого входа через браузер, Web Browser SSO (SAML Profiles 2.0, § 4.1.4), добавляет проверки, благодаря которым верная подпись означает «этот пользователь, для этого сервиса, сейчас»:

  • Для привязки HTTP-POST действует правило: «вложенные утверждения ДОЛЖНЫ быть подписаны» (§ 4.1.4.5). Подписанный Response вокруг неподписанного утверждения этому требованию не отвечает.
  • Поставщик услуг (service provider, SP) ОБЯЗАН проверить все подписи, а также убедиться, что Recipient в SubjectConfirmationData с методом bearer «совпадает с URL службы приёма утверждений», что момент NotOnOrAfter «ещё не наступил с учётом допустимого расхождения часов» и что InResponseTo равен ID его AuthnRequest либо отсутствует, если ответ не запрашивался (§ 4.1.4.3).
  • Поставщик услуг ОБЯЗАН «гарантировать, что bearer-утверждения не используются повторно, храня набор уже использованных значений ID» — всё то время, пока утверждение оставалось бы действительным (§ 4.1.4.5).
  • Условие AudienceRestriction «даёт результат Valid тогда и только тогда, когда доверяющая сторона SAML входит в одну или несколько указанных аудиторий» (SAML Core, § 2.5.1.4), а атрибуты NotBefore и NotOnOrAfter элемента Conditions ограничивают срок действия утверждения (§ 2.5.1.2).

Валидатор, который читает только подписанные данные

Валидатор ниже проверяет XML-подпись с помощью signxml 5.2.0. Документация signxml сама называет это правило «See what is signed» («смотрите на то, что подписано») и встраивает его в API: verify() возвращает подписанный элемент как signed_xml, удалив из него комментарии, которые подпись не охватывала, а location задаёт, где обязан находиться ds:Signature. Всё, что идёт после проверки подписи, читает данные из result.signed_xml:

sp.py (signxml 5.2.0, lxml 6.1.3, cryptography 50.0.2)
# Service provider side: verify the signature, then read every value from the signed Assertion only.
import datetime

from cryptography import x509
from signxml import SignatureConfiguration, XMLVerifier

SAMLP = "urn:oasis:names:tc:SAML:2.0:protocol"
SAML = "urn:oasis:names:tc:SAML:2.0:assertion"
NS = {"samlp": SAMLP, "saml": SAML}
BEARER = "urn:oasis:names:tc:SAML:2.0:cm:bearer"


class SamlError(Exception):
    pass


def _time(value):
    return datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc)


def validate_response(xml: bytes, *, idp_cert: x509.Certificate, idp_entity_id: str, sp_entity_id: str,
                      acs_url: str, request_id: str, seen_assertion_ids: set, skew=60, now=None):
    now = now or datetime.datetime.now(datetime.timezone.utc)
    lag = datetime.timedelta(seconds=skew)

    # 1. The signature: pinned IdP certificate from metadata (never the one in KeyInfo),
    #    exactly one Reference, ds:Signature where SAML puts it: inside the Assertion.
    config = SignatureConfiguration(location=f"./{{{SAML}}}Assertion/", expect_references=1)
    try:
        result = XMLVerifier().verify(xml, x509_cert=idp_cert, expect_config=config)
    except Exception as e:  # signxml raises InvalidSignature, InvalidInput and others
        raise SamlError(f"signature: {type(e).__name__}: {e}") from None

    # 2. From here on, only the element the signature covers. Never the document passed in.
    assertion = result.signed_xml
    if assertion.tag != f"{{{SAML}}}Assertion":
        raise SamlError(f"signed element is {assertion.tag}, not an Assertion")
    aid = assertion.get("ID")
    one = lambda path: assertion.find(path, NS)

    if (one("saml:Issuer").text or "").strip() != idp_entity_id:
        raise SamlError("issuer")
    conditions = one("saml:Conditions")
    if conditions is None:
        raise SamlError("no Conditions")
    if now + lag < _time(conditions.get("NotBefore")) or now - lag >= _time(conditions.get("NotOnOrAfter")):
        raise SamlError("assertion is outside its validity window")
    audiences = [a.text for a in conditions.findall("saml:AudienceRestriction/saml:Audience", NS)]
    if sp_entity_id not in audiences:
        raise SamlError(f"audience {audiences} is not this SP")

    # 3. A bearer SubjectConfirmation for this ACS URL, this request, still valid.
    data = one(f"saml:Subject/saml:SubjectConfirmation[@Method='{BEARER}']/saml:SubjectConfirmationData")
    if data is None:
        raise SamlError("no bearer SubjectConfirmationData")
    if data.get("Recipient") != acs_url:
        raise SamlError(f"recipient {data.get('Recipient')}")
    if now - lag >= _time(data.get("NotOnOrAfter")):
        raise SamlError("subject confirmation expired")
    if data.get("InResponseTo") != request_id:
        raise SamlError("InResponseTo does not match the AuthnRequest")

    # 4. One use per assertion ID, for as long as the assertion could be accepted.
    if aid in seen_assertion_ids:
        raise SamlError(f"assertion {aid} already used")
    seen_assertion_ids.add(aid)

    name_id = one("saml:Subject/saml:NameID")
    return {"name_id": "".join(name_id.itertext()), "assertion_id": aid}

Для сравнения — так выглядит сама ошибка. Этот код вызывает ту же проверку подписи, отбрасывает её результат, а затем читает первый Assertion, который найдёт в документе:

naive.py (антипаттерн)
# Anti-pattern, for comparison only: the signature is checked, but the values come from the
# document as received instead of from the element the signature covers.
from lxml import etree
from signxml import SignatureConfiguration, XMLVerifier

NS = {"samlp": "urn:oasis:names:tc:SAML:2.0:protocol", "saml": "urn:oasis:names:tc:SAML:2.0:assertion"}


def naive_validate(xml: bytes, *, idp_cert, sp_entity_id: str, acs_url: str, request_id: str):
    config = SignatureConfiguration(location="./{urn:oasis:names:tc:SAML:2.0:assertion}Assertion/", expect_references=1)
    XMLVerifier().verify(xml, x509_cert=idp_cert, expect_config=config)  # result ignored
    doc = etree.fromstring(xml)
    assertion = doc.find("saml:Assertion", NS)  # the first Assertion in the document
    assert sp_entity_id in [a.text for a in assertion.findall(".//saml:Audience", NS)]
    data = assertion.find(".//saml:SubjectConfirmationData", NS)
    assert data.get("Recipient") == acs_url and data.get("InResponseTo") == request_id
    return {"name_id": assertion.find("saml:Subject/saml:NameID", NS).text}

Проверка на изменённых ответах

Поставщик удостоверений стенда при каждом запуске генерирует ключ P-256 и самоподписанный сертификат и выпускает подписанные ответы; поставщик услуг закрепляет этот сертификат:

idp.py
# Lab identity provider: a key and self-signed certificate generated per run, and signed SAML Responses.
import datetime
import uuid

from cryptography import x509
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
from lxml import etree
from signxml import XMLSigner
from signxml.algorithms import CanonicalizationMethod, SignatureMethod

SAMLP = "urn:oasis:names:tc:SAML:2.0:protocol"
SAML = "urn:oasis:names:tc:SAML:2.0:assertion"


def new_idp(name):
    key = ec.generate_private_key(ec.SECP256R1())
    subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, name)])
    now = datetime.datetime.now(datetime.timezone.utc)
    cert = (
        x509.CertificateBuilder()
        .subject_name(subject).issuer_name(subject).public_key(key.public_key())
        .serial_number(x509.random_serial_number())
        .not_valid_before(now - datetime.timedelta(minutes=5)).not_valid_after(now + datetime.timedelta(days=30))
        .sign(key, hashes.SHA256())
    )
    return key, cert


def iso(t):
    return t.strftime("%Y-%m-%dT%H:%M:%SZ")


def response(key, cert, *, name_id, audience, recipient, in_response_to, issuer="https://idp.example/saml",
             valid_for=300, now=None):
    """A Response with one signed Assertion: enveloped signature, exclusive C14N, Reference URI=#ID."""
    now = now or datetime.datetime.now(datetime.timezone.utc)
    until = now + datetime.timedelta(seconds=valid_for)
    aid = f"_{uuid.uuid4().hex}"
    xml = f"""<samlp:Response xmlns:samlp="{SAMLP}" xmlns:saml="{SAML}" ID="_{uuid.uuid4().hex}" Version="2.0"
  IssueInstant="{iso(now)}" Destination="{recipient}" InResponseTo="{in_response_to}">
  <saml:Issuer>{issuer}</saml:Issuer>
  <samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
  <saml:Assertion ID="{aid}" Version="2.0" IssueInstant="{iso(now)}">
    <saml:Issuer>{issuer}</saml:Issuer>
    <saml:Subject>
      <saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">{name_id}</saml:NameID>
      <saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
        <saml:SubjectConfirmationData NotOnOrAfter="{iso(until)}" Recipient="{recipient}" InResponseTo="{in_response_to}"/>
      </saml:SubjectConfirmation>
    </saml:Subject>
    <saml:Conditions NotBefore="{iso(now)}" NotOnOrAfter="{iso(until)}">
      <saml:AudienceRestriction><saml:Audience>{audience}</saml:Audience></saml:AudienceRestriction>
    </saml:Conditions>
    <saml:AuthnStatement AuthnInstant="{iso(now)}"><saml:AuthnContext>
      <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
    </saml:AuthnContext></saml:AuthnStatement>
  </saml:Assertion>
</samlp:Response>"""
    root = etree.fromstring(xml.encode())
    assertion = root.find(f"{{{SAML}}}Assertion")
    signed = XMLSigner(
        signature_algorithm=SignatureMethod.ECDSA_SHA256,
        c14n_algorithm=CanonicalizationMethod.EXCLUSIVE_XML_CANONICALIZATION_1_0,
    ).sign(assertion, key=key, cert=[cert], reference_uri=aid)
    # SAML requires ds:Signature right after saml:Issuer inside the signed element.
    sig = signed.find("{http://www.w3.org/2000/09/xmldsig#}Signature")
    signed.remove(sig)
    signed.insert(1, sig)
    root.replace(assertion, signed)
    return etree.tostring(root)

Управляющий скрипт стенда отправляет обеим функциям 10 ответов: один действительный, один свежий, который затем отправляется повторно, и 8 изменённых или неприемлемых:

run.py
# Lab driver: valid and manipulated SAML Responses against the validator (sp.py) and the anti-pattern (naive.py).
import copy
import datetime
import re

from lxml import etree

from idp import SAML, new_idp, response
from naive import naive_validate
from sp import SamlError, validate_response

key, cert = new_idp("idp.example signing")
other_key, other_cert = new_idp("idp.example signing")  # same name, different key: not the IdP
SP = {"sp_entity_id": "https://sp.example/saml", "acs_url": "https://sp.example/saml/acs", "request_id": "_req-7d1f"}
seen = set()


def issue(name_id="alice@example.com", **kw):
    args = {"audience": SP["sp_entity_id"], "recipient": SP["acs_url"], "in_response_to": SP["request_id"], **kw}
    return response(args.pop("key", key), args.pop("cert", cert), name_id=name_id, **args)


def check(label, xml, **kw):
    try:
        ours = validate_response(xml, idp_cert=cert, idp_entity_id="https://idp.example/saml",
                                 seen_assertion_ids=seen, **SP, **kw)
        ours = f"login as {ours['name_id']}"
    except SamlError as e:
        ours = f"rejected: {e}"
    try:
        naive = f"login as {naive_validate(xml, idp_cert=cert, **SP)['name_id']}"
    except Exception as e:
        naive = f"rejected: {type(e).__name__}"
    print(f"{label}\n  validator:    {ours}\n  anti-pattern: {naive}")


check("valid response", issue())

# Signature wrapping: an unsigned Assertion inserted before the signed one. The signature still
# verifies, because its Reference points by ID at the original Assertion.
doc = etree.fromstring(issue())
signed = doc.find(f"{{{SAML}}}Assertion")
forged = copy.deepcopy(signed)
forged.remove(forged.find("{http://www.w3.org/2000/09/xmldsig#}Signature"))
forged.set("ID", "_forged")
forged.find(f"{{{SAML}}}Subject/{{{SAML}}}NameID").text = "admin@example.com"
doc.insert(list(doc).index(signed), forged)
check("unsigned Assertion before the signed one", etree.tostring(doc))

# A comment inside the signed NameID: exclusive C14N without comments ignores it, so the
# signature stays valid; .text on the received document stops at the comment.
xml = issue("admin@example.com.evil.test")
xml = xml.replace(b"admin@example.com.evil.test<", b"admin@example.com<!---->.evil.test<")
check("comment inside the signed NameID", xml)

check("assertion for another SP", issue(audience="https://other-sp.example/saml"))
check("expired 10 minutes ago", issue(now=datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(minutes=15)))
replay = issue()
check("first use", replay)
check("same assertion again", replay)
tampered = issue().replace(b"alice@example.com", b"admin@example.com")
check("NameID edited after signing", tampered)
check("signed with another key + its cert", issue(key=other_key, cert=other_cert))
stripped = re.sub(rb"<ds:Signature.*?</ds:Signature>", b"", issue(), flags=re.S)
check("signature removed", stripped)
Вывод (python:3.14.8-slim-trixie)
valid response
  validator:    login as alice@example.com
  anti-pattern: login as alice@example.com
unsigned Assertion before the signed one
  validator:    login as alice@example.com
  anti-pattern: login as admin@example.com
comment inside the signed NameID
  validator:    login as admin@example.com.evil.test
  anti-pattern: login as admin@example.com
assertion for another SP
  validator:    rejected: audience ['https://other-sp.example/saml'] is not this SP
  anti-pattern: rejected: AssertionError
expired 10 minutes ago
  validator:    rejected: assertion is outside its validity window
  anti-pattern: login as alice@example.com
first use
  validator:    login as alice@example.com
  anti-pattern: login as alice@example.com
same assertion again
  validator:    rejected: assertion _2291663e82ca413bb791278fa3a9a3f8 already used
  anti-pattern: login as alice@example.com
NameID edited after signing
  validator:    rejected: signature: InvalidDigest: Digest mismatch for reference 0 (#_37ad00c1109f47e3b3b1b650a17aba70)
  anti-pattern: rejected: InvalidDigest
signed with another key + its cert
  validator:    rejected: signature: InvalidSignature: Signature verification failed: 
  anti-pattern: rejected: InvalidSignature
signature removed
  validator:    rejected: signature: InvalidInput: Expected to find XML element Signature in {urn:oasis:names:tc:SAML:2.0:protocol}Response using XPath ./{urn:oasis:names:tc:SAML:2.0:assertion}Assertion/ds:Signature
  anti-pattern: rejected: InvalidInput

Разберём по случаям:

  • Неподписанное утверждение перед подписанным. Подпись по-прежнему прошла проверку, потому что её ссылка выбирает исходное утверждение по ID, где бы оно ни находилось. Антипаттерн прочитал первый Assertion в документе и пустил в систему пользователя, за которого поставщик удостоверений никогда не ручался. Валидатор работал с signed_xml и видел только исходное утверждение. Это семейство ошибок обычно называют обёртыванием подписи (signature wrapping).
  • Комментарий внутри подписанного NameID. Поставщик удостоверений подписал admin@example.com.evil.test — учётную запись, которая вполне законно может быть у одного из его пользователей. Вставка <!----> после admin@example.com не меняет каноническую форму: исключающая канонизация без комментариев отбрасывает комментарии, поэтому подпись остаётся верной. В lxml .text возвращает только текст до первого дочернего узла, поэтому антипаттерн прочитал admin@example.com. signxml возвращает подписанный элемент без комментария, а валидатор склеивает все текстовые узлы и поэтому увидел настоящую учётную запись. Именно этот приём, опубликованный в феврале 2018 года как CERT/CC VU#475445 по итогам исследования Duo Labs, затронул, среди прочих, python-saml и ruby-saml от OneLogin, saml2-js, OmniAuth-SAML и OpenSAML C++ из Shibboleth.
  • Утверждение для другого поставщика услуг, подписанное тем же поставщиком удостоверений, не прошло проверку аудитории. Его отклонили обе реализации — просто потому, что антипаттерн случайно проверял и аудиторию.
  • Истечение срока и повторную отправку обнаружил только валидатор. Это правила SAML, а не правила подписи: подпись на перехваченном утверждении остаётся верной навсегда.
  • Изменённый, переподписанный и неподписанный ответы в обеих реализациях не прошли проверку подписи: несовпадение дайджеста; ключ, который не совпадает с закреплённым, хотя его сертификат был вложен в KeyInfo и содержал то же имя субъекта; отсутствие подписи там, где её требует location.

Детали, от которых зависят реальные внедрения

Подпись ответа или подпись утверждения. Поставщики удостоверений подписывают утверждение, ответ или и то и другое. Если подписан только Response, утверждение охвачено подписью как его потомок, и правило звучит так: используйте утверждение, которое непосредственно вложено в подписанный элемент ответа. Профиль POST всё равно требует подписанных утверждений, поэтому настройте поставщика удостоверений так, чтобы он их подписывал. Никогда не принимайте «подпись где-нибудь в документе».

Зашифрованные утверждения. Сначала EncryptedAssertion должен быть расшифрован вашим ключом; затем расшифрованный Assertion должен содержать верную подпись поставщика удостоверений. Шифрование доказывает лишь то, что отправитель знал ваш открытый ключ.

Доверие берётся из метаданных. Сертификатам подписи поставщика удостоверений место в вашей конфигурации: их импортируют из его метаданных и обновляют, когда он проводит ротацию; во время смены сертификатов старый и новый какое-то время действуют одновременно, и это нормально. Сертификат внутри сообщения ничего не доказывает: кто угодно может создать сертификат с любым именем субъекта, и именно это проделано в случае с другим ключом.

Алгоритмы. Текст SAML Core 2005 года называет RSA с SHA-1; signxml 5.2.0 по умолчанию отклоняет методы подписи и дайджеста на основе SHA-1, и вам следует поступать так же. Ограничьте допустимые методы теми, которые использует ваш поставщик удостоверений, обычно RSA-SHA256 или ECDSA-SHA256.

Разбор XML. Прежде всего разбирайте документ с отключёнными внешними сущностями и DTD; парсер signxml устанавливает resolve_entities=False. Отклоняйте документы, в которых один и тот же ID есть у нескольких элементов.

Время. Допускайте небольшое расхождение часов (от нескольких секунд до пары минут) и храните использованные ID утверждений до их NotOnOrAfter плюс это расхождение.

Когда настоящий ответ не проходит проверку

У обратной проблемы, когда настоящий ответ вашего поставщика удостоверений не проходит проверку, короткий список обычных причин:

  • Поставщик удостоверений сменил сертификат, а в вашей конфигурации всё ещё хранится старый. Обновите его из метаданных: обычно новый сертификат появляется в них раньше, чем начинает использоваться.
  • Кто-то переформатировал XML. Исключающая канонизация нормализует разметку, но не текст: добавленные отступы или переводы строк между элементами меняют текстовые узлы внутри подписанного элемента и ломают дайджест. Проверяйте байты ровно в том виде, в каком они декодированы из поля формы; никогда не форматируйте XML для читаемости, не сериализуйте его заново другой библиотекой и не «чистите» его перед проверкой.
  • Неправильное декодирование. В привязке HTTP-POST поле формы SAMLResponse содержит XML в base64, возможно с переносами строк (SAML Bindings, § 3.5.4). В привязке HTTP-Redirect сообщение сжато DEFLATE, его XML-подпись удалена, а отдельная подпись над строкой запроса передаётся в параметрах SigAlg и Signature (§ 3.4.4.1); эту подпись проверяют по параметрам в URL-кодировке, а не по XML. Ответам с подписанными утверждениями вообще не следует использовать привязку Redirect.
  • Подпись не там, где её ожидает ваш валидатор. Если поставщик удостоверений подписывает Response, а ваша конфигурация требует подпись внутри Assertion (или наоборот), проверка не проходит. Решите, какую из них вы требуете, и настройте поставщика удостоверений соответственно; не ослабляйте требование к расположению подписи до «где угодно».
  • Часы. Ошибка «Not yet valid» (утверждение ещё не действительно) сразу после входа означает, что часы поставщика услуг отстают; увеличивайте допустимое расхождение, только когда исправите часы.

Библиотеки

Используйте библиотеку SAML, а не универсальную библиотеку XML-подписей со своим связующим кодом, вовремя обновляйте её и найдите в её документации функцию, которая возвращает подписанное содержимое. Версии, актуальные на 20 октября 2026 года:

ЯзыкБиблиотека SAMLУровень XML-подписи
Pythonpython3-saml 1.16.0, pysaml2 7.5.5xmlsec 1.3.17, signxml 5.2.0
Node.js@node-saml/node-saml 5.1.0, samlify 2.15.0xml-crypto 6.4.0
Gocrewjam/saml v0.5.1russellhaering/goxmldsig v1.6.1

README xml-crypto прямо формулирует то же правило, что и signxml: после успешного вызова checkSignature() используйте канонический XML из getSignedReferences() и не берите узлы из исходного документа; getReferences() и validateElementAgainstReferences() для этой цели объявлены устаревшими. Если вы сопровождаете код, который сначала проверяет подпись, а потом делает запросы к входному документу, менять нужно именно эту строку.

Чек-лист

  1. Разбирайте XML безопасно: без DTD, без внешних сущностей, без повторяющихся ID.
  2. Проверяйте подпись закреплённым сертификатом из метаданных поставщика удостоверений; при решении о доверии игнорируйте KeyInfo.
  3. Требуйте ровно одну ссылку Reference, которая указывает на элемент, с которым вы будете работать, только преобразования enveloped-signature и исключающей канонизации и только допустимые алгоритмы.
  4. Только по подписанному элементу: Issuer равен entity ID поставщика удостоверений; интервал действия в Conditions и AudienceRestriction с вашим entity ID; SubjectConfirmationData с методом bearer, где Recipient — URL вашего ACS, NotOnOrAfter в будущем, а InResponseTo — ID вашего запроса (отсутствовать он может только в незапрошенных ответах, которые вы решили принимать).
  5. Запоминайте ID утверждения и отклоняйте утверждение, если оно придёт снова, пока ещё действительно.
  6. Читайте NameID и атрибуты из подписанного элемента, склеивая все текстовые узлы, и сопоставляйте пользователей по паре Issuer и NameID.
  7. Проверьте реализацию на просроченном и повторно отправленном утверждениях и на сертификате, который не принадлежит поставщику удостоверений: все они должны быть отклонены. Проверьте также ответ с обёрнутой подписью и NameID с комментарием: по ним должен выполняться вход только под тем удостоверением, которое подписал поставщик удостоверений, причём в полном виде.

Источники