Zum Inhalt springen
ecdsa.com
← Alle Artikel

SAML-Signaturvalidierung: was du prüfen musst und warum

Eine SAML-Signatur kann vollkommen gültig sein, während die Identität, die dein Code aus demselben Dokument liest, gar nicht von ihr abgedeckt ist. Hier erfährst du, wie XML-Signaturen auf das zeigen, was sie signieren, und welche Prüfungen die SAML-Spezifikationen verlangen. Dazu kommt ein Validator, getestet mit Signature Wrapping, eingefügten Kommentaren sowie erneut eingespielten und neu signierten Responses.

Kurz gesagt. Die Validierung einer SAML-Response besteht aus zwei Hälften, und die meisten Vorfälle entstehen, weil nur die erste erledigt wird. Erstens: Prüfe die XML-Signatur mit dem Zertifikat des Identity Providers aus dessen Metadaten, nie mit dem aus KeyInfo in der Nachricht, und verlange genau eine Reference, die auf die ID der Assertion (oder der Response) zeigt, die du gleich verwenden willst. Zweitens: Lies jeden Wert, auf den du dich verlässt (die NameID, Attribute, Issuer, Audience, Recipient, Zeitangaben), aus dem Element, das diese Signatur abdeckt, statt das empfangene Dokument zu durchsuchen: Ein XML-Dokument kann neben dem signierten Element ein unsigniertes enthalten, das ihm zum Verwechseln ähnlich sieht. Wende dann die SAML-Regeln an: Die Audience ist deine Entity-ID, die Bearer-SubjectConfirmationData nennt deine Assertion-Consumer-URL und deinen Request, nichts ist abgelaufen, und jede Assertion-ID wird nur einmal akzeptiert.

Wie eine XML-Signatur auf das zeigt, was sie signiert

Eine XML-Signatur (XML Signature Syntax and Processing 1.1) signiert ein Dokument nicht als Bytefolge. Signiert wird ein Element SignedInfo, das ein oder mehrere Elemente Reference auflistet. Jede Reference hat eine URI, die Inhalt auswählt, eine Liste von Transforms, die darauf angewendet werden, und einen DigestValue des Ergebnisses. So sieht eine SAML-Signatur aus, wie sie der Test-Identity-Provider weiter unten ausstellt, hier mit gekürzten langen base64-Werten:

Eine signierte SAML-Response (Ausgabe von show.py, base64-Werte gekürzt)
<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>

