Pular para o conteúdo
ecdsa.com
← Todos os artigos

Validação de assinaturas SAML: o que verificar e por quê

Uma assinatura SAML pode ser perfeitamente válida e, ainda assim, não cobrir a identidade que seu código lê do mesmo documento. Veja como as assinaturas XML apontam para o que assinam, quais verificações as especificações SAML exigem e um validador testado contra respostas com signature wrapping, com comentários inseridos, reutilizadas e assinadas de novo.

Resposta curta. A validação de uma resposta SAML tem duas partes, e a maioria dos incidentes vem de fazer só a primeira. Primeiro, verifique a assinatura XML com o certificado do provedor de identidade obtido dos metadados dele, nunca do KeyInfo da mensagem, e exija exatamente uma Reference que aponte para o ID da Assertion (ou da Response) que você vai usar. Segundo, leia cada valor em que você se baseia (o NameID, os atributos, Issuer, Audience, Recipient, os horários) do elemento que essa assinatura cobre, e não buscando no documento recebido: um documento XML pode conter, ao lado do elemento assinado, um elemento parecido sem assinatura. Depois, aplique as regras do SAML: o público-alvo é o ID da entidade (entity ID) do seu serviço, o SubjectConfirmationData do método bearer indica a URL do seu assertion consumer service (ACS) e sua requisição, nada expirou e cada ID de asserção é aceito uma única vez.

Como uma assinatura XML aponta para o que assina

Uma assinatura XML (XML Signature Syntax and Processing 1.1) não assina um documento como uma sequência de bytes. Ela assina um elemento SignedInfo, que lista um ou mais elementos Reference. Cada Reference tem um atributo URI que seleciona o conteúdo, uma lista de Transforms aplicadas a ele e um DigestValue do resultado. Veja uma assinatura SAML emitida pelo provedor de identidade de laboratório descrito mais abaixo, exibida com os valores longos em base64 encurtados:

Uma resposta SAML assinada (saída de show.py, valores em base64 encurtados)
<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>

Três detalhes determinam o que fica protegido:

  • URI="#_cf90…" é uma referência no mesmo documento: ela seleciona o elemento cujo atributo ID tem esse valor, junto com os descendentes dele (XMLDSig §4.4.3.2). A posição desse elemento no documento não faz parte da assinatura.
  • A transformação enveloped-signature remove o próprio elemento ds:Signature antes do cálculo do digest, de modo que a assinatura pode ficar dentro do que ela assina.
  • A canonicalização exclusiva (xml-exc-c14n#, aqui sem comentários) transforma o elemento selecionado em uma sequência canônica de bytes: a ordem dos atributos, as declarações de namespace e os espaços em branco dentro das tags são normalizados, e os comentários são descartados. Por isso, documentos diferentes podem ter a mesma forma canônica.

A validação é a “validação das referências” (recalcular cada digest) seguida da “validação da assinatura” sobre o SignedInfo canonicalizado (XMLDSig §3.2). Ela responde a uma única pergunta: estes bytes canônicos foram assinados por esta chave? Sobre o restante do documento, ela não diz nada. A especificação diz isso no título de uma seção, “Only What is Signed is Secure” (só o que é assinado é seguro, §8.1.1), e acrescenta que convém que as aplicações operem sobre os dados “que foram transformados (incluindo a canonicalização) e assinados” (§8.1.3).

O que as especificações SAML exigem

O SAML restringe o XMLDSig (SAML Core 2.0 §5.4): as assinaturas DEVEM (MUST) ser envelopadas (enveloped); DEVEM (MUST) conter “uma única <ds:Reference> contendo uma referência no mesmo documento ao valor do atributo ID do elemento raiz da asserção ou da mensagem de protocolo que está sendo assinada”; a canonicalização exclusiva DEVERIA (SHOULD) ser usada; e transformações além de enveloped-signature e da canonicalização exclusiva NÃO DEVERIAM (SHOULD NOT) aparecer. Os verificadores que aceitam outras transformações “DEVEM (MUST) garantir que nenhum conteúdo da mensagem SAML seja excluído da assinatura”.

O perfil Web Browser SSO (SAML Profiles 2.0 §4.1.4) acrescenta as verificações que fazem uma assinatura válida significar “este usuário, para este serviço, agora”:

  • Com o binding HTTP-POST, “as asserções incluídas DEVEM (MUST) ser assinadas” (§4.1.4.5). Uma Response assinada em torno de uma asserção não assinada não atende a essa regra.
  • O provedor de serviços DEVE (MUST) verificar todas as assinaturas presentes e conferir que o Recipient do SubjectConfirmationData do método bearer “corresponde à URL do assertion consumer service”, que o NotOnOrAfter dele “não passou, considerando o desvio de relógio permitido” e que o InResponseTo é igual ao ID do AuthnRequest que ele enviou ou está ausente, no caso de uma resposta não solicitada (§4.1.4.3).
  • Ele DEVE (MUST) “garantir que asserções bearer não sejam reutilizadas, mantendo o conjunto dos valores de ID já usados” pelo tempo em que a asserção seria válida (§4.1.4.5).
  • A condição AudienceRestriction “é avaliada como Valid se, e somente se, a parte confiável SAML for membro de um ou mais dos públicos-alvo especificados” (SAML Core §2.5.1.4), e os atributos NotBefore e NotOnOrAfter de Conditions delimitam a validade da asserção (§2.5.1.2).

Um validador que lê apenas dados assinados

O validador abaixo usa a signxml 5.2.0 para a assinatura XML. A própria documentação da signxml chama essa regra de “See what is signed” (veja o que é assinado) e a transforma na API: verify() retorna o elemento assinado como signed_xml, sem os comentários que a assinatura não cobriu, e location fixa onde o ds:Signature precisa estar. Tudo o que vem depois da verificação da assinatura lê de 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}

