本文へスキップ
ecdsa.com
← すべての記事

SAML 署名の検証:確認すべき項目とその理由

SAML の署名が完全に有効であっても、コードが同じ文書から読み取るユーザーの身元は、その署名の対象外ということがあります。この記事では、XML 署名が署名対象をどのように指し示すか、SAML の仕様が求めるチェック、そしてラッピング、コメントの挿入、リプレイ、再署名を施したレスポンスでテストしたバリデーターを紹介します。

要点:SAML レスポンスの検証は 2 つの部分から成り、インシデントの多くは前半だけで終えてしまうことが原因です。まず、XML 署名を ID プロバイダーの証明書で検証します。証明書は必ず ID プロバイダーのメタデータから取得し、メッセージ内の KeyInfo からは決して取得しないでください。そして、これから使う Assertion(または Response)の ID を指す Reference がちょうど 1 つであることを必須とします。次に、依拠するすべての値(NameID、属性、Issuer、Audience、Recipient、時刻)は、受け取った文書を検索して得るのではなく、その署名が対象とする要素から読み取ります。XML 文書には、署名された要素の隣に、それとよく似た未署名の要素を含めることができるからです。そのうえで SAML のルールを適用します。オーディエンスが自分のエンティティ ID であること、ベアラーの SubjectConfirmationData が自分のアサーションコンシューマーサービスの URL と自分のリクエストを指していること、何も期限切れになっていないこと、各アサーション ID を 1 回しか受け入れないことです。

XML 署名が署名対象を指し示す仕組み

XML 署名(XML Signature Syntax and Processing 1.1)は、文書をバイト列として署名するわけではありません。署名するのは SignedInfo 要素で、この要素には 1 つ以上の Reference が列挙されています。各 Reference は、内容を選択する URI、その内容に適用する Transforms のリスト、そして結果の DigestValue を持ちます。以下は、後述する実験用の ID プロバイダーが発行した SAML 署名で、長い base64 の値は短縮して表示しています。

署名済みの SAML レスポンス(show.py の出力、base64 の値は短縮)
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_ef6e7d7e83384141a7a16e6fb9d52d61" Version="2.0" IssueInstant="2026-10-20T13:39:36Z" Destination="https://sp.example/saml/acs" InResponseTo="_req-7d1f">
  <saml:Issuer>https://idp.example/saml</saml:Issuer>
  <samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
  <saml:Assertion ID="_cf908bd7284743f8997ddd7943f6640c" Version="2.0" IssueInstant="2026-10-20T13:39:36Z">
    <saml:Issuer>https://idp.example/saml</saml:Issuer>
    <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256"/><ds:Reference URI="#_cf908bd7284743f8997ddd7943f6640c"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>genlRcOfIZpWA8BHZ0Eo42LeZcRMriskgTApPDxsPI0=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>cPF5v/9Ve6uUmgcn...</ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIIBPDCB4qADAgEC...</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature><saml:Subject>
      <saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">alice@example.com</saml:NameID>
      <saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
        <saml:SubjectConfirmationData NotOnOrAfter="2026-10-20T13:44:36Z" Recipient="https://sp.example/saml/acs" InResponseTo="_req-7d1f"/>
      </saml:SubjectConfirmation>
    </saml:Subject>
    <saml:Conditions NotBefore="2026-10-20T13:39:36Z" NotOnOrAfter="2026-10-20T13:44:36Z">
      <saml:AudienceRestriction><saml:Audience>https://sp.example/saml</saml:Audience></saml:AudienceRestriction>
    </saml:Conditions>
    <saml:AuthnStatement AuthnInstant="2026-10-20T13:39:36Z"><saml:AuthnContext>
      <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
    </saml:AuthnContext></saml:AuthnStatement>
  </saml:Assertion></samlp:Response>

