Skip to main content

saml_rs/crypto/
verify.rs

1//! XML-DSig verification and anti-wrapping checks, delegating cryptography to
2//! the selected `bergshamra` provider.
3//!
4//! Security model:
5//! - `trusted_keys_only`: the signature is verified against the certificate(s)
6//!   declared in IdP metadata, never an attacker-supplied inline cert.
7//! - `strict_verification`: bergshamra enforces that each signed reference
8//!   targets the document element, an ancestor, or a sibling of the Signature.
9//! - Explicit XSW guard: reject any `Assertion`/`Signature` nested under
10//!   `SubjectConfirmationData`.
11//! - Only content covered by a verified reference is returned for extraction.
12
13use super::keys::load_certificate;
14use crate::constants::transform_algorithm;
15use crate::error::{ReferenceResolutionReason, SamlError, SignatureVerificationReason};
16use crate::util::normalize_cert_string;
17use crate::xml::dom::{self, Node, XmlLimits};
18use bergshamra::core::ns as bergshamra_ns;
19use bergshamra::dsig::verify::verify_all_document_with_source;
20use bergshamra::xml::{uppsala, Document as BergshamraDocument, NodeId as BergshamraNodeId};
21use bergshamra::{verify, DsigContext, KeysManager, VerifiedReference, VerifyResult};
22use std::collections::HashSet;
23
24fn children_named<'a>(node: &'a Node, name: &str) -> Vec<&'a Node> {
25    node.children
26        .iter()
27        .filter(|c| c.local_name == name)
28        .collect()
29}
30
31fn has_child(node: &Node, name: &str) -> bool {
32    node.children.iter().any(|c| c.local_name == name)
33}
34
35fn saml_signature_candidates(root: &Node) -> Vec<&Node> {
36    let mut signatures = children_named(root, "Signature");
37    for assertion in children_named(root, "Assertion") {
38        signatures.extend(children_named(assertion, "Signature"));
39    }
40    signatures
41}
42
43fn has_descendant(node: &Node, names: &[&str]) -> bool {
44    node.children
45        .iter()
46        .any(|c| names.contains(&c.local_name.as_str()) || has_descendant(c, names))
47}
48
49/// XSW guard: `Response/Assertion/Subject/SubjectConfirmation/SubjectConfirmationData//(Assertion|Signature)`.
50fn wrapping_detected(root: &Node) -> bool {
51    for assertion in children_named(root, "Assertion") {
52        for subject in children_named(assertion, "Subject") {
53            for sc in children_named(subject, "SubjectConfirmation") {
54                for scd in children_named(sc, "SubjectConfirmationData") {
55                    if has_descendant(scd, &["Assertion", "Signature"]) {
56                        return true;
57                    }
58                }
59            }
60        }
61    }
62    false
63}
64
65fn saml_id_attr(name: &str) -> bool {
66    matches!(name, "ID" | "AssertionID")
67}
68
69fn duplicate_saml_id(node: &Node, seen: &mut HashSet<String>) -> Option<String> {
70    for (name, value) in &node.attrs {
71        if saml_id_attr(name) && !value.is_empty() && !seen.insert(value.clone()) {
72            return Some(value.clone());
73        }
74    }
75    node.children
76        .iter()
77        .find_map(|child| duplicate_saml_id(child, seen))
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81enum VerifiedTarget {
82    WholeDocument,
83    Id(String),
84}
85
86fn reference_resolution(reason: ReferenceResolutionReason) -> SamlError {
87    SamlError::ReferenceResolution { reason }
88}
89
90fn verified_target_from_uri(uri: &str) -> Result<VerifiedTarget, SamlError> {
91    if uri.is_empty() || uri == "#xpointer(/)" {
92        return Ok(VerifiedTarget::WholeDocument);
93    }
94
95    let fragment = uri
96        .strip_prefix('#')
97        .ok_or_else(|| reference_resolution(ReferenceResolutionReason::ExternalReference))?;
98    if fragment.is_empty() {
99        return Err(reference_resolution(
100            ReferenceResolutionReason::UnsupportedReferenceUri,
101        ));
102    }
103    if let Some(id) = fragment
104        .strip_prefix("xpointer(id('")
105        .and_then(|rest| rest.strip_suffix("'))"))
106    {
107        if id.is_empty() {
108            return Err(reference_resolution(
109                ReferenceResolutionReason::UnsupportedReferenceUri,
110            ));
111        }
112        return Ok(VerifiedTarget::Id(id.to_string()));
113    }
114    if fragment.starts_with("xpointer(") {
115        return Err(reference_resolution(
116            ReferenceResolutionReason::UnsupportedReferenceUri,
117        ));
118    }
119    Ok(VerifiedTarget::Id(fragment.to_string()))
120}
121
122fn verified_targets(references: &[VerifiedReference]) -> Result<Vec<VerifiedTarget>, SamlError> {
123    if references.is_empty() {
124        return Err(reference_resolution(
125            ReferenceResolutionReason::MissingSignatureReference,
126        ));
127    }
128
129    let mut targets = Vec::with_capacity(references.len());
130    for reference in references {
131        if is_external_reference(&reference.uri) {
132            return Err(reference_resolution(
133                ReferenceResolutionReason::ExternalReference,
134            ));
135        }
136        if !reference.digest_verified {
137            return Err(SamlError::SignatureVerification {
138                reason: SignatureVerificationReason::ReferenceDigest,
139            });
140        }
141        let target = verified_target_from_uri(&reference.uri)?;
142        if matches!(target, VerifiedTarget::Id(_)) && reference.resolved_node.is_none() {
143            return Err(reference_resolution(
144                ReferenceResolutionReason::UnresolvedReference,
145            ));
146        }
147        targets.push(target);
148    }
149    Ok(targets)
150}
151
152fn node_saml_id(node: &Node) -> Option<&str> {
153    node.attr("ID").or_else(|| node.attr("AssertionID"))
154}
155
156fn target_matches_node(targets: &[VerifiedTarget], node: &Node) -> bool {
157    targets.iter().any(|target| match target {
158        VerifiedTarget::WholeDocument => true,
159        VerifiedTarget::Id(id) => node_saml_id(node).is_some_and(|node_id| node_id == id),
160    })
161}
162
163fn id_target_matches_node(targets: &[VerifiedTarget], node: &Node) -> bool {
164    targets.iter().any(|target| match target {
165        VerifiedTarget::WholeDocument => false,
166        VerifiedTarget::Id(id) => node_saml_id(node).is_some_and(|node_id| node_id == id),
167    })
168}
169
170fn response_is_covered(targets: &[VerifiedTarget], root: &Node) -> bool {
171    target_matches_node(targets, root)
172}
173
174fn verified_content_not_covered() -> SamlError {
175    SamlError::SignedReferenceMismatch
176}
177
178const XML_C14N_10: &str = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315";
179const XML_C14N_10_WITH_COMMENTS: &str =
180    "http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments";
181const XML_C14N_11: &str = "http://www.w3.org/2006/12/xml-c14n11";
182const XML_C14N_11_WITH_COMMENTS: &str = "http://www.w3.org/2006/12/xml-c14n11#WithComments";
183
184fn metadata_signature_transform_allowed(algorithm: &str) -> bool {
185    matches!(
186        algorithm,
187        transform_algorithm::ENVELOPED_SIGNATURE
188            | transform_algorithm::EXC_C14N
189            | transform_algorithm::EXC_C14N_WITH_COMMENTS
190            | XML_C14N_10
191            | XML_C14N_10_WITH_COMMENTS
192            | XML_C14N_11
193            | XML_C14N_11_WITH_COMMENTS
194    )
195}
196
197fn ensure_metadata_reference_transforms_preserve_descriptor(
198    reference: &Node,
199) -> Result<(), SamlError> {
200    for transforms in children_named(reference, "Transforms") {
201        for transform in children_named(transforms, "Transform") {
202            if transform
203                .attr("Algorithm")
204                .is_some_and(metadata_signature_transform_allowed)
205            {
206                continue;
207            }
208            return Err(verified_content_not_covered());
209        }
210    }
211    Ok(())
212}
213
214fn ensure_metadata_signature_transforms_preserve_descriptor(root: &Node) -> Result<(), SamlError> {
215    if !matches!(
216        root.local_name.as_str(),
217        "EntityDescriptor" | "EntitiesDescriptor"
218    ) {
219        return Ok(());
220    }
221
222    for signature in children_named(root, "Signature") {
223        for signed_info in children_named(signature, "SignedInfo") {
224            for reference in children_named(signed_info, "Reference") {
225                ensure_metadata_reference_transforms_preserve_descriptor(reference)?;
226            }
227        }
228    }
229    Ok(())
230}
231
232fn verified_root_content(
233    root: &Node,
234    xml: &str,
235    targets: &[VerifiedTarget],
236) -> Result<String, SamlError> {
237    if target_matches_node(targets, root) {
238        return Ok(xml[root.start..root.end].to_string());
239    }
240    Err(verified_content_not_covered())
241}
242
243/// The response signature covers every assertion, or every assertion is signed.
244/// An unsigned sibling is rejected.
245fn assertion_has_bearer_confirmation(assertion: &Node) -> bool {
246    const BEARER: &str = "urn:oasis:names:tc:SAML:2.0:cm:bearer";
247    assertion
248        .children
249        .iter()
250        .filter(|child| child.local_name == "Subject")
251        .flat_map(|subject| subject.children.iter())
252        .filter(|child| child.local_name == "SubjectConfirmation")
253        .any(|confirmation| confirmation.attr("Method") == Some(BEARER))
254}
255
256fn response_assertions_covered(root: &Node, targets: &[VerifiedTarget]) -> bool {
257    let assertions = children_named(root, "Assertion");
258    !assertions.is_empty()
259        && (response_is_covered(targets, root)
260            || assertions
261                .iter()
262                .all(|assertion| id_target_matches_node(targets, assertion)))
263}
264
265/// Return the source of the content covered by a verified reference: the first
266/// bearer `<Assertion>`, a consumed root element, or the whole `<Response>`
267/// when assertions are encrypted.
268fn verified_content(
269    root: &Node,
270    xml: &str,
271    targets: &[VerifiedTarget],
272) -> Result<Option<String>, SamlError> {
273    if root.local_name == "Assertion" {
274        return verified_root_content(root, xml, targets).map(Some);
275    }
276    if root.local_name.contains("Response") {
277        let assertions = children_named(root, "Assertion");
278        if !assertions.is_empty() {
279            if !response_assertions_covered(root, targets) {
280                if assertions.len() > 1 {
281                    return Err(SamlError::PotentialWrappingAttack);
282                }
283                return Err(verified_content_not_covered());
284            }
285            let chosen = assertions
286                .iter()
287                .find(|assertion| assertion_has_bearer_confirmation(assertion))
288                .copied()
289                .unwrap_or(assertions[0]);
290            return Ok(Some(xml[chosen.start..chosen.end].to_string()));
291        }
292        if has_child(root, "EncryptedAssertion") {
293            if response_is_covered(targets, root) {
294                return Ok(Some(xml[root.start..root.end].to_string()));
295            }
296            return Err(verified_content_not_covered());
297        }
298    }
299    if matches!(
300        root.local_name.as_str(),
301        "EntityDescriptor" | "EntitiesDescriptor"
302    ) {
303        if target_matches_node(targets, root) {
304            return Ok(Some(xml[root.start..root.end].to_string()));
305        }
306        return Err(verified_content_not_covered());
307    }
308    if matches!(
309        root.local_name.as_str(),
310        "AuthnRequest" | "LogoutRequest" | "LogoutResponse"
311    ) {
312        return verified_root_content(root, xml, targets).map(Some);
313    }
314    Ok(None)
315}
316
317/// Whether `targets` satisfy the same coverage [`verified_content`] accepts.
318///
319/// This is the strict-profile gate for signatures that authenticate a message
320/// without becoming SSO evidence, including `LogoutRequest` and
321/// `LogoutResponse`. Keep the branches aligned with [`verified_content`].
322fn verified_targets_cover_accepted_content(root: &Node, targets: &[VerifiedTarget]) -> bool {
323    if root.local_name == "Assertion" {
324        return target_matches_node(targets, root);
325    }
326    if root.local_name.contains("Response") {
327        let assertions = children_named(root, "Assertion");
328        if !assertions.is_empty() {
329            return response_assertions_covered(root, targets);
330        }
331        if has_child(root, "EncryptedAssertion") {
332            return response_is_covered(targets, root);
333        }
334    }
335    if matches!(
336        root.local_name.as_str(),
337        "EntityDescriptor" | "EntitiesDescriptor"
338    ) {
339        return target_matches_node(targets, root);
340    }
341    if matches!(
342        root.local_name.as_str(),
343        "AuthnRequest" | "LogoutRequest" | "LogoutResponse"
344    ) {
345        return target_matches_node(targets, root);
346    }
347    false
348}
349
350fn assertion_is_directly_covered(root: &Node, targets: &[VerifiedTarget]) -> bool {
351    if root.local_name == "Assertion" {
352        return target_matches_node(targets, root);
353    }
354    if root.local_name.contains("Response") {
355        let assertions = children_named(root, "Assertion");
356        return !assertions.is_empty()
357            && assertions
358                .iter()
359                .all(|assertion| id_target_matches_node(targets, assertion));
360    }
361    false
362}
363
364fn references_cover_direct_assertion(
365    document: &BergshamraDocument<'_>,
366    references: &[VerifiedReference],
367) -> bool {
368    let Some(root) = document.document_element() else {
369        return false;
370    };
371    let Some(root_element) = document.element(root) else {
372        return false;
373    };
374    if root_element.matches_name_ns(crate::constants::namespace::ASSERTION, "Assertion") {
375        return reference_covers_node(references, root);
376    }
377    root_element.matches_name_ns(crate::constants::namespace::PROTOCOL, "Response")
378        && document.children_iter(root).any(|child| {
379            document.element(child).is_some_and(|element| {
380                element.matches_name_ns(crate::constants::namespace::ASSERTION, "Assertion")
381                    && reference_covers_node(references, child)
382            })
383        })
384}
385
386fn bergshamra_child_element(
387    document: &BergshamraDocument<'_>,
388    parent: BergshamraNodeId,
389    namespace: &str,
390    local_name: &str,
391) -> Option<BergshamraNodeId> {
392    document.children_iter(parent).find(|child| {
393        document
394            .element(*child)
395            .is_some_and(|element| element.matches_name_ns(namespace, local_name))
396    })
397}
398
399fn verified_signature_algorithm(
400    document: &BergshamraDocument<'_>,
401    signature_node: BergshamraNodeId,
402) -> Result<String, SamlError> {
403    let signed_info = bergshamra_child_element(
404        document,
405        signature_node,
406        bergshamra_ns::DSIG,
407        bergshamra_ns::node::SIGNED_INFO,
408    )
409    .ok_or_else(|| SamlError::Crypto("verified signature is missing SignedInfo".into()))?;
410    let signature_method = bergshamra_child_element(
411        document,
412        signed_info,
413        bergshamra_ns::DSIG,
414        bergshamra_ns::node::SIGNATURE_METHOD,
415    )
416    .ok_or_else(|| SamlError::Crypto("verified signature is missing SignatureMethod".into()))?;
417    document
418        .element(signature_method)
419        .and_then(|element| element.get_attribute(bergshamra_ns::attr::ALGORITHM))
420        .filter(|algorithm| !algorithm.is_empty())
421        .map(str::to_string)
422        .ok_or_else(|| {
423            SamlError::Crypto("verified signature is missing SignatureMethod Algorithm".into())
424        })
425}
426
427fn reference_covers_node(references: &[VerifiedReference], node: BergshamraNodeId) -> bool {
428    references
429        .iter()
430        .any(|reference| reference.resolved_node == Some(node))
431}
432
433#[derive(Debug, Clone, PartialEq, Eq)]
434pub(crate) struct VerifiedEmbeddedSignature {
435    algorithm_uri: String,
436    assertion_directly_covered: bool,
437    response_covered: bool,
438}
439
440impl VerifiedEmbeddedSignature {
441    pub(crate) fn algorithm_uri(&self) -> &str {
442        &self.algorithm_uri
443    }
444
445    pub(crate) fn assertion_directly_covered(&self) -> bool {
446        self.assertion_directly_covered
447    }
448
449    pub(crate) fn response_covered(&self) -> bool {
450        self.response_covered
451    }
452}
453
454fn verified_embedded_signature(
455    document: &BergshamraDocument<'_>,
456    signature_node: BergshamraNodeId,
457    references: &[VerifiedReference],
458    targets: &[VerifiedTarget],
459) -> Result<Option<VerifiedEmbeddedSignature>, SamlError> {
460    let Some(root) = document.document_element() else {
461        return Ok(None);
462    };
463    let root_element = document
464        .element(root)
465        .ok_or_else(|| SamlError::Crypto("verified document root is not an element".into()))?;
466    let whole_document_covered = targets
467        .iter()
468        .any(|target| matches!(target, VerifiedTarget::WholeDocument));
469
470    let response_covered = root_element
471        .matches_name_ns(crate::constants::namespace::PROTOCOL, "Response")
472        && (whole_document_covered || reference_covers_node(references, root));
473    let assertion_node =
474        if root_element.matches_name_ns(crate::constants::namespace::ASSERTION, "Assertion") {
475            Some(root)
476        } else if root_element.matches_name_ns(crate::constants::namespace::PROTOCOL, "Response") {
477            bergshamra_child_element(
478                document,
479                root,
480                crate::constants::namespace::ASSERTION,
481                "Assertion",
482            )
483        } else {
484            None
485        };
486    let assertion_directly_covered = assertion_node.is_some_and(|assertion| {
487        reference_covers_node(references, assertion)
488            || (assertion == root && whole_document_covered)
489    });
490
491    if !response_covered && !assertion_directly_covered {
492        return Ok(None);
493    }
494    Ok(Some(VerifiedEmbeddedSignature {
495        algorithm_uri: verified_signature_algorithm(document, signature_node)?,
496        assertion_directly_covered,
497        response_covered,
498    }))
499}
500
501/// True for a signed `<Reference>` URI that is not same-document (i.e. not a
502/// `#id` fragment or the whole document). Such references can pull external or
503/// local-file content into the verified set and are rejected for SAML.
504fn is_external_reference(uri: &str) -> bool {
505    !uri.is_empty() && !uri.starts_with('#')
506}
507
508fn has_saml_xml_signature(root: &Node) -> bool {
509    !saml_signature_candidates(root).is_empty()
510}
511
512fn preflight_saml_reference_uris(signatures: &[&Node]) -> Result<(), SamlError> {
513    for signature in signatures {
514        for signed_info in children_named(signature, "SignedInfo") {
515            for reference in children_named(signed_info, "Reference") {
516                verified_target_from_uri(reference.attr("URI").unwrap_or_default())?;
517            }
518        }
519    }
520    Ok(())
521}
522
523fn dsig_children(
524    document: &BergshamraDocument<'_>,
525    parent: BergshamraNodeId,
526    local_name: &str,
527) -> Vec<BergshamraNodeId> {
528    document
529        .children_iter(parent)
530        .filter(|child| {
531            document
532                .element(*child)
533                .is_some_and(|element| element.matches_name_ns(bergshamra_ns::DSIG, local_name))
534        })
535        .collect()
536}
537
538fn exactly_one_dsig_child(
539    document: &BergshamraDocument<'_>,
540    parent: BergshamraNodeId,
541    local_name: &str,
542) -> Option<BergshamraNodeId> {
543    let mut children = dsig_children(document, parent, local_name).into_iter();
544    let only = children.next()?;
545    children.next().is_none().then_some(only)
546}
547
548fn dsig_algorithm<'a>(
549    document: &'a BergshamraDocument<'_>,
550    node: BergshamraNodeId,
551) -> Option<&'a str> {
552    document
553        .element(node)
554        .and_then(|element| element.get_attribute(bergshamra_ns::attr::ALGORITHM))
555}
556
557/// Check one verifier-accepted signature against the strict RSA-SHA2 profile.
558///
559/// Callers apply this only to a signature that authenticated the message being
560/// accepted. An invalid, untrusted, or unrelated signature therefore cannot
561/// reject the message.
562fn enforce_strict_profile_on_verified_signature(
563    document: &BergshamraDocument<'_>,
564    signature_node: BergshamraNodeId,
565) -> Result<(), SamlError> {
566    let signed_info =
567        exactly_one_dsig_child(document, signature_node, bergshamra_ns::node::SIGNED_INFO)
568            .ok_or(SamlError::SignedReferenceMismatch)?;
569    let canonicalization = exactly_one_dsig_child(
570        document,
571        signed_info,
572        bergshamra_ns::node::CANONICALIZATION_METHOD,
573    )
574    .ok_or(SamlError::AlgorithmUnsupported)?;
575    if dsig_algorithm(document, canonicalization) != Some(transform_algorithm::EXC_C14N) {
576        return Err(SamlError::AlgorithmUnsupported);
577    }
578    let signature_method =
579        exactly_one_dsig_child(document, signed_info, bergshamra_ns::node::SIGNATURE_METHOD)
580            .ok_or(SamlError::AlgorithmUnsupported)?;
581    if !matches!(
582        dsig_algorithm(document, signature_method),
583        Some(
584            crate::constants::signature_algorithm::RSA_SHA256
585                | crate::constants::signature_algorithm::RSA_SHA384
586                | crate::constants::signature_algorithm::RSA_SHA512
587        )
588    ) {
589        return Err(SamlError::AlgorithmUnsupported);
590    }
591    let reference = exactly_one_dsig_child(document, signed_info, bergshamra_ns::node::REFERENCE)
592        .ok_or(SamlError::SignedReferenceMismatch)?;
593    let Some(uri) = document
594        .element(reference)
595        .and_then(|element| element.get_attribute(bergshamra_ns::attr::URI))
596    else {
597        return Err(reference_resolution(
598            ReferenceResolutionReason::UnsupportedReferenceUri,
599        ));
600    };
601    if !uri.starts_with('#') || uri.len() == 1 || uri.starts_with("#xpointer(") {
602        return Err(reference_resolution(
603            ReferenceResolutionReason::UnsupportedReferenceUri,
604        ));
605    }
606    verified_target_from_uri(uri)?;
607
608    let digest_method =
609        exactly_one_dsig_child(document, reference, bergshamra_ns::node::DIGEST_METHOD)
610            .ok_or(SamlError::AlgorithmUnsupported)?;
611    if !matches!(
612        dsig_algorithm(document, digest_method),
613        Some(
614            crate::constants::digest_algorithm::SHA256
615                | crate::constants::digest_algorithm::SHA384
616                | crate::constants::digest_algorithm::SHA512
617        )
618    ) {
619        return Err(SamlError::AlgorithmUnsupported);
620    }
621    for transforms in dsig_children(document, reference, bergshamra_ns::node::TRANSFORMS) {
622        // Bergshamra executes every direct child whose local name is
623        // `Transform`, including elements outside the XML-DSig namespace.
624        // Reject any element it would execute, and any other element child,
625        // unless it is an allowed XML-DSig transform.
626        for child in document.children_iter(transforms) {
627            let Some(element) = document.element(child) else {
628                continue;
629            };
630            if !element.matches_name_ns(bergshamra_ns::DSIG, bergshamra_ns::node::TRANSFORM)
631                || !matches!(
632                    element.get_attribute(bergshamra_ns::attr::ALGORITHM),
633                    Some(transform_algorithm::ENVELOPED_SIGNATURE | transform_algorithm::EXC_C14N)
634                )
635            {
636                return Err(SamlError::AlgorithmUnsupported);
637            }
638        }
639    }
640    Ok(())
641}
642
643pub(crate) fn has_xml_signature_with_limits(
644    xml: &str,
645    limits: XmlLimits,
646) -> Result<bool, SamlError> {
647    let doc = dom::parse_with_limits(xml, limits)?;
648    Ok(has_saml_xml_signature(&doc.root))
649}
650
651/// First `<X509Certificate>` text found inside a candidate `<Signature>` (the
652/// cert the sender embedded in the message), if any.
653fn inline_signature_cert(signatures: &[&Node]) -> Option<String> {
654    fn descendant_cert(node: &Node) -> Option<String> {
655        if node.local_name == "X509Certificate" && !node.text.is_empty() {
656            return Some(node.text.clone());
657        }
658        node.children.iter().find_map(descendant_cert)
659    }
660
661    signatures
662        .iter()
663        .find_map(|signature| descendant_cert(signature))
664}
665
666/// Verify the XML-DSig signature(s) of `xml` against `metadata_certs`.
667///
668/// Returns `(verified, signed_content)`:
669/// - `(false, None)` when there is no signature or it does not verify;
670/// - `(true, Some(xml))` with the signed assertion/response on success;
671/// - `Err(PotentialWrappingAttack)` on a detected XSW attempt.
672///
673/// # Errors
674///
675/// Returns [`SamlError`] when XML parsing, trust checks, reference resolution,
676/// cryptographic verification, or signed-content coverage checks fail.
677pub fn verify_signature(
678    xml: &str,
679    metadata_certs: &[String],
680) -> Result<(bool, Option<String>), SamlError> {
681    verify_signature_with_limits(xml, metadata_certs, XmlLimits::default())
682}
683
684/// Verify the XML-DSig signature(s) of `xml` with explicit XML parser limits.
685///
686/// # Errors
687///
688/// Returns [`SamlError`] when XML parsing, trust checks, reference resolution,
689/// cryptographic verification, or signed-content coverage checks fail.
690pub fn verify_signature_with_limits(
691    xml: &str,
692    metadata_certs: &[String],
693    limits: XmlLimits,
694) -> Result<(bool, Option<String>), SamlError> {
695    let doc = dom::parse_with_limits(xml, limits)?;
696    let root = &doc.root;
697
698    if root.local_name.contains("Response") && wrapping_detected(root) {
699        return Err(SamlError::PotentialWrappingAttack);
700    }
701
702    let mut seen_ids = HashSet::new();
703    if duplicate_saml_id(root, &mut seen_ids).is_some() {
704        return Err(SamlError::PotentialWrappingAttack);
705    }
706
707    // Candidate signatures: message-level (root > Signature) or assertion-level.
708    let signature_candidates = saml_signature_candidates(root);
709    if signature_candidates.is_empty() {
710        return Ok((false, None));
711    }
712    preflight_saml_reference_uris(&signature_candidates)?;
713
714    // If the message embeds a certificate, it must be one declared in metadata
715    // (rolling-cert safety). Verification itself still uses only the metadata
716    // certs.
717    if let Some(inline) = inline_signature_cert(&signature_candidates) {
718        let inline = normalize_cert_string(&inline);
719        if !metadata_certs.is_empty()
720            && !metadata_certs
721                .iter()
722                .any(|c| normalize_cert_string(c) == inline)
723        {
724            return Err(SamlError::CertificateMismatch);
725        }
726    }
727
728    super::provider::ensure_crypto_provider_initialized()?;
729
730    // Try each metadata certificate individually (rolling-cert support): the
731    // signature verifies if any one of the declared keys matches.
732    let mut have_key = false;
733    let mut key_load_error = None;
734    let mut tried_invalid = false;
735    let mut last_err: Option<SamlError> = None;
736    for cert in metadata_certs {
737        let key = match load_certificate(cert) {
738            Ok(key) => key,
739            Err(error) => {
740                key_load_error.get_or_insert(error);
741                continue;
742            }
743        };
744        have_key = true;
745        let mut manager = KeysManager::new();
746        manager.add_key(key);
747        // Metadata certificates are the only verification keys. Inline KeyInfo
748        // is not imported. `with_insecure(true)` skips X.509 chain and time
749        // checks only; signature, digest, reference, duplicate-ID, and XSW
750        // checks stay on. Do not replace this with `DsigContext::new_permissive()`.
751        let ctx = DsigContext::new(manager)
752            .with_trusted_keys_only(true)
753            .with_strict_verification(true)
754            .with_require_reference_digests(true)
755            .with_hmac_min_out_len(160)
756            .with_insecure(true);
757        match verify(&ctx, xml) {
758            Ok(VerifyResult::Valid {
759                signature_node: _,
760                references,
761                ..
762            }) => {
763                let targets = verified_targets(&references)?;
764                return Ok((true, verified_content(root, xml, &targets)?));
765            }
766            Ok(VerifyResult::Invalid { .. }) => tried_invalid = true,
767            Err(e) => last_err = Some(SamlError::Crypto(e.to_string())),
768        }
769    }
770    if !have_key {
771        return Err(key_load_error.unwrap_or(SamlError::NoTrustedCertificate));
772    }
773    // A leftover unloadable cert must not poison a rolling-cert verdict.
774    // A clean "invalid" (key mismatch / tampered) is a non-error false; only
775    // surface a structural error when no loaded certificate produced a verdict.
776    match last_err {
777        Some(err) if !tried_invalid => Err(err),
778        _ => Ok((false, None)),
779    }
780}
781
782#[derive(Debug, Clone, PartialEq, Eq)]
783pub(crate) struct SignatureVerification {
784    verified: bool,
785    signed_content: Option<String>,
786    assertion_directly_covered: bool,
787    response_covered: bool,
788    verified_embedded_signatures: Vec<VerifiedEmbeddedSignature>,
789}
790
791impl SignatureVerification {
792    pub(crate) fn verified(&self) -> bool {
793        self.verified
794    }
795
796    pub(crate) fn assertion_directly_covered(&self) -> bool {
797        self.assertion_directly_covered
798    }
799
800    pub(crate) fn response_covered(&self) -> bool {
801        self.response_covered
802    }
803
804    pub(crate) fn verified_embedded_signatures(&self) -> &[VerifiedEmbeddedSignature] {
805        &self.verified_embedded_signatures
806    }
807
808    pub(crate) fn into_signed_content(self) -> Option<String> {
809        self.signed_content
810    }
811}
812
813pub(crate) fn verify_signatures_detailed_with_profile(
814    xml: &str,
815    metadata_certs: &[String],
816    limits: XmlLimits,
817    strict_xml_signature_profile: bool,
818) -> Result<SignatureVerification, SamlError> {
819    let doc = dom::parse_with_limits(xml, limits)?;
820    let root = &doc.root;
821
822    if root.local_name.contains("Response") && wrapping_detected(root) {
823        return Err(SamlError::PotentialWrappingAttack);
824    }
825
826    let mut seen_ids = HashSet::new();
827    if duplicate_saml_id(root, &mut seen_ids).is_some() {
828        return Err(SamlError::PotentialWrappingAttack);
829    }
830
831    let signature_candidates = saml_signature_candidates(root);
832    if signature_candidates.is_empty() {
833        return Ok(SignatureVerification {
834            verified: false,
835            signed_content: None,
836            assertion_directly_covered: false,
837            response_covered: false,
838            verified_embedded_signatures: Vec::new(),
839        });
840    }
841    preflight_saml_reference_uris(&signature_candidates)?;
842
843    if let Some(inline) = inline_signature_cert(&signature_candidates) {
844        let inline = normalize_cert_string(&inline);
845        if !metadata_certs.is_empty()
846            && !metadata_certs
847                .iter()
848                .any(|c| normalize_cert_string(c) == inline)
849        {
850            return Err(SamlError::CertificateMismatch);
851        }
852    }
853
854    super::provider::ensure_crypto_provider_initialized()?;
855
856    let mut have_key = false;
857    let mut key_load_error = None;
858    let mut tried_invalid = false;
859    let mut last_err: Option<SamlError> = None;
860    let mut first_signature_verified = false;
861    let mut targets = Vec::new();
862    // Reuse this exact verifier DOM for every pinned certificate attempt and
863    // for evidence extraction from each successful signature node.
864    let document = uppsala::parse(xml).map_err(|error| SamlError::Crypto(error.to_string()))?;
865    let mut verified_signature_nodes = HashSet::new();
866    let mut verified_embedded_signatures = Vec::new();
867    for cert in metadata_certs {
868        let key = match load_certificate(cert) {
869            Ok(key) => key,
870            Err(error) => {
871                key_load_error.get_or_insert(error);
872                continue;
873            }
874        };
875        have_key = true;
876        let mut manager = KeysManager::new();
877        manager.add_key(key);
878        let ctx = DsigContext::new(manager)
879            .with_trusted_keys_only(true)
880            .with_strict_verification(true)
881            .with_require_reference_digests(true)
882            .with_hmac_min_out_len(160)
883            .with_insecure(true);
884        match verify_all_document_with_source(&ctx, &document, Some(xml)) {
885            Ok(results) => {
886                first_signature_verified |=
887                    matches!(results.first(), Some(VerifyResult::Valid { .. }));
888                for result in results {
889                    match result {
890                        VerifyResult::Valid {
891                            signature_node,
892                            references,
893                            ..
894                        } => {
895                            let signature_targets = verified_targets(&references)?;
896                            if verified_signature_nodes.insert(signature_node) {
897                                let signature = verified_embedded_signature(
898                                    &document,
899                                    signature_node,
900                                    &references,
901                                    &signature_targets,
902                                )?;
903                                // SSO evidence does not include LogoutRequest or
904                                // LogoutResponse coverage. A protocol Response can
905                                // also be evidence when `verified_content` returns
906                                // no XML. Enforce the profile for either case.
907                                if strict_xml_signature_profile
908                                    && (signature.is_some()
909                                        || references_cover_direct_assertion(
910                                            &document,
911                                            &references,
912                                        )
913                                        || verified_targets_cover_accepted_content(
914                                            root,
915                                            &signature_targets,
916                                        ))
917                                {
918                                    enforce_strict_profile_on_verified_signature(
919                                        &document,
920                                        signature_node,
921                                    )?;
922                                }
923                                if let Some(signature) = signature {
924                                    verified_embedded_signatures
925                                        .push((signature_node.index(), signature));
926                                }
927                            }
928                            targets.extend(signature_targets);
929                        }
930                        VerifyResult::Invalid { .. } => tried_invalid = true,
931                    }
932                }
933            }
934            Err(error) => last_err = Some(SamlError::Crypto(error.to_string())),
935        }
936    }
937    if !have_key {
938        return Err(key_load_error.unwrap_or(SamlError::NoTrustedCertificate));
939    }
940    if first_signature_verified && !targets.is_empty() {
941        verified_embedded_signatures.sort_by_key(|(index, _)| *index);
942        let assertion_directly_covered = assertion_is_directly_covered(root, &targets);
943        let response_covered =
944            root.local_name.contains("Response") && response_is_covered(&targets, root);
945        return Ok(SignatureVerification {
946            verified: true,
947            signed_content: verified_content(root, xml, &targets)?,
948            assertion_directly_covered,
949            response_covered,
950            verified_embedded_signatures: verified_embedded_signatures
951                .into_iter()
952                .map(|(_, signature)| signature)
953                .collect(),
954        });
955    }
956    match last_err {
957        Some(error) if !tried_invalid => Err(error),
958        _ => Ok(SignatureVerification {
959            verified: false,
960            signed_content: None,
961            assertion_directly_covered: false,
962            response_covered: false,
963            verified_embedded_signatures: Vec::new(),
964        }),
965    }
966}
967
968/// Detailed metadata signature verification result.
969#[derive(Debug, Clone, PartialEq, Eq)]
970pub struct MetadataSignatureVerification {
971    verified: bool,
972    signed_entity_descriptor_xml: Option<String>,
973}
974
975impl MetadataSignatureVerification {
976    pub(crate) fn from_signed_descriptor(signed_entity_descriptor_xml: String) -> Self {
977        Self {
978            verified: true,
979            signed_entity_descriptor_xml: Some(signed_entity_descriptor_xml),
980        }
981    }
982
983    pub(crate) fn unverified() -> Self {
984        Self {
985            verified: false,
986            signed_entity_descriptor_xml: None,
987        }
988    }
989
990    /// Whether a metadata signature verified against the pinned certificates.
991    pub fn verified(&self) -> bool {
992        self.verified
993    }
994
995    /// Signed `<EntityDescriptor>` or `<EntitiesDescriptor>` when verification succeeds.
996    pub fn signed_entity_descriptor_xml(&self) -> Option<&str> {
997        self.signed_entity_descriptor_xml.as_deref()
998    }
999
1000    pub(crate) fn into_signed_entity_descriptor_xml(self) -> Option<String> {
1001        self.signed_entity_descriptor_xml
1002    }
1003}
1004
1005/// Verify the enveloped XML-DSig signature on a metadata document against
1006/// trusted certificate(s); returns whether it is valid and covers the consumed
1007/// `<EntityDescriptor>` document.
1008///
1009/// # Errors
1010///
1011/// Returns [`SamlError`] when XML parsing, certificate loading, cryptographic
1012/// verification, or signed `<EntityDescriptor>` coverage checks fail.
1013pub fn verify_metadata_signature(
1014    xml: &str,
1015    trusted_certificates: &[String],
1016) -> Result<bool, SamlError> {
1017    verify_metadata_signature_with_limits(xml, trusted_certificates, XmlLimits::default())
1018}
1019
1020/// Verify a metadata XML-DSig signature with explicit XML parser limits.
1021///
1022/// # Errors
1023///
1024/// Returns [`SamlError`] when XML parsing, certificate loading, cryptographic
1025/// verification, or signed `<EntityDescriptor>` coverage checks fail.
1026pub fn verify_metadata_signature_with_limits(
1027    xml: &str,
1028    trusted_certificates: &[String],
1029    limits: XmlLimits,
1030) -> Result<bool, SamlError> {
1031    Ok(
1032        verify_metadata_signature_detailed_with_limits(xml, trusted_certificates, limits)?
1033            .verified(),
1034    )
1035}
1036
1037/// Verify a metadata XML-DSig signature and preserve signed descriptor coverage
1038/// using default XML parser limits.
1039///
1040/// # Errors
1041///
1042/// Returns [`SamlError`] when XML parsing, certificate loading, cryptographic
1043/// verification, transform policy, or signed `<EntityDescriptor>` coverage
1044/// checks fail.
1045pub fn verify_metadata_signature_detailed(
1046    xml: &str,
1047    trusted_certificates: &[String],
1048) -> Result<MetadataSignatureVerification, SamlError> {
1049    verify_metadata_signature_detailed_with_limits(xml, trusted_certificates, XmlLimits::default())
1050}
1051
1052/// Verify a metadata XML-DSig signature and preserve signed descriptor coverage.
1053///
1054/// # Errors
1055///
1056/// Returns [`SamlError`] when XML parsing, certificate loading, cryptographic
1057/// verification, transform policy, or signed `<EntityDescriptor>` coverage
1058/// checks fail.
1059pub fn verify_metadata_signature_detailed_with_limits(
1060    xml: &str,
1061    trusted_certificates: &[String],
1062    limits: XmlLimits,
1063) -> Result<MetadataSignatureVerification, SamlError> {
1064    let doc = dom::parse_with_limits(xml, limits)?;
1065    ensure_metadata_signature_transforms_preserve_descriptor(&doc.root)?;
1066    verify_metadata_signature_coverage_with_limits(xml, trusted_certificates, limits)
1067}
1068
1069/// Verify metadata signature cryptography and signed-root coverage.
1070///
1071/// The caller has already enforced the metadata signature profile.
1072///
1073/// # Errors
1074///
1075/// Returns [`SamlError`] when XML parsing, certificate loading, cryptographic
1076/// verification, or signed-root coverage checks fail.
1077pub(crate) fn verify_metadata_signature_coverage_with_limits(
1078    xml: &str,
1079    trusted_certificates: &[String],
1080    limits: XmlLimits,
1081) -> Result<MetadataSignatureVerification, SamlError> {
1082    let (verified, signed_entity_descriptor_xml) =
1083        verify_signature_with_limits(xml, trusted_certificates, limits)?;
1084    if !verified {
1085        return Ok(MetadataSignatureVerification::unverified());
1086    }
1087    signed_entity_descriptor_xml
1088        .map(MetadataSignatureVerification::from_signed_descriptor)
1089        .ok_or_else(verified_content_not_covered)
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094    use super::*;
1095    use crate::constants::signature_algorithm::{RSA_SHA256, RSA_SHA384, RSA_SHA512};
1096    use crate::constants::{digest_for_signature, namespace, transform_algorithm};
1097    use crate::crypto::construct_saml_signature;
1098    use crate::crypto::keys::load_private_key;
1099    use crate::util::normalize_cert_string;
1100    use crate::xml::{extract, ExtractorField};
1101    use bergshamra::sign;
1102
1103    #[test]
1104    fn external_reference_detection() {
1105        assert!(!is_external_reference("")); // whole document
1106        assert!(!is_external_reference("#_assertion123")); // same-document
1107        assert!(is_external_reference("https://evil.example.com/x"));
1108        assert!(is_external_reference("/etc/passwd"));
1109        assert!(is_external_reference("file:///etc/passwd"));
1110        assert!(is_external_reference("cid:attachment"));
1111    }
1112
1113    #[test]
1114    fn metadata_signature_transform_allowlist_preserves_canonicalization_interoperability() {
1115        const XPATH_TRANSFORM: &str = "http://www.w3.org/TR/1999/REC-xpath-19991116";
1116        const XSLT_TRANSFORM: &str = "http://www.w3.org/TR/1999/REC-xslt-19991116";
1117        const UNKNOWN_TRANSFORM: &str = "urn:example:unknown-transform";
1118
1119        for algorithm in [
1120            transform_algorithm::ENVELOPED_SIGNATURE,
1121            transform_algorithm::EXC_C14N,
1122            transform_algorithm::EXC_C14N_WITH_COMMENTS,
1123            XML_C14N_10,
1124            XML_C14N_10_WITH_COMMENTS,
1125            XML_C14N_11,
1126            XML_C14N_11_WITH_COMMENTS,
1127        ] {
1128            assert!(
1129                metadata_signature_transform_allowed(algorithm),
1130                "{algorithm}"
1131            );
1132        }
1133
1134        for algorithm in [XPATH_TRANSFORM, XSLT_TRANSFORM, UNKNOWN_TRANSFORM] {
1135            assert!(
1136                !metadata_signature_transform_allowed(algorithm),
1137                "{algorithm}"
1138            );
1139        }
1140    }
1141
1142    #[test]
1143    fn same_document_reference_target_parsing() -> Result<(), Box<dyn std::error::Error>> {
1144        assert_eq!(verified_target_from_uri("")?, VerifiedTarget::WholeDocument);
1145        assert_eq!(
1146            verified_target_from_uri("#_assertion123")?,
1147            VerifiedTarget::Id("_assertion123".to_string())
1148        );
1149        assert_eq!(
1150            verified_target_from_uri("#xpointer(/)")?,
1151            VerifiedTarget::WholeDocument
1152        );
1153        assert_eq!(
1154            verified_target_from_uri("#xpointer(id('_assertion123'))")?,
1155            VerifiedTarget::Id("_assertion123".to_string())
1156        );
1157        Ok(())
1158    }
1159
1160    #[test]
1161    fn unsupported_reference_target_parsing_fails() {
1162        assert!(matches!(
1163            verified_target_from_uri("#"),
1164            Err(SamlError::ReferenceResolution {
1165                reason: ReferenceResolutionReason::UnsupportedReferenceUri
1166            })
1167        ));
1168        assert!(matches!(
1169            verified_target_from_uri("#xpointer(//saml:Assertion)"),
1170            Err(SamlError::ReferenceResolution {
1171                reason: ReferenceResolutionReason::UnsupportedReferenceUri
1172            })
1173        ));
1174    }
1175
1176    fn preflight_signature(
1177        canonicalization: &str,
1178        signature: &str,
1179        digest: &str,
1180        transform: &str,
1181        references: usize,
1182        uri: &str,
1183    ) -> Result<(), SamlError> {
1184        let reference = format!(
1185            "<ds:Reference URI=\"{uri}\"><ds:Transforms><ds:Transform Algorithm=\"{enveloped}\"/><ds:Transform Algorithm=\"{transform}\"/></ds:Transforms><ds:DigestMethod Algorithm=\"{digest}\"/><ds:DigestValue>AAAA</ds:DigestValue></ds:Reference>",
1186            enveloped = transform_algorithm::ENVELOPED_SIGNATURE,
1187        );
1188        let xml = format!(
1189            "<samlp:Response xmlns:samlp=\"{protocol}\" xmlns:ds=\"{dsig}\" ID=\"_response\"><ds:Signature><ds:SignedInfo><ds:CanonicalizationMethod Algorithm=\"{canonicalization}\"/><ds:SignatureMethod Algorithm=\"{signature}\"/>{references}</ds:SignedInfo><ds:SignatureValue>AAAA</ds:SignatureValue></ds:Signature></samlp:Response>",
1190            protocol = namespace::PROTOCOL,
1191            dsig = namespace::DSIG,
1192            references = reference.repeat(references),
1193        );
1194        let document =
1195            uppsala::parse(&xml).map_err(|error| SamlError::Crypto(error.to_string()))?;
1196        let root = document.document_element().ok_or_else(|| {
1197            SamlError::Crypto("profile fixture is missing a document element".into())
1198        })?;
1199        let signature = bergshamra_child_element(
1200            &document,
1201            root,
1202            bergshamra_ns::DSIG,
1203            bergshamra_ns::node::SIGNATURE,
1204        )
1205        .ok_or(SamlError::SignedReferenceMismatch)?;
1206        enforce_strict_profile_on_verified_signature(&document, signature)
1207    }
1208
1209    #[test]
1210    fn strict_saml_signature_preflight_accepts_only_the_initial_profile() {
1211        for (signature, digest) in [
1212            (RSA_SHA256, crate::constants::digest_algorithm::SHA256),
1213            (RSA_SHA384, crate::constants::digest_algorithm::SHA384),
1214            (RSA_SHA512, crate::constants::digest_algorithm::SHA512),
1215        ] {
1216            assert!(preflight_signature(
1217                transform_algorithm::EXC_C14N,
1218                signature,
1219                digest,
1220                transform_algorithm::EXC_C14N,
1221                1,
1222                "#_response",
1223            )
1224            .is_ok());
1225        }
1226    }
1227
1228    #[test]
1229    fn strict_saml_signature_preflight_rejects_ambiguous_references_and_algorithms() {
1230        let accepted = (
1231            transform_algorithm::EXC_C14N,
1232            RSA_SHA256,
1233            crate::constants::digest_algorithm::SHA256,
1234            transform_algorithm::EXC_C14N,
1235        );
1236        for (references, uri) in [(0, "#_response"), (2, "#_response"), (1, "")] {
1237            assert!(preflight_signature(
1238                accepted.0, accepted.1, accepted.2, accepted.3, references, uri,
1239            )
1240            .is_err());
1241        }
1242        for rejected in [
1243            preflight_signature(
1244                "http://www.w3.org/TR/2001/REC-xml-c14n-20010315",
1245                accepted.1,
1246                accepted.2,
1247                accepted.3,
1248                1,
1249                "#_response",
1250            ),
1251            preflight_signature(
1252                accepted.0,
1253                "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
1254                accepted.2,
1255                accepted.3,
1256                1,
1257                "#_response",
1258            ),
1259            preflight_signature(
1260                accepted.0,
1261                accepted.1,
1262                "http://www.w3.org/2000/09/xmldsig#sha1",
1263                accepted.3,
1264                1,
1265                "#_response",
1266            ),
1267            preflight_signature(
1268                accepted.0,
1269                accepted.1,
1270                accepted.2,
1271                "http://www.w3.org/TR/1999/REC-xslt-19991116",
1272                1,
1273                "#_response",
1274            ),
1275        ] {
1276            assert!(matches!(rejected, Err(SamlError::AlgorithmUnsupported)));
1277        }
1278    }
1279
1280    #[test]
1281    fn advice_assertion_is_not_a_wrapping_shape() -> Result<(), Box<dyn std::error::Error>> {
1282        let document = dom::parse(&format!(
1283            "<samlp:Response xmlns:samlp=\"{protocol}\" xmlns:saml=\"{assertion}\"><saml:Assertion ID=\"_outer\"><saml:Advice><saml:Assertion ID=\"_advice\"/></saml:Advice></saml:Assertion></samlp:Response>",
1284            protocol = namespace::PROTOCOL,
1285            assertion = namespace::ASSERTION,
1286        ))?;
1287        assert!(!wrapping_detected(&document.root));
1288        Ok(())
1289    }
1290
1291    #[test]
1292    fn subject_confirmation_data_assertion_is_a_wrapping_shape(
1293    ) -> Result<(), Box<dyn std::error::Error>> {
1294        let document = dom::parse(&format!(
1295            "<samlp:Response xmlns:samlp=\"{protocol}\" xmlns:saml=\"{assertion}\"><saml:Assertion ID=\"_outer\"><saml:Subject><saml:SubjectConfirmation><saml:SubjectConfirmationData><saml:Assertion ID=\"_nested\"/></saml:SubjectConfirmationData></saml:SubjectConfirmation></saml:Subject></saml:Assertion></samlp:Response>",
1296            protocol = namespace::PROTOCOL,
1297            assertion = namespace::ASSERTION,
1298        ))?;
1299        assert!(wrapping_detected(&document.root));
1300        Ok(())
1301    }
1302
1303    #[test]
1304    fn duplicate_saml_id_allows_unique_ids() -> Result<(), Box<dyn std::error::Error>> {
1305        let doc = dom::parse(
1306            r#"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="_response"><saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_assertion"/></samlp:Response>"#,
1307        )?;
1308        let mut seen = HashSet::new();
1309        assert_eq!(duplicate_saml_id(&doc.root, &mut seen), None);
1310        Ok(())
1311    }
1312
1313    #[test]
1314    fn duplicate_saml_id_returns_repeated_value() -> Result<(), Box<dyn std::error::Error>> {
1315        let doc = dom::parse(
1316            r#"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"><saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_same"/><saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_same"/></samlp:Response>"#,
1317        )?;
1318        let mut seen = HashSet::new();
1319        assert_eq!(
1320            duplicate_saml_id(&doc.root, &mut seen),
1321            Some("_same".to_string())
1322        );
1323        Ok(())
1324    }
1325
1326    #[test]
1327    fn duplicate_saml_id_ignores_empty_values() -> Result<(), Box<dyn std::error::Error>> {
1328        let doc = dom::parse(
1329            r#"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID=""><saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID=""/></samlp:Response>"#,
1330        )?;
1331        let mut seen = HashSet::new();
1332        assert_eq!(duplicate_saml_id(&doc.root, &mut seen), None);
1333        Ok(())
1334    }
1335
1336    const RESPONSE_SIGNED: &str = include_str!("../../tests/fixtures/response_signed.xml");
1337    const SIGNED_REQUEST: &str = include_str!("../../tests/fixtures/signed_request_sha256.xml");
1338    const ATTACK: &str = include_str!("../../tests/fixtures/attack_response_signed.xml");
1339    const FALSE_SIGNED: &str = include_str!("../../tests/fixtures/false_signed_request_sha256.xml");
1340    const RESPONSE: &str = include_str!("../../tests/fixtures/response.xml");
1341    const SP_PRIVKEY: &str = include_str!("../../tests/fixtures/key/sp_privkey.pem");
1342    // IdP signing cert (matches the response_signed.xml signer / idpmeta).
1343    const IDP_CERT: &str = include_str!("../../tests/fixtures/key/idp_cert.cer");
1344    // SP signing cert (matches signed_request_sha256.xml signer).
1345    const SP_CERT: &str = include_str!("../../tests/fixtures/key/sp_cert.cer");
1346    const SP_SIGNING_CERT: &str = include_str!("../../tests/fixtures/key/sp_signing_cert.cer");
1347    const UNTRUSTED_PRIVKEY: &str =
1348        include_str!("../../tests/fixtures/key/idp/provider_matrix_privkey.pkcs8.pem");
1349    const UNTRUSTED_CERT: &str = include_str!("../../tests/fixtures/key/idp/cert.cer");
1350
1351    fn signed_response_with_foreign_extension_certificate(
1352    ) -> Result<String, Box<dyn std::error::Error>> {
1353        let response = RESPONSE.replacen(
1354            "<samlp:Status>",
1355            r#"<samlp:Extensions><x:Signature xmlns:x="urn:example:extension"><x:X509Certificate>attacker</x:X509Certificate></x:Signature></samlp:Extensions><samlp:Status>"#,
1356            1,
1357        );
1358        let key = load_private_key(SP_PRIVKEY, None)?;
1359        Ok(construct_saml_signature(
1360            &response,
1361            false,
1362            &key,
1363            SP_SIGNING_CERT,
1364            RSA_SHA256,
1365            &[],
1366            None,
1367        )?)
1368    }
1369
1370    fn response_with_first_invalid_signature() -> Result<String, Box<dyn std::error::Error>> {
1371        let cert = normalize_cert_string(IDP_CERT);
1372        let digest = digest_for_signature(RSA_SHA256).ok_or("unknown digest")?;
1373        let invalid_signature = format!(
1374            "<ds:Signature xmlns:ds=\"{dsig}\"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm=\"{exc_c14n}\"/><ds:SignatureMethod Algorithm=\"{sig_alg}\"/><ds:Reference URI=\"#_d71a3a8e9fcc45c9e9d248ef7049393fc8f04e5f75\"><ds:Transforms><ds:Transform Algorithm=\"{exc_c14n}\"/></ds:Transforms><ds:DigestMethod Algorithm=\"{digest}\"/><ds:DigestValue>AAAA</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>invalid</ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>{cert}</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature>",
1375            dsig = namespace::DSIG,
1376            exc_c14n = transform_algorithm::EXC_C14N,
1377            sig_alg = RSA_SHA256,
1378        );
1379        Ok(RESPONSE_SIGNED.replacen(
1380            "<samlp:Status>",
1381            &format!("{invalid_signature}<samlp:Status>"),
1382            1,
1383        ))
1384    }
1385
1386    fn signature_over_top_level_issuer(
1387        key_pem: &str,
1388        certificate: &str,
1389        algorithm: &str,
1390    ) -> Result<String, Box<dyn std::error::Error>> {
1391        let issuer_id = "_signed_response_issuer";
1392        let response = RESPONSE.replacen(
1393            "<saml:Issuer>",
1394            &format!("<saml:Issuer ID=\"{issuer_id}\">"),
1395            1,
1396        );
1397        let digest = digest_for_signature(algorithm).ok_or("unknown digest")?;
1398        let signature = format!(
1399            "<ds:Signature xmlns:ds=\"{dsig}\"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm=\"{exc_c14n}\"/><ds:SignatureMethod Algorithm=\"{algorithm}\"/><ds:Reference URI=\"#{issuer_id}\"><ds:Transforms><ds:Transform Algorithm=\"{enveloped}\"/><ds:Transform Algorithm=\"{exc_c14n}\"/></ds:Transforms><ds:DigestMethod Algorithm=\"{digest}\"/><ds:DigestValue></ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue></ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>{cert}</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature>",
1400            dsig = namespace::DSIG,
1401            exc_c14n = transform_algorithm::EXC_C14N,
1402            enveloped = transform_algorithm::ENVELOPED_SIGNATURE,
1403            cert = normalize_cert_string(certificate),
1404        );
1405        let template = response.replacen(
1406            "</saml:Issuer><samlp:Status>",
1407            &format!("</saml:Issuer>{signature}<samlp:Status>"),
1408            1,
1409        );
1410        let key = load_private_key(key_pem, None)?;
1411        let mut manager = KeysManager::new();
1412        manager.add_key(key);
1413        let context = DsigContext::new(manager).with_insecure(true);
1414        Ok(sign(&context, &template)?)
1415    }
1416
1417    fn signature_over_response_and_assertion() -> Result<String, Box<dyn std::error::Error>> {
1418        let document = dom::parse(RESPONSE)?;
1419        let response_id = node_saml_id(&document.root).ok_or("missing Response ID")?;
1420        let assertion = children_named(&document.root, "Assertion")
1421            .into_iter()
1422            .next()
1423            .ok_or("missing Assertion")?;
1424        let assertion_id = node_saml_id(assertion).ok_or("missing Assertion ID")?;
1425        let digest = digest_for_signature(RSA_SHA512).ok_or("unknown digest")?;
1426        let reference = |id: &str| {
1427            format!(
1428                "<ds:Reference URI=\"#{id}\"><ds:Transforms><ds:Transform Algorithm=\"{enveloped}\"/><ds:Transform Algorithm=\"{exc_c14n}\"/></ds:Transforms><ds:DigestMethod Algorithm=\"{digest}\"/><ds:DigestValue></ds:DigestValue></ds:Reference>",
1429                enveloped = transform_algorithm::ENVELOPED_SIGNATURE,
1430                exc_c14n = transform_algorithm::EXC_C14N,
1431            )
1432        };
1433        let signature = format!(
1434            "<ds:Signature xmlns:ds=\"{dsig}\"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm=\"{exc_c14n}\"/><ds:SignatureMethod Algorithm=\"{algorithm}\"/>{response_reference}{assertion_reference}</ds:SignedInfo><ds:SignatureValue></ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>{cert}</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature>",
1435            dsig = namespace::DSIG,
1436            exc_c14n = transform_algorithm::EXC_C14N,
1437            algorithm = RSA_SHA512,
1438            response_reference = reference(response_id),
1439            assertion_reference = reference(assertion_id),
1440            cert = normalize_cert_string(SP_SIGNING_CERT),
1441        );
1442        let template = RESPONSE.replacen(
1443            "</saml:Issuer><samlp:Status>",
1444            &format!("</saml:Issuer>{signature}<samlp:Status>"),
1445            1,
1446        );
1447        let key = load_private_key(SP_PRIVKEY, None)?;
1448        let mut manager = KeysManager::new();
1449        manager.add_key(key);
1450        let context = DsigContext::new(manager).with_insecure(true);
1451        Ok(sign(&context, &template)?)
1452    }
1453
1454    fn root_signature(xml: &str) -> Result<String, Box<dyn std::error::Error>> {
1455        let document = dom::parse(xml)?;
1456        let signature = children_named(&document.root, "Signature")
1457            .into_iter()
1458            .next()
1459            .ok_or("missing root Signature")?;
1460        Ok(xml[signature.start..signature.end].to_string())
1461    }
1462
1463    fn remove_signature_key_info(signature: &str) -> Result<String, Box<dyn std::error::Error>> {
1464        let document = dom::parse(signature)?;
1465        let key_info = children_named(&document.root, "KeyInfo")
1466            .into_iter()
1467            .next()
1468            .ok_or("missing KeyInfo")?;
1469        Ok(format!(
1470            "{}{}",
1471            &signature[..key_info.start],
1472            &signature[key_info.end..]
1473        ))
1474    }
1475
1476    fn assertion_signed_response_with_extra_signature(
1477        extra_signature: &str,
1478    ) -> Result<String, Box<dyn std::error::Error>> {
1479        let response = RESPONSE.replacen(
1480            "<saml:Issuer>",
1481            "<saml:Issuer ID=\"_signed_response_issuer\">",
1482            1,
1483        );
1484        let key = load_private_key(SP_PRIVKEY, None)?;
1485        let assertion_signed = construct_saml_signature(
1486            &response,
1487            false,
1488            &key,
1489            SP_SIGNING_CERT,
1490            RSA_SHA256,
1491            &[],
1492            None,
1493        )?;
1494        Ok(assertion_signed.replacen(
1495            "</samlp:Response>",
1496            &format!("{extra_signature}</samlp:Response>"),
1497            1,
1498        ))
1499    }
1500
1501    fn sign_template(template: &str) -> Result<String, Box<dyn std::error::Error>> {
1502        let key = load_private_key(SP_PRIVKEY, None)?;
1503        let mut manager = KeysManager::new();
1504        manager.add_key(key);
1505        let context = DsigContext::new(manager).with_insecure(true);
1506        Ok(sign(&context, template)?)
1507    }
1508
1509    fn response_signed_over_assertion_with_foreign_xpath(
1510    ) -> Result<String, Box<dyn std::error::Error>> {
1511        const XPATH_TRANSFORM: &str = "http://www.w3.org/TR/1999/REC-xpath-19991116";
1512        let document = dom::parse(RESPONSE)?;
1513        let assertion = children_named(&document.root, "Assertion")
1514            .into_iter()
1515            .next()
1516            .ok_or("missing Assertion")?;
1517        let assertion_id = node_saml_id(assertion).ok_or("missing Assertion ID")?;
1518        let digest = digest_for_signature(RSA_SHA256).ok_or("unknown digest")?;
1519        let signature = format!(
1520            "<ds:Signature xmlns:ds=\"{dsig}\"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm=\"{exc}\"/><ds:SignatureMethod Algorithm=\"{algorithm}\"/><ds:Reference URI=\"#{assertion_id}\"><ds:Transforms><ds:Transform Algorithm=\"{enveloped}\"/><Transform xmlns=\"urn:example:other\" Algorithm=\"{xpath}\"><XPath>0</XPath></Transform><ds:Transform Algorithm=\"{exc}\"/></ds:Transforms><ds:DigestMethod Algorithm=\"{digest}\"/><ds:DigestValue></ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue></ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>{cert}</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature>",
1521            dsig = namespace::DSIG,
1522            exc = transform_algorithm::EXC_C14N,
1523            algorithm = RSA_SHA256,
1524            enveloped = transform_algorithm::ENVELOPED_SIGNATURE,
1525            xpath = XPATH_TRANSFORM,
1526            digest = digest,
1527            cert = normalize_cert_string(SP_SIGNING_CERT),
1528        );
1529        let template = RESPONSE.replacen(
1530            "</saml:Issuer><samlp:Status>",
1531            &format!("</saml:Issuer>{signature}<samlp:Status>"),
1532            1,
1533        );
1534        sign_template(&template)
1535    }
1536
1537    fn response_with_issuer_only_signature(
1538        canonicalization: &str,
1539    ) -> Result<String, Box<dyn std::error::Error>> {
1540        let response = RESPONSE.replacen(
1541            "<saml:Issuer>",
1542            "<saml:Issuer ID=\"_signed_response_issuer\">",
1543            1,
1544        );
1545        let digest = digest_for_signature(RSA_SHA256).ok_or("unknown digest")?;
1546        let signature = format!(
1547            "<ds:Signature xmlns:ds=\"{dsig}\"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm=\"{canonicalization}\"/><ds:SignatureMethod Algorithm=\"{algorithm}\"/><ds:Reference URI=\"#_signed_response_issuer\"><ds:Transforms><ds:Transform Algorithm=\"{enveloped}\"/><ds:Transform Algorithm=\"{exc}\"/></ds:Transforms><ds:DigestMethod Algorithm=\"{digest}\"/><ds:DigestValue></ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue></ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>{cert}</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature>",
1548            dsig = namespace::DSIG,
1549            algorithm = RSA_SHA256,
1550            enveloped = transform_algorithm::ENVELOPED_SIGNATURE,
1551            exc = transform_algorithm::EXC_C14N,
1552            digest = digest,
1553            cert = normalize_cert_string(SP_SIGNING_CERT),
1554        );
1555        let template =
1556            response.replacen("</saml:Issuer>", &format!("</saml:Issuer>{signature}"), 1);
1557        sign_template(&template)
1558    }
1559
1560    fn cid_reference_response() -> Result<String, Box<dyn std::error::Error>> {
1561        let cert = normalize_cert_string(SP_SIGNING_CERT);
1562        let signature = format!(
1563            "<ds:Signature xmlns:ds=\"{dsig}\"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm=\"{exc_c14n}\"/><ds:SignatureMethod Algorithm=\"{sig_alg}\"/><ds:Reference URI=\"cid:attachment-1@example.com\"><ds:DigestMethod Algorithm=\"{digest}\"/><ds:DigestValue>AAAA</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue></ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>{cert}</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature>",
1564            dsig = namespace::DSIG,
1565            exc_c14n = transform_algorithm::EXC_C14N,
1566            sig_alg = RSA_SHA256,
1567            digest = digest_for_signature(RSA_SHA256).ok_or("unknown digest")?,
1568        );
1569        let template =
1570            RESPONSE.replacen("<samlp:Status>", &format!("{signature}<samlp:Status>"), 1);
1571        let key = load_private_key(SP_PRIVKEY, None)?;
1572        let mut manager = KeysManager::new();
1573        manager.add_key(key);
1574        let ctx = DsigContext::new(manager).with_insecure(true);
1575        Ok(sign(&ctx, &template)?)
1576    }
1577
1578    fn assert_reference_resolution(
1579        result: Result<(bool, Option<String>), SamlError>,
1580        expected: ReferenceResolutionReason,
1581    ) -> Result<(), Box<dyn std::error::Error>> {
1582        match result {
1583            Err(SamlError::ReferenceResolution { reason }) if reason == expected => Ok(()),
1584            other => Err(format!("expected reference resolution {expected}, got {other:?}").into()),
1585        }
1586    }
1587
1588    #[test]
1589    fn dsig_context_secure_defaults_survive_insecure_builder() {
1590        let ctx = DsigContext::new(KeysManager::new());
1591        assert!(ctx.trusted_keys_only);
1592        assert!(ctx.strict_verification);
1593        assert!(ctx.require_reference_digests);
1594        assert_eq!(ctx.hmac_min_out_len, 160);
1595        assert!(!ctx.insecure);
1596
1597        let insecure = ctx.with_insecure(true);
1598        assert!(insecure.insecure);
1599        assert!(insecure.trusted_keys_only);
1600        assert!(insecure.strict_verification);
1601        assert!(insecure.require_reference_digests);
1602        assert_eq!(insecure.hmac_min_out_len, 160);
1603    }
1604
1605    #[test]
1606    fn verifies_signed_response_with_metadata_cert() -> Result<(), Box<dyn std::error::Error>> {
1607        let (verified, content) = verify_signature(RESPONSE_SIGNED, &[IDP_CERT.to_string()])?;
1608        assert!(
1609            verified,
1610            "response_signed.xml should verify with the IdP cert"
1611        );
1612        assert!(content
1613            .ok_or("expected signed assertion")?
1614            .contains("Assertion"));
1615        Ok(())
1616    }
1617
1618    #[test]
1619    fn verifies_generated_same_document_signature() -> Result<(), Box<dyn std::error::Error>> {
1620        let key = load_private_key(SP_PRIVKEY, None)?;
1621        let signed = construct_saml_signature(
1622            RESPONSE,
1623            false,
1624            &key,
1625            SP_SIGNING_CERT,
1626            RSA_SHA256,
1627            &[],
1628            None,
1629        )?;
1630        let (verified, content) = verify_signature(&signed, &[SP_SIGNING_CERT.to_string()])?;
1631        assert!(verified);
1632        assert!(content
1633            .ok_or("expected signed assertion")?
1634            .contains("Assertion"));
1635        Ok(())
1636    }
1637
1638    #[test]
1639    fn foreign_extension_certificate_is_not_treated_as_signature_key_info(
1640    ) -> Result<(), Box<dyn std::error::Error>> {
1641        let signed = signed_response_with_foreign_extension_certificate()?;
1642        let (verified, content) = verify_signature(&signed, &[SP_SIGNING_CERT.to_string()])?;
1643        assert!(verified);
1644        assert!(content
1645            .ok_or("expected signed assertion")?
1646            .contains("Assertion"));
1647        Ok(())
1648    }
1649
1650    #[test]
1651    fn detailed_verification_ignores_foreign_extension_certificate(
1652    ) -> Result<(), Box<dyn std::error::Error>> {
1653        let signed = signed_response_with_foreign_extension_certificate()?;
1654        let result = verify_signatures_detailed_with_profile(
1655            &signed,
1656            &[SP_SIGNING_CERT.to_string()],
1657            XmlLimits::default(),
1658            false,
1659        )?;
1660        assert!(result.verified() && result.assertion_directly_covered());
1661        Ok(())
1662    }
1663
1664    #[test]
1665    fn inline_certificate_is_not_used_without_metadata_pin(
1666    ) -> Result<(), Box<dyn std::error::Error>> {
1667        match verify_signature(RESPONSE_SIGNED, &[]) {
1668            Err(SamlError::NoTrustedCertificate) => Ok(()),
1669            other => Err(format!("expected missing pinned certificate, got {other:?}").into()),
1670        }
1671    }
1672
1673    #[test]
1674    fn first_invalid_signature_prevents_later_valid_signature_from_authorizing_response(
1675    ) -> Result<(), Box<dyn std::error::Error>> {
1676        let result = verify_signature(
1677            &response_with_first_invalid_signature()?,
1678            &[IDP_CERT.to_string()],
1679        )?;
1680        assert_eq!(result, (false, None));
1681        Ok(())
1682    }
1683
1684    #[test]
1685    fn detailed_verification_rejects_invalid_first_signature_before_later_assertion_coverage(
1686    ) -> Result<(), Box<dyn std::error::Error>> {
1687        let result = verify_signatures_detailed_with_profile(
1688            &response_with_first_invalid_signature()?,
1689            &[IDP_CERT.to_string()],
1690            XmlLimits::default(),
1691            false,
1692        )?;
1693        assert!(!result.verified() && !result.response_covered());
1694        assert!(result.verified_embedded_signatures().is_empty());
1695        Ok(())
1696    }
1697
1698    #[test]
1699    fn detailed_verification_excludes_untrusted_extra_signature_evidence(
1700    ) -> Result<(), Box<dyn std::error::Error>> {
1701        let untrusted = root_signature(&signature_over_top_level_issuer(
1702            UNTRUSTED_PRIVKEY,
1703            UNTRUSTED_CERT,
1704            RSA_SHA512,
1705        )?)?;
1706        let untrusted = remove_signature_key_info(&untrusted)?;
1707        let signed = assertion_signed_response_with_extra_signature(&untrusted)?;
1708        let result = verify_signatures_detailed_with_profile(
1709            &signed,
1710            &[SP_SIGNING_CERT.to_string()],
1711            XmlLimits::default(),
1712            false,
1713        )?;
1714
1715        let [signature] = result.verified_embedded_signatures() else {
1716            return Err("expected only the trusted Assertion signature".into());
1717        };
1718        assert!(result.verified() && result.assertion_directly_covered());
1719        assert_eq!(signature.algorithm_uri(), RSA_SHA256);
1720        assert!(signature.assertion_directly_covered() && !signature.response_covered());
1721        Ok(())
1722    }
1723
1724    #[test]
1725    fn detailed_verification_excludes_valid_unrelated_signature_evidence(
1726    ) -> Result<(), Box<dyn std::error::Error>> {
1727        let unrelated = root_signature(&signature_over_top_level_issuer(
1728            SP_PRIVKEY,
1729            SP_SIGNING_CERT,
1730            RSA_SHA512,
1731        )?)?;
1732        let signed = assertion_signed_response_with_extra_signature(&unrelated)?;
1733        let result = verify_signatures_detailed_with_profile(
1734            &signed,
1735            &[SP_SIGNING_CERT.to_string()],
1736            XmlLimits::default(),
1737            false,
1738        )?;
1739
1740        let [signature] = result.verified_embedded_signatures() else {
1741            return Err("expected only the Assertion-covering signature".into());
1742        };
1743        assert!(result.verified() && result.assertion_directly_covered());
1744        assert_eq!(signature.algorithm_uri(), RSA_SHA256);
1745        assert!(signature.assertion_directly_covered() && !signature.response_covered());
1746        Ok(())
1747    }
1748
1749    #[test]
1750    fn strict_profile_accepts_verified_assertion_beside_nonconforming_extra_signature(
1751    ) -> Result<(), Box<dyn std::error::Error>> {
1752        let extra = format!(
1753            "<ds:Signature xmlns:ds=\"{dsig}\"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm=\"http://www.w3.org/TR/2001/REC-xml-c14n-20010315\"/><ds:SignatureMethod Algorithm=\"http://www.w3.org/2000/09/xmldsig#rsa-sha1\"/><ds:Reference URI=\"#_response\"><ds:DigestMethod Algorithm=\"http://www.w3.org/2000/09/xmldsig#sha1\"/><ds:DigestValue>AAAA</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>AAAA</ds:SignatureValue></ds:Signature>",
1754            dsig = namespace::DSIG,
1755        );
1756        let signed = assertion_signed_response_with_extra_signature(&extra)?;
1757        let result = verify_signatures_detailed_with_profile(
1758            &signed,
1759            &[SP_SIGNING_CERT.to_string()],
1760            XmlLimits::default(),
1761            true,
1762        )?;
1763
1764        let [signature] = result.verified_embedded_signatures() else {
1765            return Err("expected only the trusted Assertion signature".into());
1766        };
1767        assert!(result.verified() && result.assertion_directly_covered());
1768        assert_eq!(signature.algorithm_uri(), RSA_SHA256);
1769        assert!(signature.assertion_directly_covered() && !signature.response_covered());
1770        Ok(())
1771    }
1772
1773    #[test]
1774    fn strict_profile_rejects_verified_covering_signature_with_two_references(
1775    ) -> Result<(), Box<dyn std::error::Error>> {
1776        let signed = signature_over_response_and_assertion()?;
1777        let result = verify_signatures_detailed_with_profile(
1778            &signed,
1779            &[SP_SIGNING_CERT.to_string()],
1780            XmlLimits::default(),
1781            true,
1782        );
1783
1784        assert!(matches!(result, Err(SamlError::SignedReferenceMismatch)));
1785        Ok(())
1786    }
1787
1788    #[test]
1789    fn strict_profile_rejects_foreign_xpath_transform_on_verified_assertion_signature(
1790    ) -> Result<(), Box<dyn std::error::Error>> {
1791        let signed = response_signed_over_assertion_with_foreign_xpath()?;
1792        let compatible = verify_signatures_detailed_with_profile(
1793            &signed,
1794            &[SP_SIGNING_CERT.to_string()],
1795            XmlLimits::default(),
1796            false,
1797        )?;
1798        let [signature] = compatible.verified_embedded_signatures() else {
1799            return Err("expected the assertion-covering signature".into());
1800        };
1801        assert!(compatible.verified() && compatible.assertion_directly_covered());
1802        assert!(signature.assertion_directly_covered());
1803
1804        let strict = verify_signatures_detailed_with_profile(
1805            &signed,
1806            &[SP_SIGNING_CERT.to_string()],
1807            XmlLimits::default(),
1808            true,
1809        );
1810        assert!(matches!(strict, Err(SamlError::AlgorithmUnsupported)));
1811        Ok(())
1812    }
1813
1814    #[test]
1815    fn strict_profile_ignores_verified_signature_that_does_not_authenticate_the_response(
1816    ) -> Result<(), Box<dyn std::error::Error>> {
1817        let signed = response_with_issuer_only_signature(XML_C14N_10)?;
1818        let result = verify_signatures_detailed_with_profile(
1819            &signed,
1820            &[SP_SIGNING_CERT.to_string()],
1821            XmlLimits::default(),
1822            true,
1823        );
1824        assert!(matches!(result, Err(SamlError::SignedReferenceMismatch)));
1825        Ok(())
1826    }
1827
1828    #[test]
1829    fn detailed_verification_reports_one_signature_covering_response_and_assertion(
1830    ) -> Result<(), Box<dyn std::error::Error>> {
1831        let signed = signature_over_response_and_assertion()?;
1832        let result = verify_signatures_detailed_with_profile(
1833            &signed,
1834            &[SP_SIGNING_CERT.to_string()],
1835            XmlLimits::default(),
1836            false,
1837        )?;
1838
1839        let [signature] = result.verified_embedded_signatures() else {
1840            return Err("expected one verified embedded signature".into());
1841        };
1842        assert!(result.verified() && result.assertion_directly_covered());
1843        assert!(result.response_covered());
1844        assert_eq!(signature.algorithm_uri(), RSA_SHA512);
1845        assert!(signature.assertion_directly_covered() && signature.response_covered());
1846        Ok(())
1847    }
1848
1849    #[test]
1850    fn detailed_verification_aggregates_rolling_cert_coverage_after_first_signature_verifies(
1851    ) -> Result<(), Box<dyn std::error::Error>> {
1852        let response_key = load_private_key(SP_PRIVKEY, None)?;
1853        let signed_response_and_assertion = construct_saml_signature(
1854            RESPONSE_SIGNED,
1855            true,
1856            &response_key,
1857            SP_SIGNING_CERT,
1858            RSA_SHA256,
1859            &[],
1860            None,
1861        )?;
1862        let result = verify_signatures_detailed_with_profile(
1863            &signed_response_and_assertion,
1864            &[SP_SIGNING_CERT.to_string(), IDP_CERT.to_string()],
1865            XmlLimits::default(),
1866            false,
1867        )?;
1868        assert!(
1869            result.verified() && result.assertion_directly_covered() && result.response_covered()
1870        );
1871        assert_eq!(result.verified_embedded_signatures().len(), 2);
1872        Ok(())
1873    }
1874
1875    #[test]
1876    fn signed_cid_reference_is_rejected_before_content_extraction(
1877    ) -> Result<(), Box<dyn std::error::Error>> {
1878        assert_reference_resolution(
1879            verify_signature(&cid_reference_response()?, &[SP_SIGNING_CERT.to_string()]),
1880            ReferenceResolutionReason::ExternalReference,
1881        )
1882    }
1883
1884    #[test]
1885    fn rejects_signed_request_without_root_coverage() -> Result<(), Box<dyn std::error::Error>> {
1886        match verify_signature(SIGNED_REQUEST, &[SP_CERT.to_string()]) {
1887            Err(SamlError::SignedReferenceMismatch) => Ok(()),
1888            other => {
1889                Err(format!("expected uncovered AuthnRequest rejection, got {other:?}").into())
1890            }
1891        }
1892    }
1893
1894    #[test]
1895    fn rejects_wrong_certificate() -> Result<(), Box<dyn std::error::Error>> {
1896        // RESPONSE_SIGNED embeds the IdP cert; verifying against the SP cert
1897        // trips the inline-vs-metadata mismatch guard.
1898        match verify_signature(RESPONSE_SIGNED, &[SP_CERT.to_string()]) {
1899            Err(SamlError::CertificateMismatch) => Ok(()),
1900            other => Err(format!("expected CertificateMismatch, got {other:?}").into()),
1901        }
1902    }
1903
1904    #[test]
1905    fn rejects_tampered_signature() -> Result<(), Box<dyn std::error::Error>> {
1906        // false_signed_request_sha256.xml: signature present but content tampered
1907        let (verified, _) = verify_signature(FALSE_SIGNED, &[SP_CERT.to_string()])?;
1908        assert!(!verified, "tampered message must not verify");
1909        Ok(())
1910    }
1911
1912    #[test]
1913    fn rolling_cert_unloadable_peer_keeps_invalid_verdict() -> Result<(), Box<dyn std::error::Error>>
1914    {
1915        let garbage = "not a certificate".to_string();
1916        let signer = SP_CERT.to_string();
1917        for certs in [vec![garbage.clone(), signer.clone()], vec![signer, garbage]] {
1918            assert_eq!(
1919                verify_signature(FALSE_SIGNED, &certs)?,
1920                (false, None),
1921                "unloadable leftover must not replace Invalid with Crypto"
1922            );
1923        }
1924        Ok(())
1925    }
1926
1927    #[test]
1928    fn detailed_rolling_cert_unloadable_peer_keeps_invalid_verdict(
1929    ) -> Result<(), Box<dyn std::error::Error>> {
1930        let garbage = "not a certificate".to_string();
1931        let signer = SP_CERT.to_string();
1932        for certs in [vec![garbage.clone(), signer.clone()], vec![signer, garbage]] {
1933            let result = verify_signatures_detailed_with_profile(
1934                FALSE_SIGNED,
1935                &certs,
1936                XmlLimits::default(),
1937                false,
1938            )?;
1939            assert!(
1940                !result.verified()
1941                    && !result.assertion_directly_covered()
1942                    && !result.response_covered(),
1943                "unloadable leftover must not replace Invalid with Crypto"
1944            );
1945        }
1946        Ok(())
1947    }
1948
1949    #[test]
1950    fn rolling_cert_unloadable_peer_does_not_block_valid_signature(
1951    ) -> Result<(), Box<dyn std::error::Error>> {
1952        // response_signed.xml is SHA-1; FIPS rejects that digest.
1953        let key = load_private_key(SP_PRIVKEY, None)?;
1954        let signed = construct_saml_signature(
1955            RESPONSE,
1956            false,
1957            &key,
1958            SP_SIGNING_CERT,
1959            RSA_SHA256,
1960            &[],
1961            None,
1962        )?;
1963        let (verified, content) = verify_signature(
1964            &signed,
1965            &["not a certificate".to_string(), SP_SIGNING_CERT.to_string()],
1966        )?;
1967        assert!(verified);
1968        assert!(content
1969            .ok_or("expected signed assertion")?
1970            .contains("Assertion"));
1971        Ok(())
1972    }
1973
1974    #[test]
1975    fn rejects_multi_root_wrapping_attack_before_signature_verification(
1976    ) -> Result<(), Box<dyn std::error::Error>> {
1977        // attack_response_signed.xml places a forged NameID before the signed
1978        // response. Reject the multi-root document before signature processing.
1979        match verify_signature(ATTACK, &[IDP_CERT.to_string()]) {
1980            Err(SamlError::Xml(message)) if message == "multiple document elements" => Ok(()),
1981            other => Err(format!("expected multi-root XML rejection, got {other:?}").into()),
1982        }
1983    }
1984
1985    #[test]
1986    fn no_signature_returns_false() -> Result<(), Box<dyn std::error::Error>> {
1987        // a document without any Signature element verifies to (false, None)
1988        let (verified, content) = verify_signature("<samlp:Response>x</samlp:Response>", &[])?;
1989        assert!(!verified);
1990        assert!(content.is_none());
1991        // keep the extractor import exercised
1992        let _ = extract("<a/>", &[ExtractorField::new("x", &["a"])])?;
1993        Ok(())
1994    }
1995}