Para comparação, veja a forma do bug. O código chama a mesma verificação de assinatura, descarta o resultado e depois lê a primeira Assertion que encontra no documento:

naive.py (antipadrão)
# 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}

Testes com respostas manipuladas

Um provedor de identidade de laboratório gera, a cada execução, uma chave P-256 e um certificado autoassinado e emite respostas assinadas; o provedor de serviços fixa esse certificado:

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)

O script de teste envia dez respostas às duas funções: uma válida, uma recém-emitida que depois é reutilizada e oito manipuladas ou inaceitáveis:

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)
Saída (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

Caso a caso:

  • Uma asserção não assinada antes da assinada. A assinatura continuou passando na verificação, porque a referência dela seleciona a asserção original pelo ID, onde quer que ela esteja. O antipadrão leu a primeira Assertion do documento e fez o login de um usuário que o provedor de identidade nunca atestou. O validador usou signed_xml e viu apenas a original. Essa família de bugs costuma ser chamada de signature wrapping.
  • Um comentário dentro do NameID assinado. O provedor de identidade assinou admin@example.com.evil.test, uma conta que um usuário dele poderia ter legitimamente. Inserir <!----> depois de admin@example.com não altera a forma canônica, porque a canonicalização exclusiva sem comentários descarta os comentários; assim, a assinatura continua válida. Na lxml, .text retorna apenas o texto anterior ao primeiro nó filho, por isso o antipadrão leu admin@example.com. A signxml retorna o elemento assinado sem o comentário, e o validador junta todos os nós de texto: ele viu a conta real. Esse padrão exato, publicado em fevereiro de 2018 como CERT/CC VU#475445 depois de uma pesquisa do Duo Labs, afetou, entre outras, as bibliotecas python-saml e ruby-saml da OneLogin, a saml2-js, a OmniAuth-SAML e o OpenSAML C++ do Shibboleth.
  • Uma asserção para outro provedor de serviços, assinada pelo mesmo provedor de identidade, falhou na verificação do público-alvo. As duas implementações a barraram, porque o antipadrão, por acaso, verificava o público-alvo.
  • Expiração e reutilização (replay) só foram barradas pelo validador. As duas são regras do SAML, não da assinatura: a assinatura de uma asserção capturada continua válida para sempre.
  • Respostas editadas, assinadas de novo ou sem assinatura falharam na verificação da assinatura nas duas implementações: digest divergente; uma chave que não é a fixada, embora o certificado dela estivesse embutido no KeyInfo e trouxesse o mesmo nome de titular; e nenhuma assinatura onde location exige uma.

Detalhes que pesam em implantações reais

Assinatura da resposta ou da asserção. Os provedores de identidade assinam a asserção, a resposta ou as duas. Se só a Response estiver assinada, a asserção fica coberta como descendente, e a regra passa a ser: use a asserção que é filha do elemento de resposta assinado. O perfil POST continua exigindo asserções assinadas, então configure o provedor de identidade para assiná-las. Nunca aceite “uma assinatura em algum lugar do documento”.

Asserções criptografadas. Uma EncryptedAssertion precisa primeiro ser descriptografada com sua chave; depois, a Assertion descriptografada precisa trazer uma assinatura válida do provedor de identidade. A criptografia só prova que o remetente conhecia sua chave pública.

A confiança vem dos metadados. Os certificados de assinatura do provedor de identidade ficam na sua configuração, importados dos metadados dele e atualizados quando ele faz a rotação; durante uma troca de certificados, é normal haver certificados sobrepostos. Um certificado dentro da mensagem não prova nada: qualquer pessoa pode criar um com qualquer nome de titular, e foi exatamente isso que o caso “outra chave” fez.

Algoritmos. O texto do SAML Core, de 2005, cita RSA com SHA-1; a signxml 5.2.0 rejeita por padrão os métodos de assinatura e de digest com SHA-1, e convém que você faça o mesmo. Restrinja os métodos aceitos ao que seu provedor de identidade usa, normalmente RSA-SHA256 ou ECDSA-SHA256.

Parsing do XML. Antes de qualquer outra coisa, faça o parsing com entidades externas e DTDs desativadas; o parser da signxml define resolve_entities=False. Rejeite documentos com mais de um elemento com o mesmo ID.

Horários. Permita um pequeno desvio de relógio (de alguns segundos a uns dois minutos) e guarde os IDs de asserção já usados até o NotOnOrAfter de cada um, mais esse desvio.

Quando uma resposta legítima falha

O problema oposto — uma resposta real do seu provedor de identidade que não passa na verificação — tem uma lista curta de causas habituais:

  • O provedor de identidade fez a rotação do certificado e sua configuração ainda tem o antigo. Atualize a partir dos metadados dele, que normalmente listam o novo certificado antes de ele entrar em uso.
  • Algo reformatou o XML. A canonicalização exclusiva normaliza a marcação, não o texto: indentação ou quebras de linha acrescentadas entre os elementos alteram os nós de texto dentro do elemento assinado e quebram o digest. Verifique os bytes exatamente como saíram da decodificação do campo do formulário; nunca formate o XML para leitura (pretty-print), nem o serialize de novo com outra biblioteca, nem o “limpe” antes da verificação.
  • A decodificação errada. No binding HTTP-POST, o campo de formulário SAMLResponse contém o XML em base64, possivelmente com quebras de linha (SAML Bindings §3.5.4). No HTTP-Redirect, a mensagem é compactada com DEFLATE, a assinatura XML dela é removida e uma assinatura separada sobre a query string vai nos parâmetros SigAlg e Signature (§3.4.4.1); essa assinatura é verificada sobre os parâmetros codificados para URL, e não sobre o XML. Respostas que trazem asserções assinadas nem deveriam usar o binding Redirect.
  • A assinatura está onde seu validador não espera. Se o provedor de identidade assina a Response e sua configuração exige a assinatura dentro da Assertion (ou o contrário), a verificação falha. Decida qual das duas você exige e configure o provedor de identidade de acordo; não afrouxe a localização para “qualquer lugar”.
  • Relógios. Um “Not yet valid” logo depois do login significa que o relógio do provedor de serviços está atrasado; aumente a tolerância ao desvio só depois de acertar o relógio.

Bibliotecas

Use uma biblioteca SAML em vez de uma biblioteca genérica de assinatura XML com código de integração próprio, mantenha-a atualizada e procure na documentação dela a função que retorna o conteúdo assinado. Versões atuais em 20 de outubro de 2026:

LinguagemBiblioteca SAMLCamada de assinatura 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

O README da xml-crypto explicita a mesma regra da signxml: depois que checkSignature() for bem-sucedido, use o XML canônico retornado por getSignedReferences() e não pegue nós do documento original; getReferences() e validateElementAgainstReferences() estão obsoletos para esse fim. Se você mantém código que verifica e depois consulta o documento de entrada, é essa linha que precisa mudar.

Lista de verificação

  1. Faça o parsing com segurança: sem DTDs, sem entidades externas, sem valores de ID duplicados.
  2. Verifique a assinatura com o certificado fixado, vindo dos metadados do provedor de identidade; ignore o KeyInfo para fins de confiança.
  3. Exija exatamente uma Reference, apontando para o elemento que você vai usar, apenas com as transformações enveloped-signature e de canonicalização exclusiva, e só com algoritmos aceitos.
  4. Somente a partir do elemento assinado: Issuer igual ao ID da entidade do provedor de identidade; janela de tempo de Conditions e AudienceRestriction com o ID da entidade do seu serviço; um SubjectConfirmationData do método bearer com a URL do seu ACS como Recipient, um NotOnOrAfter no futuro e o ID da sua requisição como InResponseTo (ausente apenas em respostas não solicitadas que você decidiu aceitar).
  5. Registre o ID da asserção e rejeite-o se ele aparecer de novo enquanto a asserção ainda for válida.
  6. Leia o NameID e os atributos do elemento assinado, juntando todos os nós de texto, e identifique os usuários pelo par Issuer e NameID.
  7. Teste com uma asserção expirada, uma asserção reutilizada e um certificado que não é o do provedor de identidade: todos esses casos precisam falhar. Teste também uma resposta com signature wrapping e um NameID com comentário: esses casos precisam resultar no login apenas da identidade completa que o provedor assinou.

Fontes