何が保護されるかは、次の 3 つの点で決まります。

  • URI="#_cf90…" は同一文書内参照です。ID 属性がその値を持つ要素を、その子孫要素ごと選択します(XMLDSig §4.4.3.2)。その要素が文書内のどこにあるかは、署名の対象に含まれません。
  • エンベロープ署名変換(enveloped-signature)は、ダイジェストを計算する前に ds:Signature 要素そのものを取り除きます。そのため、署名を署名対象の内部に置くことができます。
  • 排他的正規化(xml-exc-c14n#、ここではコメントなし)は、選択された要素を正規化されたバイト列に変換します。属性の順序、名前空間宣言、タグ内の空白が正規化され、コメントは削除されます。そのため、異なる文書が同じ正規形になることがあります。

検証は、「参照の検証」(各ダイジェストを再計算)と、それに続く正規化済みの SignedInfo に対する「署名の検証」から成ります(XMLDSig §3.2)。検証で確かめられるのは、この正規化されたバイト列はこの鍵で署名されたという 1 点だけです。文書の残りの部分については何も示しません。仕様はこのことを「署名されたものだけが安全(Only What is Signed is Secure)」という節タイトル(§8.1.1)で表し、さらに、アプリケーションは「変換(正規化を含む)を経て署名された」データを処理すべきだと付け加えています(§8.1.3)。

SAML の仕様が求めること

SAML は XMLDSig の使い方を絞り込んでいます(SAML Core 2.0 §5.4)。署名はエンベロープ署名でなければならず(MUST)、「署名対象のアサーションまたはプロトコルメッセージのルート要素の ID 属性値への同一文書内参照を含む、単一の <ds:Reference>」を含まなければなりません(MUST)。排他的正規化を使うべきであり(SHOULD)、エンベロープ署名変換と排他的正規化以外の変換は含めるべきではありません(SHOULD NOT)。ほかの変換を受け入れる検証者は、「SAML メッセージのどの内容も署名の対象から除外されていないことを保証しなければならない(MUST)」とされています。

Web ブラウザー SSO プロファイル(SAML Profiles 2.0 §4.1.4)は、有効な署名に「このユーザーが、このサービスに対して、今」という意味を持たせるためのチェックを追加しています。

  • HTTP-POST バインディングでは、「含まれるアサーションは署名されていなければならない(MUST)」とされています(§4.1.4.5)。未署名のアサーションを署名済みの Response で包んでも、この要件は満たせません。
  • サービスプロバイダーは、すべての署名を検証し、ベアラーの SubjectConfirmationData の Recipient が「アサーションコンシューマーサービスの URL と一致する」こと、その NotOnOrAfter が「許容される時刻のずれを考慮したうえで過ぎていない」こと、InResponseTo が自身の AuthnRequest の ID と等しいこと(要求なしで送られたレスポンスでは存在しないこと)を確認しなければなりません(MUST、§4.1.4.3)。
  • また、アサーションが有効でありうる間は、「使用済みの ID 値の集合を保持することで、ベアラーアサーションがリプレイされないことを保証しなければならない(MUST)」とされています(§4.1.4.5)。
  • AudienceRestriction 条件は、「SAML のリライングパーティが、指定されたオーディエンスの 1 つ以上に属する場合に限り Valid と評価される」とされ(SAML Core §2.5.1.4)、Conditions の NotBefore と NotOnOrAfter がアサーションの有効期間を区切ります(§2.5.1.2)。

署名されたデータだけを読むバリデーター

以下のバリデーターは、XML 署名の処理に signxml 5.2.0 を使います。signxml 自身のドキュメントはこのルールを「See what is signed」(署名された内容を見る)と呼び、API の形にしています。verify() は署名された要素を signed_xml として返し、その際、署名の対象外だったコメントは取り除かれています。また、location は ds:Signature があるべき位置を固定します。署名のチェックより後の処理は、すべて result.signed_xml から読み取ります。

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

from cryptography import x509
from signxml import SignatureConfiguration, XMLVerifier

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


class SamlError(Exception):
    pass


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


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

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

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

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

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

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

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

比較のために、バグの典型的な形を示します。同じ署名チェックを呼び出しますが、その結果を捨て、文書内で最初に見つかった Assertion を読み取ります。

naive.py(アンチパターン)
# Anti-pattern, for comparison only: the signature is checked, but the values come from the
# document as received instead of from the element the signature covers.
from lxml import etree
from signxml import SignatureConfiguration, XMLVerifier

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


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

改ざんしたレスポンスでのテスト

実験用の ID プロバイダーは、実行のたびに P-256 鍵と自己署名証明書を生成し、署名付きのレスポンスを発行します。サービスプロバイダーはその証明書を固定して使います。

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

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

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


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


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


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

ドライバーは両方の関数に 10 件のレスポンスを送ります。有効なものが 1 件、新しく発行してからリプレイするものが 1 件、改ざんされたものや受け入れてはならないものが 8 件です。

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

from lxml import etree

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

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


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


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


check("valid response", issue())

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

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

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

ケースごとに見ていきます。

  • 署名済みアサーションの前に置いた未署名のアサーション:それでも署名の検証は成功しました。参照は、元のアサーションが文書内のどこにあっても、それを ID で選択するからです。アンチパターンは文書内の最初の Assertion を読み取り、ID プロバイダーが一度も保証していないユーザーをログインさせました。バリデーターは signed_xml を使ったため、元のアサーションだけを見ていました。この種のバグは、一般に署名ラッピングと呼ばれます。
  • 署名済みの NameID 内のコメント:ID プロバイダーが署名したのは admin@example.com.evil.test で、これはその ID プロバイダーのユーザーが正当に持ち得るアカウントです。admin@example.com の後ろに <!----> を挿入しても正規形は変わりません。コメントなしの排他的正規化はコメントを削除するので、署名は有効なままです。lxml の .text は最初の子ノードより前のテキストしか返さないため、アンチパターンは admin@example.com を読み取りました。signxml はコメントを除いた署名済みの要素を返し、バリデーターはすべてのテキストノードを連結するので、本来のアカウントを読み取りました。これとまったく同じパターンが、Duo Labs の調査を受けて 2018年2月に CERT/CC VU#475445 として公開され、OneLogin の python-saml と ruby-saml、saml2-js、OmniAuth-SAML、Shibboleth の OpenSAML C++ などに影響しました。
  • 同じ ID プロバイダーが署名した別のサービスプロバイダー向けのアサーションは、オーディエンスのチェックで拒否されました。アンチパターンもたまたまオーディエンスを確認していたため、両方の実装がこれを検出しました。
  • 有効期限切れとリプレイを検出したのは、バリデーターだけでした。どちらも署名のルールではなく SAML のルールです。傍受されたアサーションの署名は、いつまでも有効なままだからです。
  • 署名後に編集したレスポンス、再署名したレスポンス、署名のないレスポンスは、どちらの実装でも署名の段階で拒否されました。原因はそれぞれ、ダイジェストの不一致、固定した鍵ではない鍵(その証明書が KeyInfo に埋め込まれ、同じサブジェクト名を持っていたにもかかわらず)、location が求める位置に署名がないことです。

実際の運用を左右する詳細

レスポンスの署名か、アサーションの署名か:ID プロバイダーは、アサーション、レスポンス、またはその両方に署名します。Response だけが署名されている場合、アサーションは子孫要素として署名の対象に含まれ、ルールは「署名されたレスポンス要素の子であるアサーションを使う」となります。それでも POST プロファイルは署名済みのアサーションを求めているので、アサーションに署名するよう ID プロバイダーを設定してください。「文書内のどこかにある署名」を受け入れてはいけません。

暗号化されたアサーション:EncryptedAssertion は、まず自分の鍵で復号しなければなりません。そのうえで、復号した Assertion に ID プロバイダーによる有効な署名が付いていなければなりません。暗号化が証明するのは、送信者がこちらの公開鍵を知っていたことだけです。

信頼の根拠はメタデータ:ID プロバイダーの署名用証明書は、そのメタデータから取り込んで自分の設定に置き、ID プロバイダーが証明書をローテーションしたら更新します。切り替えの期間中に新旧の証明書が重なるのは普通のことです。メッセージ内の証明書は何も証明しません。誰でも任意のサブジェクト名で証明書を作成できます。「別の鍵」のケースで行ったのが、まさにこれです。

アルゴリズム:2005年の SAML Core の本文は SHA-1 を使う RSA を挙げていますが、signxml 5.2.0 は SHA-1 の署名方式とダイジェスト方式をデフォルトで拒否します。自分の実装でも拒否すべきです。受け入れる方式は、ID プロバイダーが使うもの(一般的には RSA-SHA256 または ECDSA-SHA256)に限定してください。

XML のパース:何よりも先に、外部エンティティと DTD を無効にした状態でパースしてください。signxml のパーサーは resolve_entities=False を設定しています。同じ ID を持つ要素が複数ある文書は拒否してください。

時刻:わずかな時刻のずれ(数秒から、長くても数分)を許容し、使用済みのアサーション ID は、その NotOnOrAfter にそのずれを加えた時刻まで保存します。

正当なレスポンスが検証に失敗する場合

逆の問題、つまり ID プロバイダーからの本物のレスポンスが検証を通らない場合、よくある原因は限られています。

  • ID プロバイダーが証明書をローテーションしたのに、設定にはまだ古い証明書が残っています。メタデータから更新してください。通常、新しい証明書は使用が始まる前からメタデータに掲載されています。
  • どこかで XML が整形し直された:排他的正規化が正規化するのはマークアップであり、テキストではありません。要素の間に追加されたインデントや改行は、署名された要素内のテキストノードを変えてしまい、ダイジェストが一致しなくなります。フォームフィールドからデコードしたバイト列をそのまま検証してください。検証の前に見やすく整形したり、別のライブラリで再シリアライズしたり、「クリーンアップ」したりしてはいけません。
  • デコード方法の誤り:HTTP-POST バインディングでは、SAMLResponse フォームフィールドは XML を base64 にしたもので、改行で折り返されている場合があります(SAML Bindings §3.5.4)。HTTP-Redirect では、メッセージは DEFLATE で圧縮されて XML 署名が取り除かれ、クエリ文字列に対する別の署名が SigAlg パラメーターと Signature パラメーターで送られます(§3.4.4.1)。この署名は、XML ではなく URL エンコードされたパラメーターに対して検証します。署名済みのアサーションを含むレスポンスでは、Redirect バインディングをそもそも使うべきではありません。
  • バリデーターが想定していない位置に署名がある:ID プロバイダーが Response に署名しているのに、設定では Assertion 内の署名を必須としている場合(またはその逆の場合)、検証は失敗します。どちらを必須とするかを決め、それに合わせて ID プロバイダーを設定してください。位置の指定を「どこでもよい」に緩めてはいけません。
  • 時計:ログイン直後に「まだ有効ではありません」となる場合は、サービスプロバイダーの時計が遅れています。許容するずれを広げるのは、時計を直してからにしてください。

ライブラリ

汎用の XML 署名ライブラリに自前のつなぎのコードを組み合わせるのではなく、SAML ライブラリを使ってください。ライブラリは最新の状態に保ち、署名された内容を返す関数をそのドキュメントで確認してください。2026年10月20日時点の最新バージョンは次のとおりです。

言語SAML ライブラリXML 署名レイヤー
Pythonpython3-saml 1.16.0、pysaml2 7.5.5xmlsec 1.3.17、signxml 5.2.0
Node.js@node-saml/node-saml 5.1.0、samlify 2.15.0xml-crypto 6.4.0
Gocrewjam/saml v0.5.1russellhaering/goxmldsig v1.6.1

xml-crypto の README も、signxml と同じルールを明記しています。checkSignature() が成功したら getSignedReferences() から得た正規化済みの XML を使い、元の文書からノードを取り出してはいけません。その用途での getReferences() と validateElementAgainstReferences() は非推奨です。検証した後に入力文書をクエリするコードを保守している場合、変更すべきなのはその行です。

チェックリスト

  1. 安全にパースします。DTD、外部エンティティ、重複する ID を許可しません。
  2. ID プロバイダーのメタデータから取得して固定した証明書で、署名を検証します。信頼の判断に KeyInfo は使いません。
  3. 使用する要素を指す Reference がちょうど 1 つであること、変換はエンベロープ署名変換と排他的正規化だけであること、アルゴリズムは許可したものだけであることを必須とします。
  4. 署名された要素だけを使って確認します。Issuer が ID プロバイダーのエンティティ ID と等しいこと、Conditions の有効期間内であり、AudienceRestriction に自分のエンティティ ID が含まれること、ベアラーの SubjectConfirmationData があり、Recipient が自分の ACS URL、NotOnOrAfter が未来の時刻、InResponseTo が自分のリクエスト ID であること(省略してよいのは、受け入れると決めた要求なしのレスポンスの場合だけです)。
  5. アサーション ID を記録し、まだ有効な間に同じ ID が再び届いたら拒否します。
  6. NameID と属性は、すべてのテキストノードを連結して署名された要素から読み取り、ユーザーは Issuer と NameID の組み合わせで対応付けます。
  7. 期限切れのアサーションとリプレイしたアサーション、ID プロバイダーのものではない証明書でテストします。これらはすべて拒否されなければなりません。ラッピングしたレスポンスとコメントを挿入した NameID でもテストします。どちらの場合も、ログインに使うのは ID プロバイダーが署名した身元だけで、しかも一部を欠くことのない完全な形でなければなりません。

出典