En bref. La validation d’une réponse SAML comporte deux volets, et la plupart des incidents surviennent quand on s’arrête au premier. D’abord, vérifiez la signature XML avec le certificat du fournisseur d’identité tiré de ses métadonnées, jamais de l’élément KeyInfo du message, et exigez exactement une Reference pointant vers l’ID de l’Assertion (ou de la Response) que vous vous apprêtez à utiliser. Ensuite, lisez chaque valeur dont vous dépendez (le NameID, les attributs, Issuer, Audience, Recipient, les dates) dans l’élément que couvre cette signature, et non en effectuant une recherche dans le document reçu : un document XML peut contenir, à côté de l’élément signé, un élément non signé qui lui ressemble. Appliquez enfin les règles SAML : l’audience correspond à votre ID d’entité, l’élément SubjectConfirmationData de type bearer désigne l’URL de votre service consommateur d’assertions et votre requête, rien n’a expiré, et chaque ID d’assertion n’est accepté qu’une seule fois.
Comment une signature XML désigne ce qu’elle signe
Une signature XML (XML Signature Syntax and Processing 1.1) ne signe pas un document en tant que suite d’octets. Elle signe un élément SignedInfo, qui énumère un ou plusieurs éléments Reference. Chaque Reference possède un URI qui sélectionne un contenu, une liste de Transforms qui lui sont appliquées et un DigestValue, le condensat du résultat. Voici une signature SAML telle que l’émet le fournisseur d’identité de laboratoire présenté plus bas, affichée avec les longues valeurs base64 abrégées :
<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>
Trois détails déterminent ce qui est protégé :
URI="#_cf90…"est une référence interne au document : elle sélectionne l’élément dont l’attributIDa cette valeur, avec ses descendants (XMLDSig §4.4.3.2). L’emplacement de cet élément dans le document ne fait pas partie de la signature.- La transformation enveloped-signature retire l’élément
ds:Signaturelui-même avant le calcul du condensat, si bien que la signature peut se trouver à l’intérieur de ce qu’elle signe. - La canonicalisation exclusive (
xml-exc-c14n#, ici sans commentaires) transforme l’élément sélectionné en une suite d’octets canonique : l’ordre des attributs, les déclarations d’espaces de noms et les blancs à l’intérieur des balises sont normalisés, et les commentaires sont supprimés. Des documents différents peuvent donc avoir la même forme canonique.
La validation se compose de la « validation des références » (recalculer chaque condensat), suivie de la « validation de la signature » sur le SignedInfo canonicalisé (XMLDSig §3.2). Elle ne répond qu’à une seule question : ces octets canoniques ont-ils été signés par cette clé ? Elle ne dit rien du reste du document. La spécification l’écrit dans le titre d’une section, « Only What is Signed is Secure » (seul ce qui est signé est sécurisé, §8.1.1), et ajoute que les applications devraient travailler sur les données « qui ont été transformées (canonicalisation comprise) et signées » (§8.1.3).
Ce qu’exigent les spécifications SAML
SAML restreint XMLDSig (SAML Core 2.0 §5.4) : les signatures DOIVENT être enveloppées ; elles DOIVENT contenir « un seul élément <ds:Reference> contenant une référence interne au document vers la valeur de l’attribut ID de l’élément racine de l’assertion ou du message de protocole signé » ; la canonicalisation exclusive DEVRAIT être utilisée ; et des transformations autres qu’enveloped-signature et la canonicalisation exclusive NE DEVRAIENT PAS figurer. Les vérificateurs qui acceptent d’autres transformations « DOIVENT s’assurer qu’aucun contenu du message SAML n’est exclu de la signature ».
Le profil Web Browser SSO (authentification unique depuis un navigateur web, SAML Profiles 2.0 §4.1.4) ajoute les contrôles grâce auxquels une signature valide signifie « cet utilisateur, pour ce service, maintenant » :
- Avec la liaison HTTP-POST, « la ou les assertions incluses DOIVENT être signées » (§4.1.4.5). Une
Responsesignée autour d’une assertion non signée ne satisfait pas cette exigence. - Le fournisseur de services DOIT vérifier toutes les signatures et s’assurer que l’attribut
Recipientde l’élémentSubjectConfirmationDatade type bearer « correspond à l’URL du service consommateur d’assertions », que sonNotOnOrAfter« n’est pas dépassé, compte tenu du décalage d’horloge admissible » et queInResponseToest égal à l’ID de sa requêteAuthnRequest, ou absent dans le cas d’une réponse non sollicitée (§4.1.4.3). - Il DOIT « s’assurer que les assertions de type bearer ne sont pas rejouées, en conservant l’ensemble des valeurs d’ID déjà utilisées » aussi longtemps que l’assertion serait valide (§4.1.4.5).
- La condition
AudienceRestriction« est évaluée à Valid si et seulement si la partie de confiance SAML (relying party) fait partie d’une ou de plusieurs des audiences spécifiées » (SAML Core §2.5.1.4), et les attributsNotBeforeetNotOnOrAfterdeConditionsbornent la validité de l’assertion (§2.5.1.2).
Un validateur qui ne lit que des données signées
Le validateur ci-dessous s’appuie sur signxml 5.2.0 pour la signature XML. La documentation de signxml elle-même nomme cette règle « See what is signed » (voyez ce qui est signé) et l’inscrit dans son API : verify() renvoie l’élément signé dans signed_xml, débarrassé des commentaires que la signature ne couvrait pas, et location impose l’emplacement où ds:Signature doit se trouver. Tout ce qui suit la vérification de la signature lit 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}
À titre de comparaison, voici la forme que prend le bug. Ce code appelle la même vérification de signature, ignore son résultat, puis lit la première Assertion qu’il trouve dans le document :
# 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}
Testé face à des réponses manipulées
Un fournisseur d’identité de laboratoire génère à chaque exécution une clé P-256 et un certificat auto-signé, et émet des réponses signées ; le fournisseur de services épingle ce certificat :
# 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)
Le script pilote envoie dix réponses aux deux fonctions : une réponse valide, une réponse neuve qui est ensuite rejouée, et huit réponses manipulées ou irrecevables :
# 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
Cas par cas :
- Une assertion non signée avant l’assertion signée. La signature a tout de même été vérifiée, car sa référence sélectionne l’assertion d’origine par son ID, où qu’elle se trouve. L’anti-pattern a lu la première
Assertiondu document et a connecté un utilisateur dont le fournisseur d’identité ne s’est jamais porté garant. Le validateur a utilisésigned_xmlet n’a vu que l’original. Cette famille de bugs porte généralement le nom de « signature wrapping ». - Un commentaire dans le
NameIDsigné. Le fournisseur d’identité a signéadmin@example.com.evil.test, un compte que l’un de ses utilisateurs pouvait légitimement posséder. Insérer<!---->aprèsadmin@example.comne modifie pas la forme canonique, car la canonicalisation exclusive sans commentaires supprime les commentaires : la signature reste donc valide. Dans lxml,.textne renvoie que le texte situé avant le premier nœud enfant, si bien que l’anti-pattern a luadmin@example.com. signxml renvoie l’élément signé sans le commentaire, et le validateur concatène tous les nœuds texte ; il a vu le vrai compte. Ce schéma précis, publié en février 2018 sous la référence CERT/CC VU#475445 à la suite des recherches de Duo Labs, touchait notamment python-saml et ruby-saml d’OneLogin, saml2-js, OmniAuth-SAML et OpenSAML C++ de Shibboleth. - Une assertion destinée à un autre fournisseur de services, signée par le même fournisseur d’identité, a échoué au contrôle de l’audience. Les deux implémentations l’ont interceptée, parce que l’anti-pattern, par chance, vérifiait l’audience.
- L’expiration et le rejeu n’ont été interceptés que par le validateur. Ce sont deux règles SAML, et non des règles de signature : la signature d’une assertion capturée reste valide pour toujours.
- Les réponses modifiées, signées à nouveau et non signées ont échoué à l’étape de la signature dans les deux implémentations : condensat non concordant, clé qui n’est pas celle épinglée bien que son certificat ait été intégré dans
KeyInfoet porte le même nom de sujet, et absence de signature là oùlocationen exige une.
Les détails qui comptent dans les déploiements réels
Signature de la réponse ou signature de l’assertion. Les fournisseurs d’identité signent l’assertion, la réponse, ou les deux. Si seule la Response est signée, l’assertion est couverte en tant que descendante, et la règle devient : utilisez l’assertion qui est un enfant de l’élément de réponse signé. Le profil POST exige malgré tout des assertions signées : configurez donc le fournisseur d’identité pour qu’il les signe. N’acceptez jamais « une signature quelque part dans le document ».
Assertions chiffrées. Une EncryptedAssertion doit d’abord être déchiffrée avec votre clé ; l’Assertion déchiffrée doit ensuite porter une signature valide du fournisseur d’identité. Le chiffrement prouve seulement que l’expéditeur connaissait votre clé publique.
La confiance vient des métadonnées. Les certificats de signature du fournisseur d’identité ont leur place dans votre configuration, importés depuis ses métadonnées et mis à jour lorsqu’il les renouvelle ; pendant une transition, il est normal que des certificats se chevauchent. Un certificat contenu dans le message ne prouve rien : n’importe qui peut en créer un avec n’importe quel nom de sujet, et c’est précisément ce qu’a fait le cas « another key ».
Algorithmes. Le texte de SAML Core, qui date de 2005, cite RSA avec SHA-1 ; signxml 5.2.0 rejette par défaut les méthodes de signature et de condensat SHA-1, et vous devriez faire de même. Limitez les méthodes acceptées à celles qu’utilise votre fournisseur d’identité, généralement RSA-SHA256 ou ECDSA-SHA256.
Analyse du XML. Avant toute chose, analysez le document avec les entités externes et les DTD désactivées ; l’analyseur de signxml définit resolve_entities=False. Rejetez les documents dans lesquels plusieurs éléments portent le même ID.
Horodatages. Tolérez un léger décalage d’horloge (de quelques secondes à une ou deux minutes) et conservez les ID d’assertion utilisés jusqu’à leur NotOnOrAfter augmenté de ce décalage.
Quand une réponse légitime échoue
Le problème inverse, une réponse authentique de votre fournisseur d’identité qui ne passe pas la vérification, a généralement l’une des causes suivantes :
- Le fournisseur d’identité a renouvelé son certificat et votre configuration contient toujours l’ancien. Rafraîchissez-la depuis ses métadonnées, qui mentionnent normalement le nouveau certificat avant sa mise en service.
- Quelque chose a reformaté le XML. La canonicalisation exclusive normalise le balisage, pas le texte : une indentation ou des sauts de ligne ajoutés entre les éléments modifient les nœuds texte à l’intérieur de l’élément signé et cassent le condensat. Vérifiez les octets exactement tels qu’ils sortent du décodage du champ de formulaire ; avant la vérification, ne faites jamais de mise en forme lisible (pretty-print), de resérialisation par une autre bibliothèque ni de « nettoyage ».
- Un mauvais décodage. Avec la liaison HTTP-POST, le champ de formulaire
SAMLResponsecontient le XML encodé en base64 ; il peut être coupé en lignes (SAML Bindings §3.5.4). Avec HTTP-Redirect, le message est compressé avec DEFLATE, sa signature XML est retirée, et une signature distincte, calculée sur la chaîne de requête, circule dans les paramètresSigAlgetSignature(§3.4.4.1) ; cette signature se vérifie sur les paramètres encodés pour l’URL, et non sur le XML. Les réponses qui transportent des assertions signées ne devraient pas du tout utiliser la liaison Redirect. - La signature n’est pas là où votre validateur l’attend. Si le fournisseur d’identité signe la
Responsealors que votre configuration exige la signature dans l’Assertion(ou l’inverse), la vérification échoue. Décidez laquelle vous exigez et configurez le fournisseur d’identité en conséquence ; n’assouplissez pas la contrainte d’emplacement en « n’importe où ». - Horloges. « Pas encore valide » juste après la connexion signifie que l’horloge du fournisseur de services retarde ; n’élargissez la tolérance de décalage qu’après avoir corrigé l’horloge.
Bibliothèques
Utilisez une bibliothèque SAML plutôt qu’une bibliothèque généraliste de signature XML complétée par votre propre code d’assemblage, tenez-la à jour, et cherchez dans sa documentation la fonction qui renvoie le contenu signé. Versions à jour au 20 octobre 2026 :
| Langage | Bibliothèque SAML | Couche de signature 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 |
Le README de xml-crypto énonce la même règle que signxml : une fois que checkSignature() a réussi, utilisez le XML canonique renvoyé par getSignedReferences(), et ne prenez pas de nœuds dans le document d’origine ; getReferences() et validateElementAgainstReferences() sont obsolètes pour cet usage. Si vous maintenez du code qui vérifie puis interroge le document d’entrée, c’est cette ligne qu’il faut changer.
Liste de contrôle
- Analysez le XML de manière sécurisée : pas de DTD, pas d’entités externes, pas d’
IDen double. - Vérifiez la signature avec le certificat épinglé issu des métadonnées du fournisseur d’identité ; ignorez
KeyInfopour établir la confiance. - Exigez exactement une
Reference, pointant vers l’élément que vous allez utiliser, avec pour seules transformations enveloped-signature et la canonicalisation exclusive, et uniquement des algorithmes acceptés. - Dans l’élément signé uniquement :
Issuerest égal à l’ID d’entité du fournisseur d’identité ; la fenêtre temporelle deConditionset uneAudienceRestrictionavec votre ID d’entité ; un élémentSubjectConfirmationDatade type bearer avec votre URL ACS commeRecipient, unNotOnOrAfterdans le futur et l’ID de votre requête commeInResponseTo(absent uniquement pour les réponses non sollicitées que vous avez décidé d’accepter). - Enregistrez l’ID de l’assertion et rejetez-le s’il se présente de nouveau alors que l’assertion est encore valide.
- Lisez le
NameIDet les attributs dans l’élément signé, en concaténant tous les nœuds texte, et identifiez les utilisateurs par la paireIssueretNameID. - Testez avec une assertion expirée, une assertion rejouée et un certificat qui n’est pas celui du fournisseur d’identité : tous ces cas doivent échouer. Testez aussi une réponse encapsulée (signature wrapping) et un
NameIDcontenant un commentaire : ces deux cas ne doivent connecter que l’identité signée par le fournisseur, dans son intégralité.
Sources
- XML Signature Syntax and Processing 1.1 : §3.2 (validation de base), §4.4.3 (références), §6.1 (algorithmes), §8.1 (considérations de sécurité)
- SAML Core 2.0 : §2.5.1 (conditions), §5.4 (profil de signature XML)
- SAML Profiles 2.0 : §4.1.4 (règles de traitement du profil Web Browser SSO)
- SAML Bindings 2.0 : §3.4.4.1 (Redirect, DEFLATE), §3.5.4 (POST)
- Documentation de signxml : « See what is signed »
- README de xml-crypto :
getSignedReferences() - CERT/CC VU#475445 : plusieurs bibliothèques SAML peuvent permettre de contourner l’authentification par une canonicalisation XML et un parcours du DOM incorrects (2018)