diff --git a/Cargo.toml b/Cargo.toml index d1d2bb8..e918b8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ readme = "README.md" [dependencies] # XML parsing roxmltree = { version = "0.21", features = ["positions"] } +quick-xml = "0.38" # Crypto rsa = { version = "0.9", optional = true } @@ -38,7 +39,7 @@ thiserror = "2" [dev-dependencies] rcgen = "0.14.6" -time = "0.3" +time = "0.3.53" [features] default = ["xmldsig", "c14n"] diff --git a/README.md b/README.md index 2c415af..a1157c1 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Every SAML, SOAP, and WS-Security implementation depends on libxmlsec1 — a C l - Has decades of CVEs in XML parsing and signature validation - Cannot cross-compile easily -`xml-sec` is a ground-up Rust rewrite using `roxmltree` + `ring` + `x509-parser`. Single `cargo build`, works everywhere Rust works. +`xml-sec` is a ground-up Rust rewrite using `roxmltree` for parsing, `quick-xml` for writing, RustCrypto for RSA/ECDSA/SHA, and `x509-parser` for certificates. Single `cargo build`, works everywhere Rust works. ## Status diff --git a/src/xmldsig/builder.rs b/src/xmldsig/builder.rs new file mode 100644 index 0000000..fa7d3ac --- /dev/null +++ b/src/xmldsig/builder.rs @@ -0,0 +1,399 @@ +//! Builders for deterministic XMLDSig signature templates. + +use std::io::Write; + +use quick_xml::Writer; +use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event}; + +use crate::c14n::{C14nAlgorithm, C14nMode}; + +use super::{ + DigestAlgorithm, ENVELOPED_SIGNATURE_URI, SignatureAlgorithm, Transform, XPATH_TRANSFORM_URI, +}; + +const XMLDSIG_NS: &str = "http://www.w3.org/2000/09/xmldsig#"; +const EXCLUSIVE_C14N_NS: &str = "http://www.w3.org/2001/10/xml-exc-c14n#"; +const XPATH_EXCLUDE_ALL_SIGNATURES: &str = "not(ancestor-or-self::dsig:Signature)"; + +/// Errors produced while validating or serializing an XMLDSig template. +#[derive(Debug, thiserror::Error)] +pub enum SignatureBuilderError { + /// A namespace prefix was not a supported XML NCName. + #[error("invalid XML namespace prefix: {0}")] + InvalidNamespacePrefix(String), + /// An XMLDSig Id attribute was not a valid XML NCName. + #[error("invalid {element} Id: {value}")] + InvalidId { + /// XMLDSig element carrying the Id attribute. + element: &'static str, + /// Rejected attribute value. + value: String, + }, + /// XMLDSig requires at least one reference in SignedInfo. + #[error("a signature template requires at least one Reference")] + MissingReference, + /// SHA-1 algorithms are available for verification but not new signatures. + #[error("algorithm is not allowed for signing: {0}")] + SigningAlgorithmDisabled(&'static str), + /// The XML writer failed. + #[error("XML serialization error: {0}")] + Serialization(#[from] std::io::Error), + /// The writer unexpectedly emitted bytes that are not UTF-8. + #[error("XML writer emitted invalid UTF-8: {0}")] + InvalidUtf8(#[from] std::string::FromUtf8Error), +} + +/// Builder for a single XMLDSig `` template. +#[derive(Debug, Clone)] +pub struct ReferenceBuilder { + uri: Option, + id: Option, + ref_type: Option, + transforms: Vec, + digest_method: DigestAlgorithm, +} + +impl ReferenceBuilder { + /// Create a reference using the required digest algorithm. + #[must_use] + pub fn new(digest_method: DigestAlgorithm) -> Self { + Self { + uri: None, + id: None, + ref_type: None, + transforms: Vec::new(), + digest_method, + } + } + + /// Set the optional reference URI. + #[must_use] + pub fn uri(mut self, uri: impl Into) -> Self { + self.uri = Some(uri.into()); + self + } + + /// Set the optional reference Id. + #[must_use] + pub fn id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); + self + } + + /// Set the optional reference Type URI. + #[must_use] + pub fn ref_type(mut self, ref_type: impl Into) -> Self { + self.ref_type = Some(ref_type.into()); + self + } + + /// Append a transform, preserving insertion order. + #[must_use] + pub fn transform(mut self, transform: Transform) -> Self { + self.transforms.push(transform); + self + } +} + +/// Builder for a complete XMLDSig `` template. +#[derive(Debug, Clone)] +pub struct SignatureBuilder { + c14n_method: C14nAlgorithm, + sign_method: SignatureAlgorithm, + ns_prefix: Option, + signature_id: Option, + references: Vec, + include_key_info: bool, +} + +impl SignatureBuilder { + /// Create a signature template using the required algorithms. + #[must_use] + pub fn new(c14n_method: C14nAlgorithm, sign_method: SignatureAlgorithm) -> Self { + Self { + c14n_method, + sign_method, + ns_prefix: None, + signature_id: None, + references: Vec::new(), + include_key_info: false, + } + } + + /// Use a namespace prefix such as `ds`; the default is an unprefixed namespace. + #[must_use] + pub fn ns_prefix(mut self, prefix: impl Into) -> Self { + self.ns_prefix = Some(prefix.into()); + self + } + + /// Set the optional Signature Id. + #[must_use] + pub fn signature_id(mut self, id: impl Into) -> Self { + self.signature_id = Some(id.into()); + self + } + + /// Append a reference, preserving insertion order. + #[must_use] + pub fn add_reference(mut self, reference: ReferenceBuilder) -> Self { + self.references.push(reference); + self + } + + /// Control whether an empty KeyInfo placeholder is emitted. + #[must_use] + pub fn key_info(mut self, include: bool) -> Self { + self.include_key_info = include; + self + } + + /// Build a namespace-correct XMLDSig template with empty digest and signature values. + pub fn build_template(&self) -> Result { + self.validate()?; + + let prefix = self.ns_prefix.as_deref(); + let mut writer = Writer::new(Vec::new()); + let signature_name = qualified_name(prefix, "Signature"); + let mut signature = BytesStart::new(&signature_name); + let namespace_attr = prefix.map_or_else(|| "xmlns".to_owned(), |p| format!("xmlns:{p}")); + signature.push_attribute((namespace_attr.as_str(), XMLDSIG_NS)); + if let Some(id) = &self.signature_id { + signature.push_attribute(("Id", id.as_str())); + } + writer.write_event(Event::Start(signature))?; + + write_start(&mut writer, prefix, "SignedInfo")?; + write_algorithm( + &mut writer, + prefix, + "CanonicalizationMethod", + self.c14n_method.uri(), + )?; + write_algorithm( + &mut writer, + prefix, + "SignatureMethod", + self.sign_method.uri(), + )?; + for reference in &self.references { + write_reference(&mut writer, prefix, reference)?; + } + write_end(&mut writer, prefix, "SignedInfo")?; + write_empty(&mut writer, prefix, "SignatureValue")?; + if self.include_key_info { + write_empty(&mut writer, prefix, "KeyInfo")?; + } + writer.write_event(Event::End(BytesEnd::new(signature_name)))?; + + Ok(String::from_utf8(writer.into_inner())?) + } + + fn validate(&self) -> Result<(), SignatureBuilderError> { + if let Some(prefix) = &self.ns_prefix + && !is_namespace_prefix(prefix) + { + return Err(SignatureBuilderError::InvalidNamespacePrefix( + prefix.clone(), + )); + } + if let Some(id) = &self.signature_id + && !is_ncname(id) + { + return Err(SignatureBuilderError::InvalidId { + element: "Signature", + value: id.clone(), + }); + } + if self.references.is_empty() { + return Err(SignatureBuilderError::MissingReference); + } + if !self.sign_method.signing_allowed() { + return Err(SignatureBuilderError::SigningAlgorithmDisabled( + self.sign_method.uri(), + )); + } + for reference in &self.references { + if let Some(id) = &reference.id + && !is_ncname(id) + { + return Err(SignatureBuilderError::InvalidId { + element: "Reference", + value: id.clone(), + }); + } + if !reference.digest_method.signing_allowed() { + return Err(SignatureBuilderError::SigningAlgorithmDisabled( + reference.digest_method.uri(), + )); + } + } + Ok(()) + } +} + +fn write_reference( + writer: &mut Writer, + prefix: Option<&str>, + reference: &ReferenceBuilder, +) -> Result<(), std::io::Error> { + let name = qualified_name(prefix, "Reference"); + let mut element = BytesStart::new(&name); + if let Some(id) = &reference.id { + element.push_attribute(("Id", id.as_str())); + } + if let Some(ref_type) = &reference.ref_type { + element.push_attribute(("Type", ref_type.as_str())); + } + if let Some(uri) = &reference.uri { + element.push_attribute(("URI", uri.as_str())); + } + writer.write_event(Event::Start(element))?; + + if !reference.transforms.is_empty() { + write_start(writer, prefix, "Transforms")?; + for transform in &reference.transforms { + write_transform(writer, prefix, transform)?; + } + write_end(writer, prefix, "Transforms")?; + } + write_algorithm( + writer, + prefix, + "DigestMethod", + reference.digest_method.uri(), + )?; + write_empty(writer, prefix, "DigestValue")?; + writer.write_event(Event::End(BytesEnd::new(name)))?; + Ok(()) +} + +fn write_transform( + writer: &mut Writer, + prefix: Option<&str>, + transform: &Transform, +) -> Result<(), std::io::Error> { + match transform { + Transform::Enveloped => { + write_algorithm(writer, prefix, "Transform", ENVELOPED_SIGNATURE_URI) + } + Transform::XpathExcludeAllSignatures => { + let name = qualified_name(prefix, "Transform"); + let mut element = BytesStart::new(&name); + element.push_attribute(("Algorithm", XPATH_TRANSFORM_URI)); + writer.write_event(Event::Start(element))?; + let xpath_name = qualified_name(prefix, "XPath"); + let mut xpath = BytesStart::new(&xpath_name); + xpath.push_attribute(("xmlns:dsig", XMLDSIG_NS)); + writer.write_event(Event::Start(xpath))?; + writer.write_event(Event::Text(BytesText::new(XPATH_EXCLUDE_ALL_SIGNATURES)))?; + writer.write_event(Event::End(BytesEnd::new(xpath_name)))?; + writer.write_event(Event::End(BytesEnd::new(name)))?; + Ok(()) + } + Transform::C14n(algorithm) if algorithm.inclusive_prefixes().is_empty() => { + write_algorithm(writer, prefix, "Transform", algorithm.uri()) + } + Transform::C14n(algorithm) => { + let name = qualified_name(prefix, "Transform"); + let mut element = BytesStart::new(&name); + element.push_attribute(("Algorithm", algorithm.uri())); + writer.write_event(Event::Start(element))?; + + if algorithm.mode() == C14nMode::Exclusive1_0 { + let mut prefixes: Vec<&str> = algorithm + .inclusive_prefixes() + .iter() + .map(String::as_str) + .collect(); + prefixes.sort_unstable(); + let prefix_list = prefixes + .into_iter() + .map(|p| if p.is_empty() { "#default" } else { p }) + .collect::>() + .join(" "); + let mut inclusive = BytesStart::new("ec:InclusiveNamespaces"); + inclusive.push_attribute(("xmlns:ec", EXCLUSIVE_C14N_NS)); + inclusive.push_attribute(("PrefixList", prefix_list.as_str())); + writer.write_event(Event::Empty(inclusive))?; + } + writer.write_event(Event::End(BytesEnd::new(name)))?; + Ok(()) + } + } +} + +fn write_algorithm( + writer: &mut Writer, + prefix: Option<&str>, + local_name: &str, + algorithm: &str, +) -> Result<(), std::io::Error> { + let name = qualified_name(prefix, local_name); + let mut element = BytesStart::new(name); + element.push_attribute(("Algorithm", algorithm)); + writer.write_event(Event::Empty(element))?; + Ok(()) +} + +fn write_start( + writer: &mut Writer, + prefix: Option<&str>, + local_name: &str, +) -> Result<(), std::io::Error> { + writer.write_event(Event::Start(BytesStart::new(qualified_name( + prefix, local_name, + ))))?; + Ok(()) +} + +fn write_end( + writer: &mut Writer, + prefix: Option<&str>, + local_name: &str, +) -> Result<(), std::io::Error> { + writer.write_event(Event::End(BytesEnd::new(qualified_name( + prefix, local_name, + ))))?; + Ok(()) +} + +fn write_empty( + writer: &mut Writer, + prefix: Option<&str>, + local_name: &str, +) -> Result<(), std::io::Error> { + writer.write_event(Event::Empty(BytesStart::new(qualified_name( + prefix, local_name, + ))))?; + Ok(()) +} + +fn qualified_name(prefix: Option<&str>, local_name: &str) -> String { + prefix.map_or_else( + || local_name.to_owned(), + |prefix| format!("{prefix}:{local_name}"), + ) +} + +fn is_ncname(value: &str) -> bool { + if value.is_empty() || value.contains(':') { + return false; + } + + roxmltree::Document::parse(&format!("<{value}/>")) + .is_ok_and(|document| document.root_element().tag_name().name() == value) +} + +fn is_namespace_prefix(value: &str) -> bool { + if !is_ncname(value) { + return false; + } + + // Parsing delegates the complete Unicode XML Name grammar and reserved-prefix + // rules to the same parser used by the rest of the crate. + roxmltree::Document::parse(&format!( + "<{value}:n xmlns:{value}=\"urn:xml-sec:prefix-validation\"/>" + )) + .is_ok() +} diff --git a/src/xmldsig/mod.rs b/src/xmldsig/mod.rs index b12ca9c..9a0a9e2 100644 --- a/src/xmldsig/mod.rs +++ b/src/xmldsig/mod.rs @@ -8,6 +8,7 @@ //! - ID attribute resolution with configurable attribute names //! - Node set types for the transform pipeline +pub mod builder; pub mod digest; pub mod keys; pub mod parse; @@ -19,6 +20,7 @@ pub mod verify; pub(crate) mod whitespace; pub mod x509; +pub use builder::{ReferenceBuilder, SignatureBuilder, SignatureBuilderError}; pub use digest::{DigestAlgorithm, compute_digest, constant_time_eq}; pub use keys::{DefaultKeyResolver, KeyResolutionError, KeyResolverConfig, VerificationKey}; pub use parse::{ diff --git a/tests/signature_builder.rs b/tests/signature_builder.rs new file mode 100644 index 0000000..82c08b3 --- /dev/null +++ b/tests/signature_builder.rs @@ -0,0 +1,214 @@ +use xml_sec::c14n::{C14nAlgorithm, C14nMode}; +use xml_sec::xmldsig::{ + DigestAlgorithm, ReferenceBuilder, SignatureAlgorithm, SignatureBuilder, SignatureBuilderError, + Transform, parse_transforms, +}; + +const DSIG_NS: &str = "http://www.w3.org/2000/09/xmldsig#"; + +fn exclusive_c14n() -> C14nAlgorithm { + C14nAlgorithm::new(C14nMode::Exclusive1_0, false) +} + +#[test] +fn builds_parseable_prefixed_template_in_required_order() { + // This guards the schema order consumed by xmlsec1 and our strict parser. + let xml = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::RsaSha256) + .ns_prefix("ds") + .signature_id("sig-1") + .add_reference( + ReferenceBuilder::new(DigestAlgorithm::Sha256) + .uri("#assertion&1") + .id("ref-1") + .ref_type("urn:test:kind") + .transform(Transform::Enveloped) + .transform(Transform::C14n(exclusive_c14n())), + ) + .key_info(true) + .build_template() + .expect("valid template"); + + let document = roxmltree::Document::parse(&xml).expect("builder must emit valid XML"); + let signature = document.root_element(); + assert_eq!(signature.tag_name().namespace(), Some(DSIG_NS)); + assert_eq!(signature.attribute("Id"), Some("sig-1")); + let children: Vec<_> = signature + .children() + .filter(roxmltree::Node::is_element) + .map(|node| node.tag_name().name()) + .collect(); + assert_eq!(children, ["SignedInfo", "SignatureValue", "KeyInfo"]); + + let signed_info = signature + .children() + .find(|node| node.has_tag_name((DSIG_NS, "SignedInfo"))) + .expect("SignedInfo"); + let reference = signed_info + .children() + .find(|node| node.has_tag_name((DSIG_NS, "Reference"))) + .expect("Reference"); + assert_eq!(reference.attribute("URI"), Some("#assertion&1")); + let reference_children: Vec<_> = reference + .children() + .filter(roxmltree::Node::is_element) + .map(|node| node.tag_name().name()) + .collect(); + assert_eq!( + reference_children, + ["Transforms", "DigestMethod", "DigestValue"] + ); + let transforms = reference + .children() + .find(|node| node.has_tag_name((DSIG_NS, "Transforms"))) + .expect("Transforms"); + assert_eq!( + parse_transforms(transforms) + .expect("valid transforms") + .len(), + 2 + ); + let digest_value = reference + .children() + .find(|node| node.has_tag_name((DSIG_NS, "DigestValue"))) + .expect("DigestValue"); + assert_eq!(digest_value.text(), None); +} + +#[test] +fn preserves_reference_order_and_default_namespace() { + // Reference order is signed data and must never be normalized or sorted. + let xml = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::EcdsaP256Sha256) + .add_reference(ReferenceBuilder::new(DigestAlgorithm::Sha384).uri("#first")) + .add_reference(ReferenceBuilder::new(DigestAlgorithm::Sha512).uri("#second")) + .build_template() + .expect("valid template"); + let document = roxmltree::Document::parse(&xml).expect("valid XML"); + let signature = document.root_element(); + assert_eq!(signature.lookup_namespace_uri(None), Some(DSIG_NS)); + let reference_uris: Vec<_> = signature + .first_element_child() + .expect("SignedInfo") + .children() + .filter(|node| node.has_tag_name((DSIG_NS, "Reference"))) + .map(|node| node.attribute("URI")) + .collect(); + assert_eq!(reference_uris, [Some("#first"), Some("#second")]); +} + +#[test] +fn rejects_incomplete_or_unsafe_signing_templates() { + // Builders fail before serialization rather than producing unusable templates. + let missing = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::RsaSha256) + .build_template() + .expect_err("Reference is mandatory"); + assert!(matches!(missing, SignatureBuilderError::MissingReference)); + + let invalid_prefix = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::RsaSha256) + .ns_prefix("bad:prefix") + .add_reference(ReferenceBuilder::new(DigestAlgorithm::Sha256)) + .build_template() + .expect_err("prefix must be an NCName"); + assert!(matches!( + invalid_prefix, + SignatureBuilderError::InvalidNamespacePrefix(_) + )); + + let sha1_signature = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::RsaSha1) + .add_reference(ReferenceBuilder::new(DigestAlgorithm::Sha256)) + .build_template() + .expect_err("SHA-1 signatures are verify-only"); + assert!(matches!( + sha1_signature, + SignatureBuilderError::SigningAlgorithmDisabled(_) + )); + + let sha1_digest = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::RsaSha256) + .add_reference(ReferenceBuilder::new(DigestAlgorithm::Sha1)) + .build_template() + .expect_err("SHA-1 digests are verify-only"); + assert!(matches!( + sha1_digest, + SignatureBuilderError::SigningAlgorithmDisabled(_) + )); +} + +#[test] +fn serializes_xpath_and_exclusive_prefix_list() { + // Complex transforms retain the child content required by their specifications. + let c14n = exclusive_c14n().with_prefix_list("saml #default ds"); + let xml = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::RsaSha256) + .ns_prefix("ds") + .add_reference( + ReferenceBuilder::new(DigestAlgorithm::Sha256) + .transform(Transform::XpathExcludeAllSignatures) + .transform(Transform::C14n(c14n)), + ) + .build_template() + .expect("valid template"); + let document = roxmltree::Document::parse(&xml).expect("valid XML"); + let xpath = document + .descendants() + .find(|node| node.has_tag_name((DSIG_NS, "XPath"))) + .expect("XPath child"); + assert_eq!(xpath.text(), Some("not(ancestor-or-self::dsig:Signature)")); + let transforms = xpath + .parent() + .and_then(|node| node.parent()) + .expect("Transforms"); + assert!(matches!( + parse_transforms(transforms).as_deref(), + Ok([Transform::XpathExcludeAllSignatures, Transform::C14n(_)]) + )); + let inclusive = document + .descendants() + .find(|node| node.tag_name().name() == "InclusiveNamespaces") + .expect("InclusiveNamespaces child"); + assert_eq!(inclusive.attribute("PrefixList"), Some("#default ds saml")); +} + +#[test] +fn accepts_unicode_xml_namespace_prefixes() { + // XML 1.0 NCNames permit Unicode letters; prefix validation must not be ASCII-only. + let xml = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::RsaSha256) + .ns_prefix("подпись") + .add_reference(ReferenceBuilder::new(DigestAlgorithm::Sha256)) + .build_template() + .expect("Unicode prefix is a valid XML NCName"); + let document = roxmltree::Document::parse(&xml).expect("valid XML"); + assert_eq!( + document.root_element().tag_name().namespace(), + Some(DSIG_NS) + ); +} + +#[test] +fn rejects_non_ncname_signature_and_reference_ids() { + // xsd:ID derives from NCName, so escaping an invalid value is not sufficient. + let signature_id = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::RsaSha256) + .signature_id("sig&1") + .add_reference(ReferenceBuilder::new(DigestAlgorithm::Sha256)) + .build_template() + .expect_err("Signature Id must be an NCName"); + assert!(signature_id.to_string().contains("Signature Id")); + + let reference_id = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::RsaSha256) + .add_reference(ReferenceBuilder::new(DigestAlgorithm::Sha256).id("ref:1")) + .build_template() + .expect_err("Reference Id must be an NCName"); + assert!(reference_id.to_string().contains("Reference Id")); + + let injected_signature_id = + SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::RsaSha256) + .signature_id("!--comment-->