Drei Details entscheiden, was geschützt ist:

  • URI="#_cf90…" ist eine Referenz innerhalb desselben Dokuments: Ausgewählt wird das Element, dessen Attribut ID diesen Wert hat, samt seinen Nachfahren (XMLDSig §4.4.3.2). Wo dieses Element im Dokument steht, ist nicht Teil der Signatur.
  • Die Transformation enveloped-signature entfernt vor der Digest-Berechnung das Element ds:Signature selbst, sodass die Signatur innerhalb dessen stehen kann, was sie signiert.
  • Exklusive Kanonisierung (xml-exc-c14n#, hier ohne Kommentare) macht aus dem ausgewählten Element eine kanonische Bytefolge: Attributreihenfolge, Namespace-Deklarationen und Whitespace innerhalb von Tags werden normalisiert, Kommentare entfallen. Verschiedene Dokumente können deshalb dieselbe kanonische Form haben.

Die Validierung besteht aus der „Referenzvalidierung“ (jeden Digest neu berechnen), gefolgt von der „Signaturvalidierung“ über das kanonisierte SignedInfo (XMLDSig §3.2). Diese Validierung beantwortet genau eine Frage: Diese kanonischen Bytes wurden mit diesem Schlüssel signiert. Über den Rest des Dokuments sagt sie nichts. Die Spezifikation macht das sogar zur Abschnittsüberschrift, „Only What is Signed is Secure“ (§8.1.1), und ergänzt, dass Anwendungen mit den Daten arbeiten sollten, „die transformiert (einschließlich Kanonisierung) und signiert wurden“ (§8.1.3).

Was die SAML-Spezifikationen verlangen

SAML schränkt XMLDSig weiter ein (SAML Core 2.0 §5.4): Signaturen MÜSSEN enveloped sein; sie MÜSSEN „eine einzige <ds:Reference> enthalten, die eine Referenz innerhalb desselben Dokuments auf den Wert des ID-Attributs des Wurzelelements der signierten Assertion oder Protokollnachricht enthält“; exklusive Kanonisierung SOLLTE verwendet werden; und andere Transformationen als enveloped-signature und exklusive Kanonisierung SOLLTEN NICHT vorkommen. Verifizierer, die andere Transformationen akzeptieren, „MÜSSEN sicherstellen, dass kein Inhalt der SAML-Nachricht von der Signatur ausgenommen ist“.

Das Profil Web Browser SSO (SAML Profiles 2.0 §4.1.4) ergänzt die Prüfungen, durch die eine gültige Signatur erst „dieser Benutzer, für diesen Dienst, jetzt“ bedeutet:

  • Beim HTTP-POST-Binding „MÜSSEN die enthaltenen Assertions signiert sein“ (§4.1.4.5). Eine signierte Response um eine unsignierte Assertion herum erfüllt das nicht.
  • Der Service Provider MUSS alle Signaturen prüfen und außerdem, dass Recipient in der Bearer-SubjectConfirmationData „der URL des Assertion Consumer Service entspricht“, dass deren NotOnOrAfter „unter Berücksichtigung einer zulässigen Uhrenabweichung nicht überschritten ist“ und dass InResponseTo der ID seines AuthnRequest entspricht oder bei einer unaufgeforderten Response fehlt (§4.1.4.3).
  • Er MUSS „sicherstellen, dass Bearer-Assertions nicht erneut eingespielt werden, indem er die Menge der verwendeten ID-Werte vorhält“, und zwar so lange, wie die Assertion gültig wäre (§4.1.4.5).
  • Die Bedingung AudienceRestriction „ergibt genau dann Valid, wenn die SAML-Relying-Party zu mindestens einer der angegebenen Audiences gehört“ (SAML Core §2.5.1.4), und NotBefore und NotOnOrAfter in Conditions begrenzen die Gültigkeit der Assertion (§2.5.1.2).

Ein Validator, der nur signierte Daten liest

Der Validator unten nutzt für die XML-Signatur signxml 5.2.0. Die Dokumentation von signxml nennt die Regel „See what is signed“ und macht sie zur API: verify() gibt das signierte Element als signed_xml zurück, ohne die Kommentare, die die Signatur nicht abgedeckt hat, und location legt fest, wo ds:Signature stehen muss. Alles nach der Signaturprüfung liest aus 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}

Zum Vergleich sieht der Fehler so aus. Dieser Code ruft dieselbe Signaturprüfung auf, verwirft das Ergebnis und liest dann die erste Assertion, die er im Dokument findet:

naive.py (Anti-Pattern)
# 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}

Getestet mit manipulierten Responses

Ein Test-Identity-Provider erzeugt bei jedem Lauf einen P-256-Schlüssel und ein selbstsigniertes Zertifikat und stellt signierte Responses aus; der Service Provider pinnt dieses Zertifikat:

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)

