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 super::provider::ensure_crypto_provider_initialized()?;
402
403 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 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 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#[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 pub fn verified(&self) -> bool {
627 self.verified
628 }
629
630 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
640pub 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
655pub 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
672pub 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
687pub 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("")); assert!(!is_external_reference("#_assertion123")); 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 const IDP_CERT: &str = include_str!("../../tests/fixtures/key/idp_cert.cer");
837 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 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 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 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 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 let (verified, content) = verify_signature("<samlp:Response>x</samlp:Response>", &[])?;
1145 assert!(!verified);
1146 assert!(content.is_none());
1147 let _ = extract("<a/>", &[ExtractorField::new("x", &["a"])])?;
1149 Ok(())
1150 }
1151}