From 8c59c9fd01906ce5f5bdc6abbd9b0e18af7a3abe Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 6 Jul 2026 10:05:02 +0300 Subject: [PATCH 1/6] build(deps): update RustCrypto dependencies --- Cargo.toml | 14 ++++++++------ src/xmldsig/keys.rs | 7 ++++--- src/xmldsig/signature.rs | 30 +++++++++++++----------------- 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e918b8f..8c02de2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,18 +18,19 @@ roxmltree = { version = "0.21", features = ["positions"] } quick-xml = "0.38" # Crypto -rsa = { version = "0.9", optional = true } -sha1 = { version = "0.10", features = ["oid"], optional = true } -sha2 = { version = "0.10", features = ["oid"], optional = true } -p256 = { version = "0.13", features = ["ecdsa"], optional = true } -p384 = { version = "0.13", features = ["ecdsa"], optional = true } -p521 = { version = "0.13", features = ["ecdsa"], optional = true } +rsa = { version = "0.10.0-rc.18", optional = true } +sha1 = { version = "0.11", features = ["oid"], optional = true } +sha2 = { version = "0.11", features = ["oid"], optional = true } +p256 = { version = "0.14", features = ["ecdsa"], optional = true } +p384 = { version = "0.14.0-rc.15", features = ["ecdsa"], optional = true } +p521 = { version = "0.14.0-rc.15", features = ["ecdsa"], optional = true } signature = { version = "3", optional = true } subtle = { version = "2", optional = true } # X.509 certificates x509-parser = { version = "0.18", features = ["verify"], optional = true } der = { version = "0.8", optional = true } +crypto-bigint = { version = "0.7", optional = true } # Base64 encoding/decoding base64 = "0.22" @@ -45,6 +46,7 @@ time = "0.3.53" default = ["xmldsig", "c14n"] xmldsig = [ # XML Digital Signatures (sign + verify) "dep:der", + "dep:crypto-bigint", "dep:p256", "dep:p384", "dep:p521", diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 05aec79..ba2e4ba 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -2,7 +2,8 @@ use std::{collections::HashMap, time::SystemTime}; -use p256::pkcs8::EncodePublicKey; +use crypto_bigint::BoxedUint; +use p256::pkcs8::EncodePublicKey as P256EncodePublicKey; use x509_parser::{ prelude::{FromDer, X509Certificate}, public_key::PublicKey, @@ -398,8 +399,8 @@ fn rsa_key_value_to_spki_der( exponent: &[u8], ) -> Result, KeyResolutionError> { let key = rsa::RsaPublicKey::new( - rsa::BigUint::from_bytes_be(modulus), - rsa::BigUint::from_bytes_be(exponent), + BoxedUint::from_be_slice_vartime(modulus), + BoxedUint::from_be_slice_vartime(exponent), ) .map_err(|_| KeyResolutionError::InvalidPublicKey)?; key.to_public_key_der() diff --git a/src/xmldsig/signature.rs b/src/xmldsig/signature.rs index c52a201..a37adda 100644 --- a/src/xmldsig/signature.rs +++ b/src/xmldsig/signature.rs @@ -11,13 +11,13 @@ //! - ECDSA keys are validated as uncompressed SEC1 points from the SPKI bit //! string and verified with RustCrypto curve crates (`p256`/`p384`/`p521`). +use p256::ecdsa::signature::Verifier as P256Verifier; use p256::ecdsa::{Signature as P256Signature, VerifyingKey as P256VerifyingKey}; use p384::ecdsa::{Signature as P384Signature, VerifyingKey as P384VerifyingKey}; use p521::ecdsa::{Signature as P521Signature, VerifyingKey as P521VerifyingKey}; use rsa::pkcs1v15::{Signature as RsaPkcs1v15Signature, VerifyingKey as RsaVerifyingKey}; use rsa::pkcs8::DecodePublicKey; use rsa::signature::hazmat::PrehashVerifier; -use rsa::signature::{DigestVerifier, Verifier}; use sha1::Sha1; use sha2::{Digest, Sha256, Sha384, Sha512}; use x509_parser::prelude::FromDer; @@ -359,9 +359,7 @@ fn verify_ecdsa_p256_sha256( ) -> Result { let key = P256VerifyingKey::from_sec1_bytes(public_key) .map_err(|_| SignatureVerificationError::InvalidKeyDer)?; - let mut digest = Sha256::new(); - digest.update(signed_data); - verify_p256_signature(&key, signature_value, signature_encoding, digest) + verify_p256_signature(&key, signature_value, signature_encoding, signed_data) } fn verify_ecdsa_p384_sha384( @@ -372,9 +370,7 @@ fn verify_ecdsa_p384_sha384( ) -> Result { let key = P384VerifyingKey::from_sec1_bytes(public_key) .map_err(|_| SignatureVerificationError::InvalidKeyDer)?; - let mut digest = Sha384::new(); - digest.update(signed_data); - verify_p384_signature(&key, signature_value, signature_encoding, digest) + verify_p384_signature(&key, signature_value, signature_encoding, signed_data) } fn verify_ecdsa_p521_sha384( @@ -393,29 +389,29 @@ fn verify_p256_signature( key: &P256VerifyingKey, signature_value: &[u8], signature_encoding: EcdsaSignatureEncoding, - digest: Sha256, + signed_data: &[u8], ) -> Result { match signature_encoding { EcdsaSignatureEncoding::XmlDsigFixed => { let signature = P256Signature::from_slice(signature_value) .map_err(|_| SignatureVerificationError::InvalidSignatureFormat)?; - Ok(key.verify_digest(digest, &signature).is_ok()) + Ok(key.verify(signed_data, &signature).is_ok()) } EcdsaSignatureEncoding::Asn1Der => { let signature = P256Signature::from_der(signature_value) .map_err(|_| SignatureVerificationError::InvalidSignatureFormat)?; - Ok(key.verify_digest(digest, &signature).is_ok()) + Ok(key.verify(signed_data, &signature).is_ok()) } EcdsaSignatureEncoding::Ambiguous => { if let Ok(signature) = P256Signature::from_der(signature_value) - && key.verify_digest(digest.clone(), &signature).is_ok() + && key.verify(signed_data, &signature).is_ok() { return Ok(true); } let signature = P256Signature::from_slice(signature_value) .map_err(|_| SignatureVerificationError::InvalidSignatureFormat)?; - Ok(key.verify_digest(digest, &signature).is_ok()) + Ok(key.verify(signed_data, &signature).is_ok()) } } } @@ -424,29 +420,29 @@ fn verify_p384_signature( key: &P384VerifyingKey, signature_value: &[u8], signature_encoding: EcdsaSignatureEncoding, - digest: Sha384, + signed_data: &[u8], ) -> Result { match signature_encoding { EcdsaSignatureEncoding::XmlDsigFixed => { let signature = P384Signature::from_slice(signature_value) .map_err(|_| SignatureVerificationError::InvalidSignatureFormat)?; - Ok(key.verify_digest(digest, &signature).is_ok()) + Ok(key.verify(signed_data, &signature).is_ok()) } EcdsaSignatureEncoding::Asn1Der => { let signature = P384Signature::from_der(signature_value) .map_err(|_| SignatureVerificationError::InvalidSignatureFormat)?; - Ok(key.verify_digest(digest, &signature).is_ok()) + Ok(key.verify(signed_data, &signature).is_ok()) } EcdsaSignatureEncoding::Ambiguous => { if let Ok(signature) = P384Signature::from_der(signature_value) - && key.verify_digest(digest.clone(), &signature).is_ok() + && key.verify(signed_data, &signature).is_ok() { return Ok(true); } let signature = P384Signature::from_slice(signature_value) .map_err(|_| SignatureVerificationError::InvalidSignatureFormat)?; - Ok(key.verify_digest(digest, &signature).is_ok()) + Ok(key.verify(signed_data, &signature).is_ok()) } } } From 81b922eb98d9ae1e6d752828723c7dfb11605c41 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 6 Jul 2026 10:09:31 +0300 Subject: [PATCH 2/6] test(xmldsig): adapt RSA key value tests --- src/xmldsig/keys.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index ba2e4ba..494b16d 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -528,6 +528,13 @@ mod tests { format!("{}{}{}", &xml[..start], replacement, &xml[end..]) } + fn rsa_key_value_parts(public_key: &rsa::RsaPublicKey) -> (String, String) { + ( + STANDARD.encode(public_key.n().to_be_bytes_trimmed_vartime()), + STANDARD.encode(public_key.e().to_be_bytes_trimmed_vartime()), + ) + } + fn x509_signature_with_leaf_subject() -> String { replace_unprefixed_key_info( X509_DIGEST_SIGNATURE, @@ -851,10 +858,10 @@ mod tests { // Embedded CryptoBinary parameters must verify the original RSA-2048 donor signature. let public_key = rsa::RsaPublicKey::from_public_key_pem(RSA_PUBLIC_KEY) .expect("fixture must contain an RSA public key"); + let (modulus, exponent) = rsa_key_value_parts(&public_key); let key_info = format!( "{}{}", - STANDARD.encode(public_key.n().to_bytes_be()), - STANDARD.encode(public_key.e().to_bytes_be()), + modulus, exponent, ); let xml = replace_unprefixed_key_info(RSA_KEY_VALUE_SIGNATURE, &key_info); let resolver = DefaultKeyResolver::default(); @@ -886,10 +893,10 @@ mod tests { // Embedded RSA parameters must not be relabeled for an ECDSA SignatureMethod. let public_key = rsa::RsaPublicKey::from_public_key_pem(RSA_PUBLIC_KEY) .expect("fixture must contain an RSA public key"); + let (modulus, exponent) = rsa_key_value_parts(&public_key); let key_info = format!( "{}{}", - STANDARD.encode(public_key.n().to_bytes_be()), - STANDARD.encode(public_key.e().to_bytes_be()), + modulus, exponent, ); let xml = replace_key_info(SIGNED_SAML, &key_info); let resolver = DefaultKeyResolver::default(); @@ -950,10 +957,10 @@ mod tests { // Mixed KeyInfo should keep scanning after an incompatible ECKeyValue source. let public_key = rsa::RsaPublicKey::from_public_key_pem(RSA_PUBLIC_KEY) .expect("fixture must contain an RSA public key"); + let (modulus, exponent) = rsa_key_value_parts(&public_key); let key_info = format!( r#"BJ/yaXNlq4FRObyJCBhb5jAz8GVzinK3bBGLjSDfjbJwNfydtgjnlS4EsDmxSRhWyJWq6GIqy5wvnaiARK04uB4={}{}"#, - STANDARD.encode(public_key.n().to_bytes_be()), - STANDARD.encode(public_key.e().to_bytes_be()), + modulus, exponent, ); let xml = replace_unprefixed_key_info(RSA_KEY_VALUE_SIGNATURE, &key_info); let resolver = DefaultKeyResolver::default(); From 7e0f572dc7de4e54512850d65fc24666fa615c39 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 6 Jul 2026 10:10:06 +0300 Subject: [PATCH 3/6] feat(xmldsig): add XML mutation helpers --- src/xmldsig/mod.rs | 1 + src/xmldsig/mutation.rs | 355 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 356 insertions(+) create mode 100644 src/xmldsig/mutation.rs diff --git a/src/xmldsig/mod.rs b/src/xmldsig/mod.rs index 9a0a9e2..93f6252 100644 --- a/src/xmldsig/mod.rs +++ b/src/xmldsig/mod.rs @@ -11,6 +11,7 @@ pub mod builder; pub mod digest; pub mod keys; +pub mod mutation; pub mod parse; pub mod signature; pub mod transforms; diff --git a/src/xmldsig/mutation.rs b/src/xmldsig/mutation.rs new file mode 100644 index 0000000..b636b24 --- /dev/null +++ b/src/xmldsig/mutation.rs @@ -0,0 +1,355 @@ +//! Streaming XML mutation helpers for the XMLDSig signing pipeline. +//! +//! Signing cannot mutate `roxmltree`'s read-only DOM. These helpers validate +//! structure with `roxmltree`, then rewrite the document with `quick-xml`. + +use std::io::Write; + +use quick_xml::events::{BytesText, Event}; +use quick_xml::name::{Namespace, ResolveResult}; +use quick_xml::reader::NsReader; +use quick_xml::{Reader, Writer}; + +use super::parse::XMLDSIG_NS; + +/// Errors produced by XMLDSig XML mutation helpers. +#[derive(Debug, thiserror::Error)] +pub enum XmlMutationError { + /// Input XML or generated template is not parseable XML. + #[error("XML parsing error: {0}")] + XmlParse(#[from] roxmltree::Error), + /// The streaming XML reader failed. + #[error("XML read error: {0}")] + Read(#[from] quick_xml::Error), + /// The streaming XML writer failed. + #[error("XML write error: {0}")] + Write(#[from] std::io::Error), + /// The writer unexpectedly emitted non-UTF-8 bytes. + #[error("XML writer emitted invalid UTF-8: {0}")] + InvalidUtf8(#[from] std::string::FromUtf8Error), + /// A template did not contain exactly one XMLDSig `` root. + #[error("signature template root must be one XMLDSig Signature element")] + InvalidSignatureTemplate, + /// A replacement call supplied a different number of values than matching elements. + #[error("expected {expected} XMLDSig {element} values, got {actual}")] + ValueCountMismatch { + /// XMLDSig element local name. + element: &'static str, + /// Number of matching XMLDSig elements in the document. + expected: usize, + /// Number of values supplied by the caller. + actual: usize, + }, + /// The source XML did not contain a root element that can receive a signature. + #[error("source XML must contain a root element")] + MissingRootElement, +} + +/// Append a generated XMLDSig `` template as the last child of the +/// source document root. +pub fn append_signature_to_root( + xml: &str, + signature_template: &str, +) -> Result { + validate_signature_template(signature_template)?; + let source = roxmltree::Document::parse(xml)?; + if !source.root().children().any(|node| node.is_element()) { + return Err(XmlMutationError::MissingRootElement); + } + + let mut reader = Reader::from_str(xml); + let mut writer = Writer::new(Vec::new()); + let mut root_depth = 0usize; + let mut saw_root = false; + let mut buf = Vec::new(); + + loop { + match reader.read_event_into(&mut buf)? { + Event::Start(element) if root_depth == 0 => { + saw_root = true; + root_depth = 1; + writer.write_event(Event::Start(element))?; + } + Event::Start(element) => { + root_depth += 1; + writer.write_event(Event::Start(element))?; + } + Event::Empty(element) if root_depth == 0 => { + saw_root = true; + writer.write_event(Event::Start(element.borrow()))?; + writer.get_mut().write_all(signature_template.as_bytes())?; + writer.write_event(Event::End(element.to_end()))?; + } + Event::End(element) if root_depth == 1 => { + writer.get_mut().write_all(signature_template.as_bytes())?; + writer.write_event(Event::End(element))?; + root_depth = 0; + } + Event::End(element) => { + root_depth = root_depth.saturating_sub(1); + writer.write_event(Event::End(element))?; + } + Event::Eof => break, + event => writer.write_event(event)?, + } + buf.clear(); + } + + if !saw_root { + return Err(XmlMutationError::MissingRootElement); + } + + let output = String::from_utf8(writer.into_inner())?; + roxmltree::Document::parse(&output)?; + Ok(output) +} + +/// Fill XMLDSig `` elements in document order. +pub fn fill_digest_values(xml: &str, values: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + fill_dsig_values(xml, "DigestValue", values) +} + +/// Fill XMLDSig `` elements in document order. +pub fn fill_signature_values(xml: &str, values: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + fill_dsig_values(xml, "SignatureValue", values) +} + +fn fill_dsig_values( + xml: &str, + local_name: &'static str, + values: I, +) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let values: Vec = values + .into_iter() + .map(|value| value.as_ref().to_owned()) + .collect(); + let expected = count_dsig_elements(xml, local_name)?; + if expected != values.len() { + return Err(XmlMutationError::ValueCountMismatch { + element: local_name, + expected, + actual: values.len(), + }); + } + + let mut reader = NsReader::from_str(xml); + let mut writer = Writer::new(Vec::new()); + let mut buf = Vec::new(); + let mut value_index = 0usize; + let mut replacing_depth: Option = None; + + loop { + let (namespace, event) = reader.read_resolved_event_into(&mut buf)?; + if let Some(depth) = replacing_depth.as_mut() { + match event { + Event::Start(_) | Event::Empty(_) => *depth += 1, + Event::End(end) if *depth == 0 => { + writer.write_event(Event::End(end))?; + replacing_depth = None; + } + Event::End(_) => *depth -= 1, + Event::Eof => break, + _ => {} + } + buf.clear(); + continue; + } + + match event { + Event::Start(element) + if is_dsig_element(&namespace, element.local_name().as_ref(), local_name) => + { + writer.write_event(Event::Start(element))?; + writer.write_event(Event::Text(BytesText::new(&values[value_index])))?; + value_index += 1; + replacing_depth = Some(0); + } + Event::Empty(element) + if is_dsig_element(&namespace, element.local_name().as_ref(), local_name) => + { + writer.write_event(Event::Start(element.borrow()))?; + writer.write_event(Event::Text(BytesText::new(&values[value_index])))?; + value_index += 1; + writer.write_event(Event::End(element.to_end()))?; + } + Event::Eof => break, + event => writer.write_event(event)?, + } + buf.clear(); + } + + let output = String::from_utf8(writer.into_inner())?; + roxmltree::Document::parse(&output)?; + Ok(output) +} + +fn validate_signature_template(signature_template: &str) -> Result<(), XmlMutationError> { + let document = roxmltree::Document::parse(signature_template)?; + let root = document.root_element(); + if root.tag_name().namespace() == Some(XMLDSIG_NS) && root.tag_name().name() == "Signature" { + Ok(()) + } else { + Err(XmlMutationError::InvalidSignatureTemplate) + } +} + +fn count_dsig_elements(xml: &str, local_name: &str) -> Result { + let document = roxmltree::Document::parse(xml)?; + Ok(document + .descendants() + .filter(|node| { + node.is_element() + && node.tag_name().namespace() == Some(XMLDSIG_NS) + && node.tag_name().name() == local_name + }) + .count()) +} + +fn is_dsig_element(namespace: &ResolveResult<'_>, local: &[u8], expected_local: &str) -> bool { + matches!(namespace, ResolveResult::Bound(Namespace(ns)) if ns.as_ref() == XMLDSIG_NS.as_bytes()) + && local == expected_local.as_bytes() +} + +#[cfg(test)] +mod tests { + use crate::c14n::{C14nAlgorithm, C14nMode}; + use crate::xmldsig::{ + DigestAlgorithm, ReferenceBuilder, SignatureAlgorithm, SignatureBuilder, Transform, + }; + + use super::*; + + fn template(reference_count: usize) -> String { + let mut builder = SignatureBuilder::new( + C14nAlgorithm::new(C14nMode::Exclusive1_0, false), + SignatureAlgorithm::RsaSha256, + ) + .ns_prefix("ds"); + for index in 0..reference_count { + builder = builder.add_reference( + ReferenceBuilder::new(DigestAlgorithm::Sha256) + .uri(format!("#ref-{index}")) + .transform(Transform::Enveloped), + ); + } + builder.build_template().expect("valid template") + } + + #[test] + fn appends_signature_template_to_non_empty_root() { + let signed = append_signature_to_root("", &template(1)) + .expect("append signature"); + let document = roxmltree::Document::parse(&signed).expect("parse output"); + let root = document.root_element(); + let children: Vec<_> = root + .children() + .filter(roxmltree::Node::is_element) + .map(|node| node.tag_name().name()) + .collect(); + assert_eq!(children, ["payload", "Signature"]); + assert_eq!( + root.last_element_child() + .expect("signature") + .tag_name() + .namespace(), + Some(XMLDSIG_NS) + ); + } + + #[test] + fn appends_signature_template_to_empty_root() { + let signed = append_signature_to_root("", &template(1)).expect("append signature"); + let document = roxmltree::Document::parse(&signed).expect("parse output"); + let root = document.root_element(); + assert_eq!( + root.first_element_child() + .expect("signature") + .tag_name() + .name(), + "Signature" + ); + } + + #[test] + fn rejects_non_signature_template() { + let err = append_signature_to_root("", "") + .expect_err("template must be a Signature"); + assert!(matches!(err, XmlMutationError::InvalidSignatureTemplate)); + } + + #[test] + fn fills_digest_values_in_xml_dsig_document_order() { + let signed = append_signature_to_root("", &template(2)).expect("append signature"); + let filled = + fill_digest_values(&signed, ["digest-one", "digest-two"]).expect("fill digest values"); + let document = roxmltree::Document::parse(&filled).expect("parse output"); + let values: Vec<_> = document + .descendants() + .filter(|node| node.has_tag_name((XMLDSIG_NS, "DigestValue"))) + .map(|node| node.text()) + .collect(); + assert_eq!(values, [Some("digest-one"), Some("digest-two")]); + } + + #[test] + fn fills_signature_value_without_touching_digest_values() { + let signed = append_signature_to_root("", &template(1)).expect("append signature"); + let filled = + fill_signature_values(&signed, ["signature&bytes"]).expect("fill signature value"); + let document = roxmltree::Document::parse(&filled).expect("parse output"); + let signature_value = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "SignatureValue"))) + .expect("SignatureValue"); + assert_eq!(signature_value.text(), Some("signature&bytes")); + let digest_value = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "DigestValue"))) + .expect("DigestValue"); + assert_eq!(digest_value.text(), None); + } + + #[test] + fn replacement_count_must_match_dsig_elements() { + let signed = append_signature_to_root("", &template(2)).expect("append signature"); + let err = fill_digest_values(&signed, ["only-one"]).expect_err("mismatch"); + assert!(matches!( + err, + XmlMutationError::ValueCountMismatch { + element: "DigestValue", + expected: 2, + actual: 1 + } + )); + } + + #[test] + fn does_not_replace_foreign_same_local_name_elements() { + let source = r#"keep"#; + let signed = append_signature_to_root(source, &template(1)).expect("append signature"); + let filled = fill_digest_values(&signed, ["digest"]).expect("fill digest"); + let document = roxmltree::Document::parse(&filled).expect("parse output"); + let foreign = document + .descendants() + .find(|node| node.has_tag_name(("urn:test", "DigestValue"))) + .expect("foreign DigestValue"); + assert_eq!(foreign.text(), Some("keep")); + let dsig = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "DigestValue"))) + .expect("dsig DigestValue"); + assert_eq!(dsig.text(), Some("digest")); + } +} From 34ee90cec60c3fe081a400e6284692f062800463 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 6 Jul 2026 10:12:47 +0300 Subject: [PATCH 4/6] style(xmldsig): satisfy mutation clippy --- src/xmldsig/mutation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/xmldsig/mutation.rs b/src/xmldsig/mutation.rs index b636b24..ee400f4 100644 --- a/src/xmldsig/mutation.rs +++ b/src/xmldsig/mutation.rs @@ -218,7 +218,7 @@ fn count_dsig_elements(xml: &str, local_name: &str) -> Result, local: &[u8], expected_local: &str) -> bool { - matches!(namespace, ResolveResult::Bound(Namespace(ns)) if ns.as_ref() == XMLDSIG_NS.as_bytes()) + matches!(namespace, ResolveResult::Bound(Namespace(ns)) if *ns == XMLDSIG_NS.as_bytes()) && local == expected_local.as_bytes() } From b74b0322afeae2cd15f49f09336a0987843d9960 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 6 Jul 2026 11:27:09 +0300 Subject: [PATCH 5/6] fix(xmldsig): preserve value end after empty child --- src/xmldsig/mutation.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/xmldsig/mutation.rs b/src/xmldsig/mutation.rs index ee400f4..f7ee1eb 100644 --- a/src/xmldsig/mutation.rs +++ b/src/xmldsig/mutation.rs @@ -154,7 +154,7 @@ where let (namespace, event) = reader.read_resolved_event_into(&mut buf)?; if let Some(depth) = replacing_depth.as_mut() { match event { - Event::Start(_) | Event::Empty(_) => *depth += 1, + Event::Start(_) => *depth += 1, Event::End(end) if *depth == 0 => { writer.write_event(Event::End(end))?; replacing_depth = None; @@ -352,4 +352,22 @@ mod tests { .expect("dsig DigestValue"); assert_eq!(dsig.text(), Some("digest")); } + + #[test] + fn replacement_preserves_target_end_after_self_closing_child() { + let source = r#""#; + let filled = fill_digest_values(source, ["digest"]).expect("fill digest"); + let document = roxmltree::Document::parse(&filled).expect("parse output"); + let digest_value = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "DigestValue"))) + .expect("DigestValue"); + assert_eq!(digest_value.text(), Some("digest")); + assert_eq!( + digest_value + .next_sibling_element() + .map(|node| node.tag_name().name()), + None + ); + } } From 6162dc29a4239a37bfd288e35f13122f24d10846 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 6 Jul 2026 12:12:44 +0300 Subject: [PATCH 6/6] fix(xmldsig): fail closed on skipped values --- src/xmldsig/mutation.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/xmldsig/mutation.rs b/src/xmldsig/mutation.rs index f7ee1eb..0d0b9cd 100644 --- a/src/xmldsig/mutation.rs +++ b/src/xmldsig/mutation.rs @@ -190,6 +190,14 @@ where buf.clear(); } + if value_index != values.len() { + return Err(XmlMutationError::ValueCountMismatch { + element: local_name, + expected, + actual: value_index, + }); + } + let output = String::from_utf8(writer.into_inner())?; roxmltree::Document::parse(&output)?; Ok(output) @@ -370,4 +378,19 @@ mod tests { None ); } + + #[test] + fn replacement_fails_when_nested_dsig_values_are_skipped() { + let source = r#"nested"#; + let err = + fill_digest_values(source, ["outer", "nested"]).expect_err("nested target skipped"); + assert!(matches!( + err, + XmlMutationError::ValueCountMismatch { + element: "DigestValue", + expected: 2, + actual: 1 + } + )); + } }