1use 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
46fn 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
238fn 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
294fn 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
324fn 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
339pub 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
357pub 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 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 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 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 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 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#[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 pub fn verified(&self) -> bool {
600 self.verified
601 }
602
603 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
613pub 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
628pub 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
645pub 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
660pub 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("")); assert!(!is_external_reference("#_assertion123")); 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 const IDP_CERT: &str = include_str!("../../tests/fixtures/key/idp_cert.cer");
810 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 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 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 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 let (verified, content) = verify_signature("<samlp:Response>x</samlp:Response>", &[])?;
1056 assert!(!verified);
1057 assert!(content.is_none());
1058 let _ = extract("<a/>", &[ExtractorField::new("x", &["a"])])?;
1060 Ok(())
1061 }
1062}