Das Testskript schickt beiden Funktionen zehn Responses: eine gültige, eine frische, die danach erneut eingespielt wird, und acht manipulierte oder nicht akzeptable:

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)
Ausgabe (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

Die Fälle im Einzelnen:

  • Eine unsignierte Assertion vor der signierten. Die Signatur bestand die Prüfung trotzdem, weil ihre Referenz die ursprüngliche Assertion über die ID auswählt, egal wo sie steht. Das Anti-Pattern las die erste Assertion im Dokument und meldete einen Benutzer an, für den der Identity Provider nie gebürgt hat. Der Validator nutzte signed_xml und sah nur das Original. Diese Familie von Fehlern heißt meist Signature Wrapping.
  • Ein Kommentar in der signierten NameID. Der Identity Provider signierte admin@example.com.evil.test, ein Konto, das einer seiner Benutzer ganz legitim haben kann. Ein eingefügtes <!----> nach admin@example.com ändert die kanonische Form nicht, weil exklusive Kanonisierung in der Variante ohne Kommentare alle Kommentare entfernt; die Signatur bleibt also gültig. In lxml liefert .text nur den Text vor dem ersten Kindknoten, deshalb las das Anti-Pattern admin@example.com. signxml gibt das signierte Element ohne den Kommentar zurück, und der Validator fügt alle Textknoten zusammen; er sah das echte Konto. Genau dieses Muster, im Februar 2018 nach Untersuchungen von Duo Labs als CERT/CC VU#475445 veröffentlicht, betraf unter anderem python-saml und ruby-saml von OneLogin, saml2-js, OmniAuth-SAML und OpenSAML C++ von Shibboleth.
  • Eine Assertion für einen anderen Service Provider, signiert vom selben Identity Provider, scheiterte an der Audience-Prüfung. Beide Implementierungen fingen sie ab, weil das Anti-Pattern zufällig die Audience prüfte.
  • Ablauf und Replay fing nur der Validator ab. Beides sind SAML-Regeln, keine Signaturregeln: Die Signatur einer abgefangenen Assertion bleibt für immer gültig.
  • Bearbeitete, neu signierte und unsignierte Responses scheiterten in beiden an der Signatur: ein abweichender Digest, ein Schlüssel, der nicht der gepinnte ist, obwohl sein Zertifikat in KeyInfo eingebettet war und denselben Subject-Namen trug, und keine Signatur dort, wo location eine verlangt.

Details, die im echten Einsatz entscheiden

Signatur der Response oder der Assertion. Identity Provider signieren die Assertion, die Response oder beides. Ist nur die Response signiert, ist die Assertion als Nachfahre mit abgedeckt, und die Regel lautet dann: Verwende die Assertion, die ein Kind des signierten Response-Elements ist. Das POST-Profil verlangt trotzdem signierte Assertions, also konfiguriere den Identity Provider so, dass er sie signiert. Akzeptiere nie „eine Signatur irgendwo im Dokument“.

Verschlüsselte Assertions. Eine EncryptedAssertion muss zuerst mit deinem Schlüssel entschlüsselt werden; die entschlüsselte Assertion muss dann eine gültige Signatur des Identity Providers tragen. Die Verschlüsselung beweist nur, dass der Absender deinen öffentlichen Schlüssel kannte.

Vertrauen kommt aus den Metadaten. Die Signaturzertifikate des Identity Providers gehören in deine Konfiguration, importiert aus seinen Metadaten und aktualisiert, wenn er sie rotiert; überlappende Zertifikate während eines Wechsels sind normal. Ein Zertifikat in der Nachricht beweist nichts: Jeder kann eines mit beliebigem Subject-Namen erzeugen, und genau das tat der Fall mit dem anderen Schlüssel.

Algorithmen. Der Text von SAML Core aus dem Jahr 2005 nennt RSA mit SHA-1; signxml 5.2.0 lehnt SHA-1 als Signatur- und Digest-Methode standardmäßig ab, und das solltest du auch tun. Beschränke die akzeptierten Methoden auf das, was dein Identity Provider verwendet, typischerweise RSA-SHA256 oder ECDSA-SHA256.

XML-Parsing. Parse vor allem anderen mit deaktivierten externen Entitäten und DTDs; der Parser von signxml setzt resolve_entities=False. Lehne Dokumente ab, in denen mehr als ein Element dieselbe ID trägt.

Zeiten. Erlaube eine kleine Uhrenabweichung (Sekunden bis wenige Minuten) und speichere verwendete Assertion-IDs bis zu ihrem NotOnOrAfter plus dieser Abweichung.

Wenn eine legitime Response scheitert

Für das umgekehrte Problem, eine echte Response deines Identity Providers, die die Prüfung nicht besteht, gibt es eine kurze Liste üblicher Ursachen:

  • Der Identity Provider hat sein Zertifikat rotiert, und deine Konfiguration enthält noch das alte. Aktualisiere es aus seinen Metadaten, die das neue Zertifikat normalerweise schon auflisten, bevor es verwendet wird.
  • Etwas hat das XML umformatiert. Exklusive Kanonisierung normalisiert Markup, keinen Text: Hinzugefügte Einrückungen oder Zeilenumbrüche zwischen Elementen ändern Textknoten im signierten Element und machen den Digest ungültig. Prüfe die Bytes genau so, wie sie aus dem Formularfeld dekodiert wurden; vor der Prüfung gilt: kein Pretty-Printing, kein erneutes Serialisieren mit einer anderen Bibliothek, kein „Aufräumen“.
  • Die falsche Dekodierung. Beim HTTP-POST-Binding enthält das Formularfeld SAMLResponse das XML als base64; es kann Zeilenumbrüche enthalten (SAML Bindings §3.5.4). Bei HTTP-Redirect wird die Nachricht DEFLATE-komprimiert und ihre XML-Signatur entfernt, und eine separate Signatur über den Query-String wird in den Parametern SigAlg und Signature übertragen (§3.4.4.1); diese Signatur wird über die URL-kodierten Parameter geprüft, nicht über das XML. Responses mit signierten Assertions sollten das Redirect-Binding gar nicht verwenden.
  • Die Signatur steht dort, wo dein Validator sie nicht erwartet. Signiert der Identity Provider die Response, während deine Konfiguration die Signatur in der Assertion verlangt (oder umgekehrt), schlägt die Prüfung fehl. Entscheide, welche du verlangst, und konfiguriere den Identity Provider passend; lockere die Vorgabe für die Position nicht auf „irgendwo“.
  • Uhren. „Noch nicht gültig“ direkt nach der Anmeldung bedeutet, dass die Uhr des Service Providers nachgeht; vergrößere die erlaubte Abweichung erst, nachdem du die Uhr korrigiert hast.

Bibliotheken

Verwende eine SAML-Bibliothek statt einer allgemeinen Bibliothek für XML-Signaturen plus eigenem Glue-Code, halte sie aktuell und suche in ihrer Dokumentation die Funktion, die den signierten Inhalt zurückgibt. Aktuelle Versionen am 20. Oktober 2026:

SpracheSAML-BibliothekSchicht für XML-Signaturen
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

Das README von xml-crypto formuliert dieselbe Regel wie signxml: Verwende nach erfolgreichem checkSignature() das kanonische XML aus getSignedReferences() und nimm keine Knoten aus dem ursprünglichen Dokument; getReferences() und validateElementAgainstReferences() sind für diesen Zweck veraltet. Wenn du Code pflegst, der erst prüft und dann das Eingabedokument abfragt, ist das die Zeile, die du ändern musst.

Checkliste

  1. Parse sicher: keine DTDs, keine externen Entitäten, keine doppelten Werte für ID.
  2. Prüfe die Signatur mit dem gepinnten Zertifikat aus den Metadaten des Identity Providers; ignoriere KeyInfo, wenn es um Vertrauen geht.
  3. Verlange genau eine Reference, die auf das Element zeigt, das du verwenden wirst, nur mit den Transformationen enveloped-signature und exklusive Kanonisierung und nur mit akzeptierten Algorithmen.
  4. Nur aus dem signierten Element: Issuer entspricht der Entity-ID des Identity Providers; Zeitfenster aus Conditions und AudienceRestriction mit deiner Entity-ID; eine Bearer-SubjectConfirmationData mit deiner ACS-URL als Recipient, einem NotOnOrAfter in der Zukunft und deiner Request-ID als InResponseTo (fehlt nur bei unaufgeforderten Responses, die du bewusst akzeptierst).
  5. Speichere die Assertion-ID und lehne sie ab, wenn sie erneut auftaucht, solange die Assertion noch gültig ist.
  6. Lies NameID und Attribute aus dem signierten Element, füge dabei alle Textknoten zusammen, und ordne Benutzer über Issuer plus NameID zu.
  7. Teste mit einer abgelaufenen und einer erneut eingespielten Assertion sowie einem Zertifikat, das nicht dem Identity Provider gehört: Alle müssen scheitern. Teste außerdem eine per Signature Wrapping manipulierte Response und eine NameID mit Kommentar: Bei beiden muss ausschließlich die Identität angemeldet werden, die der Identity Provider signiert hat, und zwar vollständig.

Quellen