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::{verify, verify_all, DsigContext, KeysManager, VerifiedReference, VerifyResult};
19use std::collections::HashSet;
20
21fn children_named<'a>(node: &'a Node, name: &str) -> Vec<&'a Node> {
22    node.children
23        .iter()
24        .filter(|c| c.local_name == name)
25        .collect()
26}
27
28fn has_child(node: &Node, name: &str) -> bool {
29    node.children.iter().any(|c| c.local_name == name)
30}
31
32fn saml_signature_candidates(root: &Node) -> Vec<&Node> {
33    let mut signatures = children_named(root, "Signature");
34    for assertion in children_named(root, "Assertion") {
35        signatures.extend(children_named(assertion, "Signature"));
36    }
37    signatures
38}
39
40fn has_descendant(node: &Node, names: &[&str]) -> bool {
41    node.children
42        .iter()
43        .any(|c| names.contains(&c.local_name.as_str()) || has_descendant(c, names))
44}
45
46/// XSW guard: `Response/Assertion/Subject/SubjectConfirmation/SubjectConfirmationData//(Assertion|Signature)`.
47fn wrapping_detected(root: &Node) -> bool {
48    for assertion in children_named(root, "Assertion") {
49        for subject in children_named(assertion, "Subject") {
50            for sc in children_named(subject, "SubjectConfirmation") {
51                for scd in children_named(sc, "SubjectConfirmationData") {
52                    if has_descendant(scd, &["Assertion", "Signature"]) {
53                        return true;
54                    }
55                }
56            }
57        }
58    }
59    false
60}
61
62fn saml_id_attr(name: &str) -> bool {
63    matches!(name, "ID" | "AssertionID")
64}
65
66fn duplicate_saml_id(node: &Node, seen: &mut HashSet<String>) -> Option<String> {
67    for (name, value) in &node.attrs {
68        if saml_id_attr(name) && !value.is_empty() && !seen.insert(value.clone()) {
69            return Some(value.clone());
70        }
71    }
72    node.children
73        .iter()
74        .find_map(|child| duplicate_saml_id(child, seen))
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
78enum VerifiedTarget {
79    WholeDocument,
80    Id(String),
81}
82
83fn reference_resolution(reason: ReferenceResolutionReason) -> SamlError {
84    SamlError::ReferenceResolution { reason }
85}
86
87fn verified_target_from_uri(uri: &str) -> Result<VerifiedTarget, SamlError> {
88    if uri.is_empty() || uri == "#xpointer(/)" {
89        return Ok(VerifiedTarget::WholeDocument);
90    }
91
92    let fragment = uri
93        .strip_prefix('#')
94        .ok_or_else(|| reference_resolution(ReferenceResolutionReason::ExternalReference))?;
95    if fragment.is_empty() {
96        return Err(reference_resolution(
97            ReferenceResolutionReason::UnsupportedReferenceUri,
98        ));
99    }
100    if let Some(id) = fragment
101        .strip_prefix("xpointer(id('")
102        .and_then(|rest| rest.strip_suffix("'))"))
103    {
104        if id.is_empty() {
105            return Err(reference_resolution(
106                ReferenceResolutionReason::UnsupportedReferenceUri,
107            ));
108        }
109        return Ok(VerifiedTarget::Id(id.to_string()));
110    }
111    if fragment.starts_with("xpointer(") {
112        return Err(reference_resolution(
113            ReferenceResolutionReason::UnsupportedReferenceUri,
114        ));
115    }
116    Ok(VerifiedTarget::Id(fragment.to_string()))
117}
118
119fn verified_targets(references: &[VerifiedReference]) -> Result<Vec<VerifiedTarget>, SamlError> {
120    if references.is_empty() {
121        return Err(reference_resolution(
122            ReferenceResolutionReason::MissingSignatureReference,
123        ));
124    }
125
126    let mut targets = Vec::with_capacity(references.len());
127    for reference in references {
128        if is_external_reference(&reference.uri) {
129            return Err(reference_resolution(
130                ReferenceResolutionReason::ExternalReference,
131            ));
132        }
133        if !reference.digest_verified {
134            return Err(SamlError::SignatureVerification {
135                reason: SignatureVerificationReason::ReferenceDigest,
136            });
137        }
138        let target = verified_target_from_uri(&reference.uri)?;
139        if matches!(target, VerifiedTarget::Id(_)) && reference.resolved_node.is_none() {
140            return Err(reference_resolution(
141                ReferenceResolutionReason::UnresolvedReference,
142            ));
143        }
144        targets.push(target);
145    }
146    Ok(targets)
147}
148
149fn node_saml_id(node: &Node) -> Option<&str> {
150    node.attr("ID").or_else(|| node.attr("AssertionID"))
151}
152
153fn target_matches_node(targets: &[VerifiedTarget], node: &Node) -> bool {
154    targets.iter().any(|target| match target {
155        VerifiedTarget::WholeDocument => true,
156        VerifiedTarget::Id(id) => node_saml_id(node).is_some_and(|node_id| node_id == id),
157    })
158}
159
160fn id_target_matches_node(targets: &[VerifiedTarget], node: &Node) -> bool {
161    targets.iter().any(|target| match target {
162        VerifiedTarget::WholeDocument => false,
163        VerifiedTarget::Id(id) => node_saml_id(node).is_some_and(|node_id| node_id == id),
164    })
165}
166
167fn response_is_covered(targets: &[VerifiedTarget], root: &Node) -> bool {
168    target_matches_node(targets, root)
169}
170
171fn verified_content_not_covered() -> SamlError {
172    SamlError::SignedReferenceMismatch
173}
174
175const EXC_C14N_WITH_COMMENTS: &str = "http://www.w3.org/2001/10/xml-exc-c14n#WithComments";
176const XML_C14N_10: &str = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315";
177const XML_C14N_10_WITH_COMMENTS: &str =
178    "http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments";
179const XML_C14N_11: &str = "http://www.w3.org/2006/12/xml-c14n11";
180const XML_C14N_11_WITH_COMMENTS: &str = "http://www.w3.org/2006/12/xml-c14n11#WithComments";
181
182fn metadata_signature_transform_allowed(algorithm: &str) -> bool {
183    matches!(
184        algorithm,
185        transform_algorithm::ENVELOPED_SIGNATURE
186            | transform_algorithm::EXC_C14N
187            | EXC_C14N_WITH_COMMENTS
188            | XML_C14N_10
189            | XML_C14N_10_WITH_COMMENTS
190            | XML_C14N_11
191            | XML_C14N_11_WITH_COMMENTS
192    )
193}
194
195fn ensure_metadata_reference_transforms_preserve_descriptor(
196    reference: &Node,
197) -> Result<(), SamlError> {
198    for transforms in children_named(reference, "Transforms") {
199        for transform in children_named(transforms, "Transform") {
200            if transform
201                .attr("Algorithm")
202                .is_some_and(metadata_signature_transform_allowed)
203            {
204                continue;
205            }
206            return Err(verified_content_not_covered());
207        }
208    }
209    Ok(())
210}
211
212fn ensure_metadata_signature_transforms_preserve_descriptor(root: &Node) -> Result<(), SamlError> {
213    if root.local_name != "EntityDescriptor" {
214        return Ok(());
215    }
216
217    for signature in children_named(root, "Signature") {
218        for signed_info in children_named(signature, "SignedInfo") {
219            for reference in children_named(signed_info, "Reference") {
220                ensure_metadata_reference_transforms_preserve_descriptor(reference)?;
221            }
222        }
223    }
224    Ok(())
225}
226
227fn verified_root_content(
228    root: &Node,
229    xml: &str,
230    targets: &[VerifiedTarget],
231) -> Result<String, SamlError> {
232    if target_matches_node(targets, root) {
233        return Ok(xml[root.start..root.end].to_string());
234    }
235    Err(verified_content_not_covered())
236}
237
238/// Return the source of the content covered by a verified reference: the lone
239/// `<Assertion>`, a consumed root element, or the whole `<Response>` when
240/// assertions are encrypted.
241fn verified_content(
242    root: &Node,
243    xml: &str,
244    targets: &[VerifiedTarget],
245) -> Result<Option<String>, SamlError> {
246    if root.local_name == "Assertion" {
247        return verified_root_content(root, xml, targets).map(Some);
248    }
249    if root.local_name.contains("Response") {
250        let assertions = children_named(root, "Assertion");
251        if assertions.len() > 1 {
252            return Err(SamlError::PotentialWrappingAttack);
253        }
254        if assertions.len() == 1 {
255            let a = assertions[0];
256            if id_target_matches_node(targets, a) || response_is_covered(targets, root) {
257                return Ok(Some(xml[a.start..a.end].to_string()));
258            }
259            return Err(verified_content_not_covered());
260        }
261        if has_child(root, "EncryptedAssertion") {
262            if response_is_covered(targets, root) {
263                return Ok(Some(xml[root.start..root.end].to_string()));
264            }
265            return Err(verified_content_not_covered());
266        }
267    }
268    if root.local_name == "EntityDescriptor" {
269        if target_matches_node(targets, root) {
270            return Ok(Some(xml[root.start..root.end].to_string()));
271        }
272        return Err(verified_content_not_covered());
273    }
274    if matches!(
275        root.local_name.as_str(),
276        "AuthnRequest" | "LogoutRequest" | "LogoutResponse"
277    ) {
278        return verified_root_content(root, xml, targets).map(Some);
279    }
280    Ok(None)
281}
282
283fn assertion_is_directly_covered(root: &Node, targets: &[VerifiedTarget]) -> bool {
284    if root.local_name == "Assertion" {
285        return target_matches_node(targets, root);
286    }
287    if root.local_name.contains("Response") {
288        let assertions = children_named(root, "Assertion");
289        return assertions.len() == 1 && id_target_matches_node(targets, assertions[0]);
290    }
291    false
292}
293
294/// True for a signed `<Reference>` URI that is not same-document (i.e. not a
295/// `#id` fragment or the whole document). Such references can pull external or
296/// local-file content into the verified set and are rejected for SAML.
297fn is_external_reference(uri: &str) -> bool {
298    !uri.is_empty() && !uri.starts_with('#')
299}
300
301fn has_saml_xml_signature(root: &Node) -> bool {
302    !saml_signature_candidates(root).is_empty()
303}
304
305fn preflight_saml_reference_uris(signatures: &[&Node]) -> Result<(), SamlError> {
306    for signature in signatures {
307        for signed_info in children_named(signature, "SignedInfo") {
308            for reference in children_named(signed_info, "Reference") {
309                verified_target_from_uri(reference.attr("URI").unwrap_or_default())?;
310            }
311        }
312    }
313    Ok(())
314}
315
316pub(crate) fn has_xml_signature_with_limits(
317    xml: &str,
318    limits: XmlLimits,
319) -> Result<bool, SamlError> {
320    let doc = dom::parse_with_limits(xml, limits)?;
321    Ok(has_saml_xml_signature(&doc.root))
322}
323
324/// First `<X509Certificate>` text found inside a candidate `<Signature>` (the
325/// cert the sender embedded in the message), if any.
326fn inline_signature_cert(signatures: &[&Node]) -> Option<String> {
327    fn descendant_cert(node: &Node) -> Option<String> {
328        if node.local_name == "X509Certificate" && !node.text.is_empty() {
329            return Some(node.text.clone());
330        }
331        node.children.iter().find_map(descendant_cert)
332    }
333
334    signatures
335        .iter()
336        .find_map(|signature| descendant_cert(signature))
337}
338
339/// Verify the XML-DSig signature(s) of `xml` against `metadata_certs`.
340///
341/// Returns `(verified, signed_content)`:
342/// - `(false, None)` when there is no signature or it does not verify;
343/// - `(true, Some(xml))` with the signed assertion/response on success;
344/// - `Err(PotentialWrappingAttack)` on a detected XSW attempt.
345///
346/// # Errors
347///
348/// Returns [`SamlError`] when XML parsing, trust checks, reference resolution,
349/// cryptographic verification, or signed-content coverage checks fail.
350pub fn verify_signature(
351    xml: &str,
352    metadata_certs: &[String],
353) -> Result<(bool, Option<String>), SamlError> {
354    verify_signature_with_limits(xml, metadata_certs, XmlLimits::default())
355}
356
357/// Verify the XML-DSig signature(s) of `xml` with explicit XML parser limits.
358///
359/// # Errors
360///
361/// Returns [`SamlError`] when XML parsing, trust checks, reference resolution,
362/// cryptographic verification, or signed-content coverage checks fail.
363pub fn verify_signature_with_limits(
364    xml: &str,
365    metadata_certs: &[String],
366    limits: XmlLimits,
367) -> Result<(bool, Option<String>), SamlError> {
368    let doc = dom::parse_with_limits(xml, limits)?;
369    let root = &doc.root;
370
371    if root.local_name.contains("Response") && wrapping_detected(root) {
372        return Err(SamlError::PotentialWrappingAttack);
373    }
374
375    let mut seen_ids = HashSet::new();
376    if duplicate_saml_id(root, &mut seen_ids).is_some() {
377        return Err(SamlError::PotentialWrappingAttack);
378    }
379
380    // Candidate signatures: message-level (root > Signature) or assertion-level.
381    let signature_candidates = saml_signature_candidates(root);
382    if signature_candidates.is_empty() {
383        return Ok((false, None));
384    }
385    preflight_saml_reference_uris(&signature_candidates)?;
386
387    // If the message embeds a certificate, it must be one declared in metadata
388    // (rolling-cert safety). Verification itself still uses only the metadata
389    // certs.
390    if let Some(inline) = inline_signature_cert(&signature_candidates) {
391        let inline = normalize_cert_string(&inline);
392        if !metadata_certs.is_empty()
393            && !metadata_certs
394                .iter()
395                .any(|c| normalize_cert_string(c) == inline)
396        {
397            return Err(SamlError::CertificateMismatch);
398        }
399    }
400
401    super::provider::ensure_crypto_provider_initialized()?;
402
403    // Try each metadata certificate individually (rolling-cert support): the
404    // signature verifies if any one of the declared keys matches.
405    let mut have_key = false;
406    let mut key_load_error = None;
407    let mut tried_invalid = false;
408    let mut last_err: Option<SamlError> = None;
409    for cert in metadata_certs {
410        let key = match load_certificate(cert) {
411            Ok(key) => key,
412            Err(error) => {
413                key_load_error.get_or_insert(error);
414                continue;
415            }
416        };
417        have_key = true;
418        let mut manager = KeysManager::new();
419        manager.add_key(key);
420        // Trust model (audited against bergshamra 0.8.0):
421        // - Metadata certificates are pinned key material, not a public CA
422        //   chain. Verification uses only the metadata-pinned key; inline
423        //   KeyInfo (X509Certificate/KeyValue) is never imported as key
424        //   material.
425        // - Set `trusted_keys_only`, `strict_verification`,
426        //   `require_reference_digests`, and `hmac_min_out_len` explicitly
427        //   instead of relying on upstream defaults.
428        // - `strict_verification`: same-document references must target the
429        //   document element, an ancestor, or a sibling of the Signature (XSW
430        //   guard); the surrounding preflight and result checks reject
431        //   external or unresolved SAML references.
432        // - `with_insecure(true)`: intentionally skips Bergshamra's X.509
433        //   certificate validation (chain/trust/time), which is irrelevant to
434        //   our leaf-key pinning model. `trusted_keys_only` still confines
435        //   verification to metadata-pinned keys, and this setting does not
436        //   skip signature, digest, reference, duplicate-ID, or XSW checks.
437        // - Inbound SAML verification must never use
438        //   `DsigContext::new_permissive()`.
439        let ctx = DsigContext::new(manager)
440            .with_trusted_keys_only(true)
441            .with_strict_verification(true)
442            .with_require_reference_digests(true)
443            .with_hmac_min_out_len(160)
444            .with_insecure(true);
445        match verify(&ctx, xml) {
446            Ok(VerifyResult::Valid {
447                signature_node: _,
448                references,
449                ..
450            }) => {
451                let targets = verified_targets(&references)?;
452                return Ok((true, verified_content(root, xml, &targets)?));
453            }
454            Ok(VerifyResult::Invalid { .. }) => tried_invalid = true,
455            Err(e) => last_err = Some(SamlError::Crypto(e.to_string())),
456        }
457    }
458    if !have_key {
459        return Err(key_load_error.unwrap_or(SamlError::NoTrustedCertificate));
460    }
461    // A leftover unloadable cert must not poison a rolling-cert verdict.
462    // A clean "invalid" (key mismatch / tampered) is a non-error false; only
463    // surface a structural error when no loaded certificate produced a verdict.
464    match last_err {
465        Some(err) if !tried_invalid => Err(err),
466        _ => Ok((false, None)),
467    }
468}
469
470#[derive(Debug, Clone, PartialEq, Eq)]
471pub(crate) struct SignatureVerification {
472    verified: bool,
473    signed_content: Option<String>,
474    assertion_directly_covered: bool,
475    response_covered: bool,
476}
477
478impl SignatureVerification {
479    pub(crate) fn verified(&self) -> bool {
480        self.verified
481    }
482
483    pub(crate) fn assertion_directly_covered(&self) -> bool {
484        self.assertion_directly_covered
485    }
486
487    pub(crate) fn response_covered(&self) -> bool {
488        self.response_covered
489    }
490
491    pub(crate) fn into_signed_content(self) -> Option<String> {
492        self.signed_content
493    }
494}
495
496pub(crate) fn verify_signatures_detailed_with_limits(
497    xml: &str,
498    metadata_certs: &[String],
499    limits: XmlLimits,
500) -> Result<SignatureVerification, SamlError> {
501    let doc = dom::parse_with_limits(xml, limits)?;
502    let root = &doc.root;
503
504    if root.local_name.contains("Response") && wrapping_detected(root) {
505        return Err(SamlError::PotentialWrappingAttack);
506    }
507
508    let mut seen_ids = HashSet::new();
509    if duplicate_saml_id(root, &mut seen_ids).is_some() {
510        return Err(SamlError::PotentialWrappingAttack);
511    }
512
513    let signature_candidates = saml_signature_candidates(root);
514    if signature_candidates.is_empty() {
515        return Ok(SignatureVerification {
516            verified: false,
517            signed_content: None,
518            assertion_directly_covered: false,
519            response_covered: false,
520        });
521    }
522    preflight_saml_reference_uris(&signature_candidates)?;
523
524    if let Some(inline) = inline_signature_cert(&signature_candidates) {
525        let inline = normalize_cert_string(&inline);
526        if !metadata_certs.is_empty()
527            && !metadata_certs
528                .iter()
529                .any(|c| normalize_cert_string(c) == inline)
530        {
531            return Err(SamlError::CertificateMismatch);
532        }
533    }
534
535    super::provider::ensure_crypto_provider_initialized()?;
536
537    let mut have_key = false;
538    let mut key_load_error = None;
539    let mut tried_invalid = false;
540    let mut last_err: Option<SamlError> = None;
541    let mut first_signature_verified = false;
542    let mut targets = Vec::new();
543    for cert in metadata_certs {
544        let key = match load_certificate(cert) {
545            Ok(key) => key,
546            Err(error) => {
547                key_load_error.get_or_insert(error);
548                continue;
549            }
550        };
551        have_key = true;
552        let mut manager = KeysManager::new();
553        manager.add_key(key);
554        let ctx = DsigContext::new(manager)
555            .with_trusted_keys_only(true)
556            .with_strict_verification(true)
557            .with_require_reference_digests(true)
558            .with_hmac_min_out_len(160)
559            .with_insecure(true);
560        match verify_all(&ctx, xml) {
561            Ok(results) => {
562                first_signature_verified |=
563                    matches!(results.first(), Some(VerifyResult::Valid { .. }));
564                for result in results {
565                    match result {
566                        VerifyResult::Valid {
567                            signature_node: _,
568                            references,
569                            ..
570                        } => targets.extend(verified_targets(&references)?),
571                        VerifyResult::Invalid { .. } => tried_invalid = true,
572                    }
573                }
574            }
575            Err(error) => last_err = Some(SamlError::Crypto(error.to_string())),
576        }
577    }
578    if !have_key {
579        return Err(key_load_error.unwrap_or(SamlError::NoTrustedCertificate));
580    }
581    if first_signature_verified && !targets.is_empty() {
582        let assertion_directly_covered = assertion_is_directly_covered(root, &targets);
583        let response_covered =
584            root.local_name.contains("Response") && response_is_covered(&targets, root);
585        return Ok(SignatureVerification {
586            verified: true,
587            signed_content: verified_content(root, xml, &targets)?,
588            assertion_directly_covered,
589            response_covered,
590        });
591    }
592    match last_err {
593        Some(error) if !tried_invalid => Err(error),
594        _ => Ok(SignatureVerification {
595            verified: false,
596            signed_content: None,
597            assertion_directly_covered: false,
598            response_covered: false,
599        }),
600    }
601}
602
603/// Detailed metadata signature verification result.
604#[derive(Debug, Clone, PartialEq, Eq)]
605pub struct MetadataSignatureVerification {
606    verified: bool,
607    signed_entity_descriptor_xml: Option<String>,
608}
609
610impl MetadataSignatureVerification {
611    pub(crate) fn from_signed_descriptor(signed_entity_descriptor_xml: String) -> Self {
612        Self {
613            verified: true,
614            signed_entity_descriptor_xml: Some(signed_entity_descriptor_xml),
615        }
616    }
617
618    pub(crate) fn unverified() -> Self {
619        Self {
620            verified: false,
621            signed_entity_descriptor_xml: None,
622        }
623    }
624
625    /// Whether a metadata signature verified against the pinned certificates.
626    pub fn verified(&self) -> bool {
627        self.verified
628    }
629
630    /// The signed `<EntityDescriptor>` XML when verification succeeds.
631    pub fn signed_entity_descriptor_xml(&self) -> Option<&str> {
632        self.signed_entity_descriptor_xml.as_deref()
633    }
634
635    pub(crate) fn into_signed_entity_descriptor_xml(self) -> Option<String> {
636        self.signed_entity_descriptor_xml
637    }
638}
639
640/// Verify the enveloped XML-DSig signature on a metadata document against
641/// trusted certificate(s); returns whether it is valid and covers the consumed
642/// `<EntityDescriptor>` document.
643///
644/// # Errors
645///
646/// Returns [`SamlError`] when XML parsing, certificate loading, cryptographic
647/// verification, or signed `<EntityDescriptor>` coverage checks fail.
648pub fn verify_metadata_signature(
649    xml: &str,
650    trusted_certificates: &[String],
651) -> Result<bool, SamlError> {
652    verify_metadata_signature_with_limits(xml, trusted_certificates, XmlLimits::default())
653}
654
655/// Verify a metadata XML-DSig signature with explicit XML parser limits.
656///
657/// # Errors
658///
659/// Returns [`SamlError`] when XML parsing, certificate loading, cryptographic
660/// verification, or signed `<EntityDescriptor>` coverage checks fail.
661pub fn verify_metadata_signature_with_limits(
662    xml: &str,
663    trusted_certificates: &[String],
664    limits: XmlLimits,
665) -> Result<bool, SamlError> {
666    Ok(
667        verify_metadata_signature_detailed_with_limits(xml, trusted_certificates, limits)?
668            .verified(),
669    )
670}
671
672/// Verify a metadata XML-DSig signature and preserve signed descriptor coverage
673/// using default XML parser limits.
674///
675/// # Errors
676///
677/// Returns [`SamlError`] when XML parsing, certificate loading, cryptographic
678/// verification, transform policy, or signed `<EntityDescriptor>` coverage
679/// checks fail.
680pub fn verify_metadata_signature_detailed(
681    xml: &str,
682    trusted_certificates: &[String],
683) -> Result<MetadataSignatureVerification, SamlError> {
684    verify_metadata_signature_detailed_with_limits(xml, trusted_certificates, XmlLimits::default())
685}
686
687/// Verify a metadata XML-DSig signature and preserve signed descriptor coverage.
688///
689/// # Errors
690///
691/// Returns [`SamlError`] when XML parsing, certificate loading, cryptographic
692/// verification, transform policy, or signed `<EntityDescriptor>` coverage
693/// checks fail.
694pub fn verify_metadata_signature_detailed_with_limits(
695    xml: &str,
696    trusted_certificates: &[String],
697    limits: XmlLimits,
698) -> Result<MetadataSignatureVerification, SamlError> {
699    let doc = dom::parse_with_limits(xml, limits)?;
700    ensure_metadata_signature_transforms_preserve_descriptor(&doc.root)?;
701
702    let (verified, signed_entity_descriptor_xml) =
703        verify_signature_with_limits(xml, trusted_certificates, limits)?;
704    if !verified {
705        return Ok(MetadataSignatureVerification::unverified());
706    }
707    signed_entity_descriptor_xml
708        .map(MetadataSignatureVerification::from_signed_descriptor)
709        .ok_or_else(verified_content_not_covered)
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715    use crate::constants::signature_algorithm::RSA_SHA256;
716    use crate::constants::{digest_for_signature, namespace, transform_algorithm};
717    use crate::crypto::construct_saml_signature;
718    use crate::crypto::keys::load_private_key;
719    use crate::util::normalize_cert_string;
720    use crate::xml::{extract, ExtractorField};
721    use bergshamra::sign;
722
723    #[test]
724    fn external_reference_detection() {
725        assert!(!is_external_reference("")); // whole document
726        assert!(!is_external_reference("#_assertion123")); // same-document
727        assert!(is_external_reference("https://evil.example.com/x"));
728        assert!(is_external_reference("/etc/passwd"));
729        assert!(is_external_reference("file:///etc/passwd"));
730        assert!(is_external_reference("cid:attachment"));
731    }
732
733    #[test]
734    fn metadata_signature_transform_allowlist_preserves_canonicalization_interoperability() {
735        const XPATH_TRANSFORM: &str = "http://www.w3.org/TR/1999/REC-xpath-19991116";
736        const XSLT_TRANSFORM: &str = "http://www.w3.org/TR/1999/REC-xslt-19991116";
737        const UNKNOWN_TRANSFORM: &str = "urn:example:unknown-transform";
738
739        for algorithm in [
740            transform_algorithm::ENVELOPED_SIGNATURE,
741            transform_algorithm::EXC_C14N,
742            EXC_C14N_WITH_COMMENTS,
743            XML_C14N_10,
744            XML_C14N_10_WITH_COMMENTS,
745            XML_C14N_11,
746            XML_C14N_11_WITH_COMMENTS,
747        ] {
748            assert!(
749                metadata_signature_transform_allowed(algorithm),
750                "{algorithm}"
751            );
752        }
753
754        for algorithm in [XPATH_TRANSFORM, XSLT_TRANSFORM, UNKNOWN_TRANSFORM] {
755            assert!(
756                !metadata_signature_transform_allowed(algorithm),
757                "{algorithm}"
758            );
759        }
760    }
761
762    #[test]
763    fn same_document_reference_target_parsing() -> Result<(), Box<dyn std::error::Error>> {
764        assert_eq!(verified_target_from_uri("")?, VerifiedTarget::WholeDocument);
765        assert_eq!(
766            verified_target_from_uri("#_assertion123")?,
767            VerifiedTarget::Id("_assertion123".to_string())
768        );
769        assert_eq!(
770            verified_target_from_uri("#xpointer(/)")?,
771            VerifiedTarget::WholeDocument
772        );
773        assert_eq!(
774            verified_target_from_uri("#xpointer(id('_assertion123'))")?,
775            VerifiedTarget::Id("_assertion123".to_string())
776        );
777        Ok(())
778    }
779
780    #[test]
781    fn unsupported_reference_target_parsing_fails() {
782        assert!(matches!(
783            verified_target_from_uri("#"),
784            Err(SamlError::ReferenceResolution {
785                reason: ReferenceResolutionReason::UnsupportedReferenceUri
786            })
787        ));
788        assert!(matches!(
789            verified_target_from_uri("#xpointer(//saml:Assertion)"),
790            Err(SamlError::ReferenceResolution {
791                reason: ReferenceResolutionReason::UnsupportedReferenceUri
792            })
793        ));
794    }
795
796    #[test]
797    fn duplicate_saml_id_allows_unique_ids() -> Result<(), Box<dyn std::error::Error>> {
798        let doc = dom::parse(
799            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>"#,
800        )?;
801        let mut seen = HashSet::new();
802        assert_eq!(duplicate_saml_id(&doc.root, &mut seen), None);
803        Ok(())
804    }
805
806    #[test]
807    fn duplicate_saml_id_returns_repeated_value() -> Result<(), Box<dyn std::error::Error>> {
808        let doc = dom::parse(
809            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>"#,
810        )?;
811        let mut seen = HashSet::new();
812        assert_eq!(
813            duplicate_saml_id(&doc.root, &mut seen),
814            Some("_same".to_string())
815        );
816        Ok(())
817    }
818
819    #[test]
820    fn duplicate_saml_id_ignores_empty_values() -> Result<(), Box<dyn std::error::Error>> {
821        let doc = dom::parse(
822            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>"#,
823        )?;
824        let mut seen = HashSet::new();
825        assert_eq!(duplicate_saml_id(&doc.root, &mut seen), None);
826        Ok(())
827    }
828
829    const RESPONSE_SIGNED: &str = include_str!("../../tests/fixtures/response_signed.xml");
830    const SIGNED_REQUEST: &str = include_str!("../../tests/fixtures/signed_request_sha256.xml");
831    const ATTACK: &str = include_str!("../../tests/fixtures/attack_response_signed.xml");
832    const FALSE_SIGNED: &str = include_str!("../../tests/fixtures/false_signed_request_sha256.xml");
833    const RESPONSE: &str = include_str!("../../tests/fixtures/response.xml");
834    const SP_PRIVKEY: &str = include_str!("../../tests/fixtures/key/sp_privkey.pem");
835    // IdP signing cert (matches the response_signed.xml signer / idpmeta).
836    const IDP_CERT: &str = include_str!("../../tests/fixtures/key/idp_cert.cer");
837    // SP signing cert (matches signed_request_sha256.xml signer).
838    const SP_CERT: &str = include_str!("../../tests/fixtures/key/sp_cert.cer");
839    const SP_SIGNING_CERT: &str = include_str!("../../tests/fixtures/key/sp_signing_cert.cer");
840
841    fn signed_response_with_foreign_extension_certificate(
842    ) -> Result<String, Box<dyn std::error::Error>> {
843        let response = RESPONSE.replacen(
844            "<samlp:Status>",
845            r#"<samlp:Extensions><x:Signature xmlns:x="urn:example:extension"><x:X509Certificate>attacker</x:X509Certificate></x:Signature></samlp:Extensions><samlp:Status>"#,
846            1,
847        );
848        let key = load_private_key(SP_PRIVKEY, None)?;
849        Ok(construct_saml_signature(
850            &response,
851            false,
852            &key,
853            SP_SIGNING_CERT,
854            RSA_SHA256,
855            &[],
856            None,
857        )?)
858    }
859
860    fn response_with_first_invalid_signature() -> Result<String, Box<dyn std::error::Error>> {
861        let cert = normalize_cert_string(IDP_CERT);
862        let digest = digest_for_signature(RSA_SHA256).ok_or("unknown digest")?;
863        let invalid_signature = format!(
864            "<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>",
865            dsig = namespace::DSIG,
866            exc_c14n = transform_algorithm::EXC_C14N,
867            sig_alg = RSA_SHA256,
868        );
869        Ok(RESPONSE_SIGNED.replacen(
870            "<samlp:Status>",
871            &format!("{invalid_signature}<samlp:Status>"),
872            1,
873        ))
874    }
875
876    fn cid_reference_response() -> Result<String, Box<dyn std::error::Error>> {
877        let cert = normalize_cert_string(SP_SIGNING_CERT);
878        let signature = format!(
879            "<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>",
880            dsig = namespace::DSIG,
881            exc_c14n = transform_algorithm::EXC_C14N,
882            sig_alg = RSA_SHA256,
883            digest = digest_for_signature(RSA_SHA256).ok_or("unknown digest")?,
884        );
885        let template =
886            RESPONSE.replacen("<samlp:Status>", &format!("{signature}<samlp:Status>"), 1);
887        let key = load_private_key(SP_PRIVKEY, None)?;
888        let mut manager = KeysManager::new();
889        manager.add_key(key);
890        let ctx = DsigContext::new(manager).with_insecure(true);
891        Ok(sign(&ctx, &template)?)
892    }
893
894    fn assert_reference_resolution(
895        result: Result<(bool, Option<String>), SamlError>,
896        expected: ReferenceResolutionReason,
897    ) -> Result<(), Box<dyn std::error::Error>> {
898        match result {
899            Err(SamlError::ReferenceResolution { reason }) if reason == expected => Ok(()),
900            other => Err(format!("expected reference resolution {expected}, got {other:?}").into()),
901        }
902    }
903
904    #[test]
905    fn dsig_context_secure_defaults_survive_insecure_builder() {
906        let ctx = DsigContext::new(KeysManager::new());
907        assert!(ctx.trusted_keys_only);
908        assert!(ctx.strict_verification);
909        assert!(ctx.require_reference_digests);
910        assert_eq!(ctx.hmac_min_out_len, 160);
911        assert!(!ctx.insecure);
912
913        let insecure = ctx.with_insecure(true);
914        assert!(insecure.insecure);
915        assert!(insecure.trusted_keys_only);
916        assert!(insecure.strict_verification);
917        assert!(insecure.require_reference_digests);
918        assert_eq!(insecure.hmac_min_out_len, 160);
919    }
920
921    #[test]
922    fn verifies_signed_response_with_metadata_cert() -> Result<(), Box<dyn std::error::Error>> {
923        let (verified, content) = verify_signature(RESPONSE_SIGNED, &[IDP_CERT.to_string()])?;
924        assert!(
925            verified,
926            "response_signed.xml should verify with the IdP cert"
927        );
928        assert!(content
929            .ok_or("expected signed assertion")?
930            .contains("Assertion"));
931        Ok(())
932    }
933
934    #[test]
935    fn verifies_generated_same_document_signature() -> Result<(), Box<dyn std::error::Error>> {
936        let key = load_private_key(SP_PRIVKEY, None)?;
937        let signed = construct_saml_signature(
938            RESPONSE,
939            false,
940            &key,
941            SP_SIGNING_CERT,
942            RSA_SHA256,
943            &[],
944            None,
945        )?;
946        let (verified, content) = verify_signature(&signed, &[SP_SIGNING_CERT.to_string()])?;
947        assert!(verified);
948        assert!(content
949            .ok_or("expected signed assertion")?
950            .contains("Assertion"));
951        Ok(())
952    }
953
954    #[test]
955    fn foreign_extension_certificate_is_not_treated_as_signature_key_info(
956    ) -> Result<(), Box<dyn std::error::Error>> {
957        let signed = signed_response_with_foreign_extension_certificate()?;
958        let (verified, content) = verify_signature(&signed, &[SP_SIGNING_CERT.to_string()])?;
959        assert!(verified);
960        assert!(content
961            .ok_or("expected signed assertion")?
962            .contains("Assertion"));
963        Ok(())
964    }
965
966    #[test]
967    fn detailed_verification_ignores_foreign_extension_certificate(
968    ) -> Result<(), Box<dyn std::error::Error>> {
969        let signed = signed_response_with_foreign_extension_certificate()?;
970        let result = verify_signatures_detailed_with_limits(
971            &signed,
972            &[SP_SIGNING_CERT.to_string()],
973            XmlLimits::default(),
974        )?;
975        assert!(result.verified() && result.assertion_directly_covered());
976        Ok(())
977    }
978
979    #[test]
980    fn inline_certificate_is_not_used_without_metadata_pin(
981    ) -> Result<(), Box<dyn std::error::Error>> {
982        match verify_signature(RESPONSE_SIGNED, &[]) {
983            Err(SamlError::NoTrustedCertificate) => Ok(()),
984            other => Err(format!("expected missing pinned certificate, got {other:?}").into()),
985        }
986    }
987
988    #[test]
989    fn first_invalid_signature_prevents_later_valid_signature_from_authorizing_response(
990    ) -> Result<(), Box<dyn std::error::Error>> {
991        let result = verify_signature(
992            &response_with_first_invalid_signature()?,
993            &[IDP_CERT.to_string()],
994        )?;
995        assert_eq!(result, (false, None));
996        Ok(())
997    }
998
999    #[test]
1000    fn detailed_verification_rejects_invalid_first_signature_before_later_assertion_coverage(
1001    ) -> Result<(), Box<dyn std::error::Error>> {
1002        let result = verify_signatures_detailed_with_limits(
1003            &response_with_first_invalid_signature()?,
1004            &[IDP_CERT.to_string()],
1005            XmlLimits::default(),
1006        )?;
1007        assert!(!result.verified() && !result.response_covered());
1008        Ok(())
1009    }
1010
1011    #[test]
1012    fn detailed_verification_aggregates_rolling_cert_coverage_after_first_signature_verifies(
1013    ) -> Result<(), Box<dyn std::error::Error>> {
1014        let response_key = load_private_key(SP_PRIVKEY, None)?;
1015        let signed_response_and_assertion = construct_saml_signature(
1016            RESPONSE_SIGNED,
1017            true,
1018            &response_key,
1019            SP_SIGNING_CERT,
1020            RSA_SHA256,
1021            &[],
1022            None,
1023        )?;
1024        let result = verify_signatures_detailed_with_limits(
1025            &signed_response_and_assertion,
1026            &[SP_SIGNING_CERT.to_string(), IDP_CERT.to_string()],
1027            XmlLimits::default(),
1028        )?;
1029        assert!(
1030            result.verified() && result.assertion_directly_covered() && result.response_covered()
1031        );
1032        Ok(())
1033    }
1034
1035    #[test]
1036    fn signed_cid_reference_is_rejected_before_content_extraction(
1037    ) -> Result<(), Box<dyn std::error::Error>> {
1038        assert_reference_resolution(
1039            verify_signature(&cid_reference_response()?, &[SP_SIGNING_CERT.to_string()]),
1040            ReferenceResolutionReason::ExternalReference,
1041        )
1042    }
1043
1044    #[test]
1045    fn rejects_signed_request_without_root_coverage() -> Result<(), Box<dyn std::error::Error>> {
1046        match verify_signature(SIGNED_REQUEST, &[SP_CERT.to_string()]) {
1047            Err(SamlError::SignedReferenceMismatch) => Ok(()),
1048            other => {
1049                Err(format!("expected uncovered AuthnRequest rejection, got {other:?}").into())
1050            }
1051        }
1052    }
1053
1054    #[test]
1055    fn rejects_wrong_certificate() -> Result<(), Box<dyn std::error::Error>> {
1056        // RESPONSE_SIGNED embeds the IdP cert; verifying against the SP cert
1057        // trips the inline-vs-metadata mismatch guard.
1058        match verify_signature(RESPONSE_SIGNED, &[SP_CERT.to_string()]) {
1059            Err(SamlError::CertificateMismatch) => Ok(()),
1060            other => Err(format!("expected CertificateMismatch, got {other:?}").into()),
1061        }
1062    }
1063
1064    #[test]
1065    fn rejects_tampered_signature() -> Result<(), Box<dyn std::error::Error>> {
1066        // false_signed_request_sha256.xml: signature present but content tampered
1067        let (verified, _) = verify_signature(FALSE_SIGNED, &[SP_CERT.to_string()])?;
1068        assert!(!verified, "tampered message must not verify");
1069        Ok(())
1070    }
1071
1072    #[test]
1073    fn rolling_cert_unloadable_peer_keeps_invalid_verdict() -> Result<(), Box<dyn std::error::Error>>
1074    {
1075        let garbage = "not a certificate".to_string();
1076        let signer = SP_CERT.to_string();
1077        for certs in [vec![garbage.clone(), signer.clone()], vec![signer, garbage]] {
1078            assert_eq!(
1079                verify_signature(FALSE_SIGNED, &certs)?,
1080                (false, None),
1081                "unloadable leftover must not replace Invalid with Crypto"
1082            );
1083        }
1084        Ok(())
1085    }
1086
1087    #[test]
1088    fn detailed_rolling_cert_unloadable_peer_keeps_invalid_verdict(
1089    ) -> Result<(), Box<dyn std::error::Error>> {
1090        let garbage = "not a certificate".to_string();
1091        let signer = SP_CERT.to_string();
1092        for certs in [vec![garbage.clone(), signer.clone()], vec![signer, garbage]] {
1093            let result =
1094                verify_signatures_detailed_with_limits(FALSE_SIGNED, &certs, XmlLimits::default())?;
1095            assert!(
1096                !result.verified()
1097                    && !result.assertion_directly_covered()
1098                    && !result.response_covered(),
1099                "unloadable leftover must not replace Invalid with Crypto"
1100            );
1101        }
1102        Ok(())
1103    }
1104
1105    #[test]
1106    fn rolling_cert_unloadable_peer_does_not_block_valid_signature(
1107    ) -> Result<(), Box<dyn std::error::Error>> {
1108        // response_signed.xml is SHA-1; FIPS rejects that digest.
1109        let key = load_private_key(SP_PRIVKEY, None)?;
1110        let signed = construct_saml_signature(
1111            RESPONSE,
1112            false,
1113            &key,
1114            SP_SIGNING_CERT,
1115            RSA_SHA256,
1116            &[],
1117            None,
1118        )?;
1119        let (verified, content) = verify_signature(
1120            &signed,
1121            &["not a certificate".to_string(), SP_SIGNING_CERT.to_string()],
1122        )?;
1123        assert!(verified);
1124        assert!(content
1125            .ok_or("expected signed assertion")?
1126            .contains("Assertion"));
1127        Ok(())
1128    }
1129
1130    #[test]
1131    fn rejects_multi_root_wrapping_attack_before_signature_verification(
1132    ) -> Result<(), Box<dyn std::error::Error>> {
1133        // attack_response_signed.xml places a forged NameID before the signed
1134        // response. Reject the multi-root document before signature processing.
1135        match verify_signature(ATTACK, &[IDP_CERT.to_string()]) {
1136            Err(SamlError::Xml(message)) if message == "multiple document elements" => Ok(()),
1137            other => Err(format!("expected multi-root XML rejection, got {other:?}").into()),
1138        }
1139    }
1140
1141    #[test]
1142    fn no_signature_returns_false() -> Result<(), Box<dyn std::error::Error>> {
1143        // a document without any Signature element verifies to (false, None)
1144        let (verified, content) = verify_signature("<samlp:Response>x</samlp:Response>", &[])?;
1145        assert!(!verified);
1146        assert!(content.is_none());
1147        // keep the extractor import exercised
1148        let _ = extract("<a/>", &[ExtractorField::new("x", &["a"])])?;
1149        Ok(())
1150    }
1151}