Skip to main content

saml_rs/crypto/
verify.rs

1//! XML-DSig verification and anti-wrapping checks, delegating cryptography to
2//! `bergshamra` (feature `crypto-bergshamra`).
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    // Try each metadata certificate individually (rolling-cert support): the
402    // signature verifies if any one of the declared keys matches.
403    let mut have_key = false;
404    let mut tried_invalid = false;
405    let mut last_err: Option<SamlError> = None;
406    for cert in metadata_certs {
407        let key = match load_certificate(cert) {
408            Ok(key) => key,
409            Err(_) => continue,
410        };
411        have_key = true;
412        let mut manager = KeysManager::new();
413        manager.add_key(key);
414        // Trust model (audited against bergshamra 0.7.0):
415        // - Metadata certificates are pinned key material, not a public CA
416        //   chain. Verification uses only the metadata-pinned key; inline
417        //   KeyInfo (X509Certificate/KeyValue) is never imported as key
418        //   material.
419        // - Set `trusted_keys_only`, `strict_verification`, and
420        //   `hmac_min_out_len` explicitly instead of relying on upstream
421        //   defaults.
422        // - `strict_verification`: each signed reference must target the
423        //   document element, an ancestor, or a sibling of the Signature (XSW
424        //   guard).
425        // - `with_insecure(true)`: intentionally skips X.509 chain/path/time
426        //   validation only, which is irrelevant to our pinning model. It does
427        //   not skip signature, digest, reference, duplicate-ID, or XSW
428        //   enforcement.
429        // - Inbound SAML verification must never use
430        //   `DsigContext::new_permissive()`.
431        let ctx = DsigContext::new(manager)
432            .with_trusted_keys_only(true)
433            .with_strict_verification(true)
434            .with_hmac_min_out_len(160)
435            .with_insecure(true);
436        match verify(&ctx, xml) {
437            Ok(VerifyResult::Valid {
438                signature_node: _,
439                references,
440                ..
441            }) => {
442                let targets = verified_targets(&references)?;
443                return Ok((true, verified_content(root, xml, &targets)?));
444            }
445            Ok(VerifyResult::Invalid { .. }) => tried_invalid = true,
446            Err(e) => last_err = Some(SamlError::Crypto(e.to_string())),
447        }
448    }
449    if !have_key {
450        return Err(SamlError::NoTrustedCertificate);
451    }
452    // A clean "invalid" (key mismatch / tampered) is a non-error false; only
453    // surface a structural error when no certificate produced a verdict.
454    match last_err {
455        Some(err) if !tried_invalid => Err(err),
456        _ => Ok((false, None)),
457    }
458}
459
460#[derive(Debug, Clone, PartialEq, Eq)]
461pub(crate) struct SignatureVerification {
462    verified: bool,
463    signed_content: Option<String>,
464    assertion_directly_covered: bool,
465}
466
467impl SignatureVerification {
468    pub(crate) fn verified(&self) -> bool {
469        self.verified
470    }
471
472    pub(crate) fn assertion_directly_covered(&self) -> bool {
473        self.assertion_directly_covered
474    }
475
476    pub(crate) fn into_signed_content(self) -> Option<String> {
477        self.signed_content
478    }
479}
480
481pub(crate) fn verify_signatures_detailed_with_limits(
482    xml: &str,
483    metadata_certs: &[String],
484    limits: XmlLimits,
485) -> Result<SignatureVerification, SamlError> {
486    let doc = dom::parse_with_limits(xml, limits)?;
487    let root = &doc.root;
488
489    if root.local_name.contains("Response") && wrapping_detected(root) {
490        return Err(SamlError::PotentialWrappingAttack);
491    }
492
493    let mut seen_ids = HashSet::new();
494    if duplicate_saml_id(root, &mut seen_ids).is_some() {
495        return Err(SamlError::PotentialWrappingAttack);
496    }
497
498    let signature_candidates = saml_signature_candidates(root);
499    if signature_candidates.is_empty() {
500        return Ok(SignatureVerification {
501            verified: false,
502            signed_content: None,
503            assertion_directly_covered: false,
504        });
505    }
506    preflight_saml_reference_uris(&signature_candidates)?;
507
508    if let Some(inline) = inline_signature_cert(&signature_candidates) {
509        let inline = normalize_cert_string(&inline);
510        if !metadata_certs.is_empty()
511            && !metadata_certs
512                .iter()
513                .any(|c| normalize_cert_string(c) == inline)
514        {
515            return Err(SamlError::CertificateMismatch);
516        }
517    }
518
519    let mut have_key = false;
520    let mut tried_invalid = false;
521    let mut last_err: Option<SamlError> = None;
522    let mut first_signature_verified = false;
523    let mut targets = Vec::new();
524    for cert in metadata_certs {
525        let key = match load_certificate(cert) {
526            Ok(key) => key,
527            Err(_) => continue,
528        };
529        have_key = true;
530        let mut manager = KeysManager::new();
531        manager.add_key(key);
532        let ctx = DsigContext::new(manager)
533            .with_trusted_keys_only(true)
534            .with_strict_verification(true)
535            .with_hmac_min_out_len(160)
536            .with_insecure(true);
537        match verify_all(&ctx, xml) {
538            Ok(results) => {
539                first_signature_verified |=
540                    matches!(results.first(), Some(VerifyResult::Valid { .. }));
541                for result in results {
542                    match result {
543                        VerifyResult::Valid {
544                            signature_node: _,
545                            references,
546                            ..
547                        } => targets.extend(verified_targets(&references)?),
548                        VerifyResult::Invalid { .. } => tried_invalid = true,
549                    }
550                }
551            }
552            Err(error) => last_err = Some(SamlError::Crypto(error.to_string())),
553        }
554    }
555    if !have_key {
556        return Err(SamlError::NoTrustedCertificate);
557    }
558    if first_signature_verified && !targets.is_empty() {
559        let assertion_directly_covered = assertion_is_directly_covered(root, &targets);
560        return Ok(SignatureVerification {
561            verified: true,
562            signed_content: verified_content(root, xml, &targets)?,
563            assertion_directly_covered,
564        });
565    }
566    match last_err {
567        Some(error) if !tried_invalid => Err(error),
568        _ => Ok(SignatureVerification {
569            verified: false,
570            signed_content: None,
571            assertion_directly_covered: false,
572        }),
573    }
574}
575
576/// Detailed metadata signature verification result.
577#[derive(Debug, Clone, PartialEq, Eq)]
578pub struct MetadataSignatureVerification {
579    verified: bool,
580    signed_entity_descriptor_xml: Option<String>,
581}
582
583impl MetadataSignatureVerification {
584    pub(crate) fn from_signed_descriptor(signed_entity_descriptor_xml: String) -> Self {
585        Self {
586            verified: true,
587            signed_entity_descriptor_xml: Some(signed_entity_descriptor_xml),
588        }
589    }
590
591    pub(crate) fn unverified() -> Self {
592        Self {
593            verified: false,
594            signed_entity_descriptor_xml: None,
595        }
596    }
597
598    /// Whether a metadata signature verified against the pinned certificates.
599    pub fn verified(&self) -> bool {
600        self.verified
601    }
602
603    /// The signed `<EntityDescriptor>` XML when verification succeeds.
604    pub fn signed_entity_descriptor_xml(&self) -> Option<&str> {
605        self.signed_entity_descriptor_xml.as_deref()
606    }
607
608    pub(crate) fn into_signed_entity_descriptor_xml(self) -> Option<String> {
609        self.signed_entity_descriptor_xml
610    }
611}
612
613/// Verify the enveloped XML-DSig signature on a metadata document against
614/// trusted certificate(s); returns whether it is valid and covers the consumed
615/// `<EntityDescriptor>` document.
616///
617/// # Errors
618///
619/// Returns [`SamlError`] when XML parsing, certificate loading, cryptographic
620/// verification, or signed `<EntityDescriptor>` coverage checks fail.
621pub fn verify_metadata_signature(
622    xml: &str,
623    trusted_certificates: &[String],
624) -> Result<bool, SamlError> {
625    verify_metadata_signature_with_limits(xml, trusted_certificates, XmlLimits::default())
626}
627
628/// Verify a metadata XML-DSig signature with explicit XML parser limits.
629///
630/// # Errors
631///
632/// Returns [`SamlError`] when XML parsing, certificate loading, cryptographic
633/// verification, or signed `<EntityDescriptor>` coverage checks fail.
634pub fn verify_metadata_signature_with_limits(
635    xml: &str,
636    trusted_certificates: &[String],
637    limits: XmlLimits,
638) -> Result<bool, SamlError> {
639    Ok(
640        verify_metadata_signature_detailed_with_limits(xml, trusted_certificates, limits)?
641            .verified(),
642    )
643}
644
645/// Verify a metadata XML-DSig signature and preserve signed descriptor coverage
646/// using default XML parser limits.
647///
648/// # Errors
649///
650/// Returns [`SamlError`] when XML parsing, certificate loading, cryptographic
651/// verification, transform policy, or signed `<EntityDescriptor>` coverage
652/// checks fail.
653pub fn verify_metadata_signature_detailed(
654    xml: &str,
655    trusted_certificates: &[String],
656) -> Result<MetadataSignatureVerification, SamlError> {
657    verify_metadata_signature_detailed_with_limits(xml, trusted_certificates, XmlLimits::default())
658}
659
660/// Verify a metadata XML-DSig signature and preserve signed descriptor coverage.
661///
662/// # Errors
663///
664/// Returns [`SamlError`] when XML parsing, certificate loading, cryptographic
665/// verification, transform policy, or signed `<EntityDescriptor>` coverage
666/// checks fail.
667pub fn verify_metadata_signature_detailed_with_limits(
668    xml: &str,
669    trusted_certificates: &[String],
670    limits: XmlLimits,
671) -> Result<MetadataSignatureVerification, SamlError> {
672    let doc = dom::parse_with_limits(xml, limits)?;
673    ensure_metadata_signature_transforms_preserve_descriptor(&doc.root)?;
674
675    let (verified, signed_entity_descriptor_xml) =
676        verify_signature_with_limits(xml, trusted_certificates, limits)?;
677    if !verified {
678        return Ok(MetadataSignatureVerification::unverified());
679    }
680    signed_entity_descriptor_xml
681        .map(MetadataSignatureVerification::from_signed_descriptor)
682        .ok_or_else(verified_content_not_covered)
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688    use crate::constants::signature_algorithm::RSA_SHA256;
689    use crate::constants::{digest_for_signature, namespace, transform_algorithm};
690    use crate::crypto::construct_saml_signature;
691    use crate::crypto::keys::load_private_key;
692    use crate::util::normalize_cert_string;
693    use crate::xml::{extract, ExtractorField};
694    use bergshamra::sign;
695
696    #[test]
697    fn external_reference_detection() {
698        assert!(!is_external_reference("")); // whole document
699        assert!(!is_external_reference("#_assertion123")); // same-document
700        assert!(is_external_reference("https://evil.example.com/x"));
701        assert!(is_external_reference("/etc/passwd"));
702        assert!(is_external_reference("file:///etc/passwd"));
703        assert!(is_external_reference("cid:attachment"));
704    }
705
706    #[test]
707    fn metadata_signature_transform_allowlist_preserves_canonicalization_interoperability() {
708        const XPATH_TRANSFORM: &str = "http://www.w3.org/TR/1999/REC-xpath-19991116";
709        const XSLT_TRANSFORM: &str = "http://www.w3.org/TR/1999/REC-xslt-19991116";
710        const UNKNOWN_TRANSFORM: &str = "urn:example:unknown-transform";
711
712        for algorithm in [
713            transform_algorithm::ENVELOPED_SIGNATURE,
714            transform_algorithm::EXC_C14N,
715            EXC_C14N_WITH_COMMENTS,
716            XML_C14N_10,
717            XML_C14N_10_WITH_COMMENTS,
718            XML_C14N_11,
719            XML_C14N_11_WITH_COMMENTS,
720        ] {
721            assert!(
722                metadata_signature_transform_allowed(algorithm),
723                "{algorithm}"
724            );
725        }
726
727        for algorithm in [XPATH_TRANSFORM, XSLT_TRANSFORM, UNKNOWN_TRANSFORM] {
728            assert!(
729                !metadata_signature_transform_allowed(algorithm),
730                "{algorithm}"
731            );
732        }
733    }
734
735    #[test]
736    fn same_document_reference_target_parsing() -> Result<(), Box<dyn std::error::Error>> {
737        assert_eq!(verified_target_from_uri("")?, VerifiedTarget::WholeDocument);
738        assert_eq!(
739            verified_target_from_uri("#_assertion123")?,
740            VerifiedTarget::Id("_assertion123".to_string())
741        );
742        assert_eq!(
743            verified_target_from_uri("#xpointer(/)")?,
744            VerifiedTarget::WholeDocument
745        );
746        assert_eq!(
747            verified_target_from_uri("#xpointer(id('_assertion123'))")?,
748            VerifiedTarget::Id("_assertion123".to_string())
749        );
750        Ok(())
751    }
752
753    #[test]
754    fn unsupported_reference_target_parsing_fails() {
755        assert!(matches!(
756            verified_target_from_uri("#"),
757            Err(SamlError::ReferenceResolution {
758                reason: ReferenceResolutionReason::UnsupportedReferenceUri
759            })
760        ));
761        assert!(matches!(
762            verified_target_from_uri("#xpointer(//saml:Assertion)"),
763            Err(SamlError::ReferenceResolution {
764                reason: ReferenceResolutionReason::UnsupportedReferenceUri
765            })
766        ));
767    }
768
769    #[test]
770    fn duplicate_saml_id_allows_unique_ids() -> Result<(), Box<dyn std::error::Error>> {
771        let doc = dom::parse(
772            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>"#,
773        )?;
774        let mut seen = HashSet::new();
775        assert_eq!(duplicate_saml_id(&doc.root, &mut seen), None);
776        Ok(())
777    }
778
779    #[test]
780    fn duplicate_saml_id_returns_repeated_value() -> Result<(), Box<dyn std::error::Error>> {
781        let doc = dom::parse(
782            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>"#,
783        )?;
784        let mut seen = HashSet::new();
785        assert_eq!(
786            duplicate_saml_id(&doc.root, &mut seen),
787            Some("_same".to_string())
788        );
789        Ok(())
790    }
791
792    #[test]
793    fn duplicate_saml_id_ignores_empty_values() -> Result<(), Box<dyn std::error::Error>> {
794        let doc = dom::parse(
795            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>"#,
796        )?;
797        let mut seen = HashSet::new();
798        assert_eq!(duplicate_saml_id(&doc.root, &mut seen), None);
799        Ok(())
800    }
801
802    const RESPONSE_SIGNED: &str = include_str!("../../tests/fixtures/response_signed.xml");
803    const SIGNED_REQUEST: &str = include_str!("../../tests/fixtures/signed_request_sha256.xml");
804    const ATTACK: &str = include_str!("../../tests/fixtures/attack_response_signed.xml");
805    const FALSE_SIGNED: &str = include_str!("../../tests/fixtures/false_signed_request_sha256.xml");
806    const RESPONSE: &str = include_str!("../../tests/fixtures/response.xml");
807    const SP_PRIVKEY: &str = include_str!("../../tests/fixtures/key/sp_privkey.pem");
808    // IdP signing cert (matches the response_signed.xml signer / idpmeta).
809    const IDP_CERT: &str = include_str!("../../tests/fixtures/key/idp_cert.cer");
810    // SP signing cert (matches signed_request_sha256.xml signer).
811    const SP_CERT: &str = include_str!("../../tests/fixtures/key/sp_cert.cer");
812    const SP_SIGNING_CERT: &str = include_str!("../../tests/fixtures/key/sp_signing_cert.cer");
813
814    fn signed_response_with_foreign_extension_certificate(
815    ) -> Result<String, Box<dyn std::error::Error>> {
816        let response = RESPONSE.replacen(
817            "<samlp:Status>",
818            r#"<samlp:Extensions><x:Signature xmlns:x="urn:example:extension"><x:X509Certificate>attacker</x:X509Certificate></x:Signature></samlp:Extensions><samlp:Status>"#,
819            1,
820        );
821        let key = load_private_key(SP_PRIVKEY, None)?;
822        Ok(construct_saml_signature(
823            &response,
824            false,
825            &key,
826            SP_SIGNING_CERT,
827            RSA_SHA256,
828            &[],
829            None,
830        )?)
831    }
832
833    fn response_with_first_invalid_signature() -> Result<String, Box<dyn std::error::Error>> {
834        let cert = normalize_cert_string(IDP_CERT);
835        let digest = digest_for_signature(RSA_SHA256).ok_or("unknown digest")?;
836        let invalid_signature = format!(
837            "<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>",
838            dsig = namespace::DSIG,
839            exc_c14n = transform_algorithm::EXC_C14N,
840            sig_alg = RSA_SHA256,
841        );
842        Ok(RESPONSE_SIGNED.replacen(
843            "<samlp:Status>",
844            &format!("{invalid_signature}<samlp:Status>"),
845            1,
846        ))
847    }
848
849    fn cid_reference_response() -> Result<String, Box<dyn std::error::Error>> {
850        let cert = normalize_cert_string(SP_SIGNING_CERT);
851        let signature = format!(
852            "<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>",
853            dsig = namespace::DSIG,
854            exc_c14n = transform_algorithm::EXC_C14N,
855            sig_alg = RSA_SHA256,
856            digest = digest_for_signature(RSA_SHA256).ok_or("unknown digest")?,
857        );
858        let template =
859            RESPONSE.replacen("<samlp:Status>", &format!("{signature}<samlp:Status>"), 1);
860        let key = load_private_key(SP_PRIVKEY, None)?;
861        let mut manager = KeysManager::new();
862        manager.add_key(key);
863        let ctx = DsigContext::new(manager).with_insecure(true);
864        Ok(sign(&ctx, &template)?)
865    }
866
867    fn assert_reference_resolution(
868        result: Result<(bool, Option<String>), SamlError>,
869        expected: ReferenceResolutionReason,
870    ) -> Result<(), Box<dyn std::error::Error>> {
871        match result {
872            Err(SamlError::ReferenceResolution { reason }) if reason == expected => Ok(()),
873            other => Err(format!("expected reference resolution {expected}, got {other:?}").into()),
874        }
875    }
876
877    #[test]
878    fn dsig_context_secure_defaults_survive_insecure_builder() {
879        let ctx = DsigContext::new(KeysManager::new());
880        assert!(ctx.trusted_keys_only);
881        assert!(ctx.strict_verification);
882        assert_eq!(ctx.hmac_min_out_len, 160);
883        assert!(!ctx.insecure);
884
885        let insecure = ctx.with_insecure(true);
886        assert!(insecure.insecure);
887        assert!(insecure.trusted_keys_only);
888        assert!(insecure.strict_verification);
889        assert_eq!(insecure.hmac_min_out_len, 160);
890    }
891
892    #[test]
893    fn verifies_signed_response_with_metadata_cert() -> Result<(), Box<dyn std::error::Error>> {
894        let (verified, content) = verify_signature(RESPONSE_SIGNED, &[IDP_CERT.to_string()])?;
895        assert!(
896            verified,
897            "response_signed.xml should verify with the IdP cert"
898        );
899        assert!(content
900            .ok_or("expected signed assertion")?
901            .contains("Assertion"));
902        Ok(())
903    }
904
905    #[test]
906    fn verifies_generated_same_document_signature() -> Result<(), Box<dyn std::error::Error>> {
907        let key = load_private_key(SP_PRIVKEY, None)?;
908        let signed = construct_saml_signature(
909            RESPONSE,
910            false,
911            &key,
912            SP_SIGNING_CERT,
913            RSA_SHA256,
914            &[],
915            None,
916        )?;
917        let (verified, content) = verify_signature(&signed, &[SP_SIGNING_CERT.to_string()])?;
918        assert!(verified);
919        assert!(content
920            .ok_or("expected signed assertion")?
921            .contains("Assertion"));
922        Ok(())
923    }
924
925    #[test]
926    fn foreign_extension_certificate_is_not_treated_as_signature_key_info(
927    ) -> Result<(), Box<dyn std::error::Error>> {
928        let signed = signed_response_with_foreign_extension_certificate()?;
929        let (verified, content) = verify_signature(&signed, &[SP_SIGNING_CERT.to_string()])?;
930        assert!(verified);
931        assert!(content
932            .ok_or("expected signed assertion")?
933            .contains("Assertion"));
934        Ok(())
935    }
936
937    #[test]
938    fn detailed_verification_ignores_foreign_extension_certificate(
939    ) -> Result<(), Box<dyn std::error::Error>> {
940        let signed = signed_response_with_foreign_extension_certificate()?;
941        let result = verify_signatures_detailed_with_limits(
942            &signed,
943            &[SP_SIGNING_CERT.to_string()],
944            XmlLimits::default(),
945        )?;
946        assert!(result.verified() && result.assertion_directly_covered());
947        Ok(())
948    }
949
950    #[test]
951    fn inline_certificate_is_not_used_without_metadata_pin(
952    ) -> Result<(), Box<dyn std::error::Error>> {
953        match verify_signature(RESPONSE_SIGNED, &[]) {
954            Err(SamlError::NoTrustedCertificate) => Ok(()),
955            other => Err(format!("expected missing pinned certificate, got {other:?}").into()),
956        }
957    }
958
959    #[test]
960    fn first_invalid_signature_prevents_later_valid_signature_from_authorizing_response(
961    ) -> Result<(), Box<dyn std::error::Error>> {
962        let result = verify_signature(
963            &response_with_first_invalid_signature()?,
964            &[IDP_CERT.to_string()],
965        )?;
966        assert_eq!(result, (false, None));
967        Ok(())
968    }
969
970    #[test]
971    fn detailed_verification_rejects_invalid_first_signature_before_later_assertion_coverage(
972    ) -> Result<(), Box<dyn std::error::Error>> {
973        let result = verify_signatures_detailed_with_limits(
974            &response_with_first_invalid_signature()?,
975            &[IDP_CERT.to_string()],
976            XmlLimits::default(),
977        )?;
978        assert!(!result.verified());
979        Ok(())
980    }
981
982    #[test]
983    fn detailed_verification_aggregates_rolling_cert_coverage_after_first_signature_verifies(
984    ) -> Result<(), Box<dyn std::error::Error>> {
985        let response_key = load_private_key(SP_PRIVKEY, None)?;
986        let signed_response_and_assertion = construct_saml_signature(
987            RESPONSE_SIGNED,
988            true,
989            &response_key,
990            SP_SIGNING_CERT,
991            RSA_SHA256,
992            &[],
993            None,
994        )?;
995        let result = verify_signatures_detailed_with_limits(
996            &signed_response_and_assertion,
997            &[SP_SIGNING_CERT.to_string(), IDP_CERT.to_string()],
998            XmlLimits::default(),
999        )?;
1000        assert!(result.verified() && result.assertion_directly_covered());
1001        Ok(())
1002    }
1003
1004    #[test]
1005    fn signed_cid_reference_is_rejected_before_content_extraction(
1006    ) -> Result<(), Box<dyn std::error::Error>> {
1007        assert_reference_resolution(
1008            verify_signature(&cid_reference_response()?, &[SP_SIGNING_CERT.to_string()]),
1009            ReferenceResolutionReason::ExternalReference,
1010        )
1011    }
1012
1013    #[test]
1014    fn rejects_signed_request_without_root_coverage() -> Result<(), Box<dyn std::error::Error>> {
1015        match verify_signature(SIGNED_REQUEST, &[SP_CERT.to_string()]) {
1016            Err(SamlError::SignedReferenceMismatch) => Ok(()),
1017            other => {
1018                Err(format!("expected uncovered AuthnRequest rejection, got {other:?}").into())
1019            }
1020        }
1021    }
1022
1023    #[test]
1024    fn rejects_wrong_certificate() -> Result<(), Box<dyn std::error::Error>> {
1025        // RESPONSE_SIGNED embeds the IdP cert; verifying against the SP cert
1026        // trips the inline-vs-metadata mismatch guard.
1027        match verify_signature(RESPONSE_SIGNED, &[SP_CERT.to_string()]) {
1028            Err(SamlError::CertificateMismatch) => Ok(()),
1029            other => Err(format!("expected CertificateMismatch, got {other:?}").into()),
1030        }
1031    }
1032
1033    #[test]
1034    fn rejects_tampered_signature() -> Result<(), Box<dyn std::error::Error>> {
1035        // false_signed_request_sha256.xml: signature present but content tampered
1036        let (verified, _) = verify_signature(FALSE_SIGNED, &[SP_CERT.to_string()])?;
1037        assert!(!verified, "tampered message must not verify");
1038        Ok(())
1039    }
1040
1041    #[test]
1042    fn rejects_multi_root_wrapping_attack_before_signature_verification(
1043    ) -> Result<(), Box<dyn std::error::Error>> {
1044        // attack_response_signed.xml places a forged NameID before the signed
1045        // response. Reject the multi-root document before signature processing.
1046        match verify_signature(ATTACK, &[IDP_CERT.to_string()]) {
1047            Err(SamlError::Xml(message)) if message == "multiple document elements" => Ok(()),
1048            other => Err(format!("expected multi-root XML rejection, got {other:?}").into()),
1049        }
1050    }
1051
1052    #[test]
1053    fn no_signature_returns_false() -> Result<(), Box<dyn std::error::Error>> {
1054        // a document without any Signature element verifies to (false, None)
1055        let (verified, content) = verify_signature("<samlp:Response>x</samlp:Response>", &[])?;
1056        assert!(!verified);
1057        assert!(content.is_none());
1058        // keep the extractor import exercised
1059        let _ = extract("<a/>", &[ExtractorField::new("x", &["a"])])?;
1060        Ok(())
1061    }
1062}