Коротко. Проверка ответа 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 сокращены:
<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:
# 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, который найдёт в документе:
# 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 и самоподписанный сертификат и выпускает подписанные ответы; поставщик услуг закрепляет этот сертификат:
# 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 изменённых или неприемлемых:
# 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)
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-подписи |
|---|---|---|
| Python | python3-saml 1.16.0, pysaml2 7.5.5 | xmlsec 1.3.17, signxml 5.2.0 |
| Node.js | @node-saml/node-saml 5.1.0, samlify 2.15.0 | xml-crypto 6.4.0 |
| Go | crewjam/saml v0.5.1 | russellhaering/goxmldsig v1.6.1 |
README xml-crypto прямо формулирует то же правило, что и signxml: после успешного вызова checkSignature() используйте канонический XML из getSignedReferences() и не берите узлы из исходного документа; getReferences() и validateElementAgainstReferences() для этой цели объявлены устаревшими. Если вы сопровождаете код, который сначала проверяет подпись, а потом делает запросы к входному документу, менять нужно именно эту строку.
Чек-лист
- Разбирайте XML безопасно: без DTD, без внешних сущностей, без повторяющихся
ID. - Проверяйте подпись закреплённым сертификатом из метаданных поставщика удостоверений; при решении о доверии игнорируйте
KeyInfo. - Требуйте ровно одну ссылку
Reference, которая указывает на элемент, с которым вы будете работать, только преобразования enveloped-signature и исключающей канонизации и только допустимые алгоритмы. - Только по подписанному элементу:
Issuerравен entity ID поставщика удостоверений; интервал действия вConditionsиAudienceRestrictionс вашим entity ID;SubjectConfirmationDataс методом bearer, гдеRecipient— URL вашего ACS,NotOnOrAfterв будущем, аInResponseTo— ID вашего запроса (отсутствовать он может только в незапрошенных ответах, которые вы решили принимать). - Запоминайте ID утверждения и отклоняйте утверждение, если оно придёт снова, пока ещё действительно.
- Читайте
NameIDи атрибуты из подписанного элемента, склеивая все текстовые узлы, и сопоставляйте пользователей по пареIssuerиNameID. - Проверьте реализацию на просроченном и повторно отправленном утверждениях и на сертификате, который не принадлежит поставщику удостоверений: все они должны быть отклонены. Проверьте также ответ с обёрнутой подписью и
NameIDс комментарием: по ним должен выполняться вход только под тем удостоверением, которое подписал поставщик удостоверений, причём в полном виде.
Источники
- XML Signature Syntax and Processing 1.1: § 3.2 (базовая проверка), § 4.4.3 (ссылки), § 6.1 (алгоритмы), § 8.1 (вопросы безопасности)
- SAML Core 2.0: § 2.5.1 (условия), § 5.4 (профиль XML-подписи)
- SAML Profiles 2.0: § 4.1.4 (правила обработки для единого входа через браузер, Web Browser SSO)
- SAML Bindings 2.0: § 3.4.4.1 (Redirect, DEFLATE), § 3.5.4 (POST)
- Документация signxml: «See what is signed»
- README xml-crypto:
getSignedReferences() - CERT/CC VU#475445: в ряде библиотек SAML возможен обход аутентификации из-за неверной канонизации XML и навигации по DOM (2018)