From 2a8dd9712ae5a5f7e6b22019c88ad06540394a79 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Tue, 7 Jul 2026 13:03:59 +0300 Subject: [PATCH 1/7] feat(xmldsig): compute signing reference digests - Add signing-template digest computation and fill APIs - Preserve strict verification parsing for populated DigestValue - Cover same-document, multi-reference, enveloped, and SHA-1 rejection cases - Update p384 to stable 0.14 Closes #78 --- Cargo.toml | 2 +- src/xmldsig/mod.rs | 5 + src/xmldsig/sign.rs | 281 ++++++++++++++++++++++++++++++++++++++++ tests/signing_digest.rs | 140 ++++++++++++++++++++ 4 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 src/xmldsig/sign.rs create mode 100644 tests/signing_digest.rs diff --git a/Cargo.toml b/Cargo.toml index 8c02de2..129aab3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ 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 } +p384 = { version = "0.14", 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 } diff --git a/src/xmldsig/mod.rs b/src/xmldsig/mod.rs index 93f6252..807667f 100644 --- a/src/xmldsig/mod.rs +++ b/src/xmldsig/mod.rs @@ -13,6 +13,7 @@ pub mod digest; pub mod keys; pub mod mutation; pub mod parse; +pub mod sign; pub mod signature; pub mod transforms; pub mod types; @@ -28,6 +29,10 @@ pub use parse::{ KeyInfo, KeyInfoSource, KeyValueInfo, ParseError, Reference, SignatureAlgorithm, SignedInfo, X509DataInfo, find_signature_node, parse_key_info, parse_signed_info, }; +pub use sign::{ + ComputedReferenceDigest, SigningDigestError, compute_reference_digest_values, + fill_reference_digest_values, +}; pub use signature::{ SignatureVerificationError, verify_ecdsa_signature_pem, verify_ecdsa_signature_spki, verify_rsa_signature_pem, verify_rsa_signature_spki, diff --git a/src/xmldsig/sign.rs b/src/xmldsig/sign.rs new file mode 100644 index 0000000..dd1e805 --- /dev/null +++ b/src/xmldsig/sign.rs @@ -0,0 +1,281 @@ +//! Signing-side XMLDSig digest computation. +//! +//! This pass fills `` elements before `` is +//! canonicalized and signed. It intentionally uses a signing-template parser +//! instead of [`crate::xmldsig::parse::parse_signed_info`], because verification +//! must continue to reject empty or malformed stored digest values. + +use base64::Engine; +use roxmltree::{Document, Node}; + +use super::digest::{DigestAlgorithm, compute_digest}; +use super::mutation::{XmlMutationError, fill_digest_values}; +use super::parse::XMLDSIG_NS; +use super::transforms::{Transform, execute_transforms, parse_transforms}; +use super::types::TransformError; +use super::uri::UriReferenceResolver; + +/// Result for one computed signing-template reference digest. +#[derive(Debug, Clone, PartialEq, Eq)] +#[must_use = "use the computed digest value to fill the corresponding "] +pub struct ComputedReferenceDigest { + /// Zero-based reference index in `` document order. + pub index: usize, + /// Reference URI used for same-document dereference. + pub uri: String, + /// Digest algorithm declared by ``. + pub digest_method: DigestAlgorithm, + /// Base64-encoded digest value ready for ``. + pub digest_value: String, +} + +/// Errors returned by the XMLDSig signing digest pass. +#[derive(Debug, thiserror::Error)] +pub enum SigningDigestError { + /// The input XML document is not well-formed. + #[error("XML parse error: {0}")] + XmlParse(#[from] roxmltree::Error), + + /// Required XMLDSig element is missing. + #[error("missing required element: <{element}>")] + MissingElement { + /// Required element name. + element: &'static str, + }, + + /// XMLDSig template structure is invalid. + #[error("invalid signing template: {0}")] + InvalidStructure(String), + + /// Digest algorithm URI is not supported. + #[error("unsupported digest algorithm: {uri}")] + UnsupportedAlgorithm { + /// Unrecognized algorithm URI. + uri: String, + }, + + /// Digest algorithm is supported for verification but disabled for signing. + #[error("digest algorithm is disabled for signing: {uri}")] + SigningAlgorithmDisabled { + /// Algorithm URI rejected for new signatures. + uri: &'static str, + }, + + /// URI dereference or transform execution failed. + #[error("reference processing error: {0}")] + Transform(#[from] TransformError), + + /// Writing computed digest values back into XML failed. + #[error("XML mutation error: {0}")] + XmlMutation(#[from] XmlMutationError), +} + +#[derive(Debug)] +struct SigningReference { + uri: String, + transforms: Vec, + digest_method: DigestAlgorithm, +} + +/// Compute base64 digest values for every `` in the signing template. +/// +/// References are processed in `` document order. The input must +/// contain exactly one XMLDSig `` element so an enveloped-signature +/// transform cannot accidentally target the wrong signature subtree. +pub fn compute_reference_digest_values( + xml: &str, +) -> Result, SigningDigestError> { + let doc = Document::parse(xml)?; + let signature = find_single_signature_node(&doc)?; + let signed_info = find_required_child(signature, "SignedInfo")?; + let references = parse_signing_references(signed_info)?; + let resolver = UriReferenceResolver::new(&doc); + + references + .into_iter() + .enumerate() + .map(|(index, reference)| { + let initial_data = resolver.dereference(&reference.uri)?; + let pre_digest = execute_transforms(signature, initial_data, &reference.transforms)?; + let digest = compute_digest(reference.digest_method, &pre_digest); + let digest_value = base64::engine::general_purpose::STANDARD.encode(digest); + Ok(ComputedReferenceDigest { + index, + uri: reference.uri, + digest_method: reference.digest_method, + digest_value, + }) + }) + .collect() +} + +/// Compute and fill all signing-template `` elements. +/// +/// This is the signing counterpart to verification reference processing: it +/// dereferences each ``, applies transforms, computes the digest, +/// and writes the base64 digest into the matching `` in document +/// order. +pub fn fill_reference_digest_values(xml: &str) -> Result { + let digest_values = compute_reference_digest_values(xml)? + .into_iter() + .map(|digest| digest.digest_value); + Ok(fill_digest_values(xml, digest_values)?) +} + +fn find_single_signature_node<'a>( + doc: &'a Document<'a>, +) -> Result, SigningDigestError> { + let mut signatures = doc.descendants().filter(|node| { + node.is_element() + && node.tag_name().name() == "Signature" + && node.tag_name().namespace() == Some(XMLDSIG_NS) + }); + let signature = signatures + .next() + .ok_or(SigningDigestError::MissingElement { + element: "Signature", + })?; + if signatures.next().is_some() { + return Err(SigningDigestError::InvalidStructure( + "expected exactly one element".into(), + )); + } + Ok(signature) +} + +fn parse_signing_references( + signed_info: Node<'_, '_>, +) -> Result, SigningDigestError> { + verify_ds_element(signed_info, "SignedInfo")?; + let mut children = element_children(signed_info); + + let c14n_node = children.next().ok_or(SigningDigestError::MissingElement { + element: "CanonicalizationMethod", + })?; + verify_ds_element(c14n_node, "CanonicalizationMethod")?; + required_algorithm_attr(c14n_node, "CanonicalizationMethod")?; + + let signature_method_node = children.next().ok_or(SigningDigestError::MissingElement { + element: "SignatureMethod", + })?; + verify_ds_element(signature_method_node, "SignatureMethod")?; + required_algorithm_attr(signature_method_node, "SignatureMethod")?; + + let mut references = Vec::new(); + for child in children { + verify_ds_element(child, "Reference")?; + references.push(parse_signing_reference(child)?); + } + if references.is_empty() { + return Err(SigningDigestError::MissingElement { + element: "Reference", + }); + } + Ok(references) +} + +fn parse_signing_reference( + reference_node: Node<'_, '_>, +) -> Result { + let uri = reference_node + .attribute("URI") + .ok_or_else(|| { + SigningDigestError::InvalidStructure( + "signing Reference must include URI attribute".into(), + ) + })? + .to_string(); + let mut children = element_children(reference_node); + + let mut transforms = Vec::new(); + let mut next = children.next().ok_or(SigningDigestError::MissingElement { + element: "DigestMethod", + })?; + if next.tag_name().name() == "Transforms" && next.tag_name().namespace() == Some(XMLDSIG_NS) { + transforms = parse_transforms(next)?; + next = children.next().ok_or(SigningDigestError::MissingElement { + element: "DigestMethod", + })?; + } + + verify_ds_element(next, "DigestMethod")?; + let digest_uri = required_algorithm_attr(next, "DigestMethod")?; + let digest_method = DigestAlgorithm::from_uri(digest_uri).ok_or_else(|| { + SigningDigestError::UnsupportedAlgorithm { + uri: digest_uri.to_string(), + } + })?; + if !digest_method.signing_allowed() { + return Err(SigningDigestError::SigningAlgorithmDisabled { + uri: digest_method.uri(), + }); + } + + let digest_value_node = children.next().ok_or(SigningDigestError::MissingElement { + element: "DigestValue", + })?; + verify_ds_element(digest_value_node, "DigestValue")?; + + if let Some(unexpected) = children.next() { + return Err(SigningDigestError::InvalidStructure(format!( + "unexpected element <{}> after in ", + unexpected.tag_name().name() + ))); + } + + Ok(SigningReference { + uri, + transforms, + digest_method, + }) +} + +fn find_required_child<'a>( + parent: Node<'a, 'a>, + child_name: &'static str, +) -> Result, SigningDigestError> { + parent + .children() + .find(|node| { + node.is_element() + && node.tag_name().name() == child_name + && node.tag_name().namespace() == Some(XMLDSIG_NS) + }) + .ok_or(SigningDigestError::MissingElement { + element: child_name, + }) +} + +fn element_children<'a>(node: Node<'a, 'a>) -> impl Iterator> { + node.children().filter(Node::is_element) +} + +fn verify_ds_element( + node: Node<'_, '_>, + expected_name: &'static str, +) -> Result<(), SigningDigestError> { + if !node.is_element() { + return Err(SigningDigestError::InvalidStructure(format!( + "expected element <{expected_name}>, got non-element node" + ))); + } + let tag = node.tag_name(); + if tag.name() != expected_name || tag.namespace() != Some(XMLDSIG_NS) { + return Err(SigningDigestError::InvalidStructure(format!( + "expected , got <{}>", + tag.name() + ))); + } + Ok(()) +} + +fn required_algorithm_attr<'a>( + node: Node<'a, 'a>, + element_name: &'static str, +) -> Result<&'a str, SigningDigestError> { + node.attribute("Algorithm").ok_or_else(|| { + SigningDigestError::InvalidStructure(format!( + "missing Algorithm attribute on <{element_name}>" + )) + }) +} diff --git a/tests/signing_digest.rs b/tests/signing_digest.rs new file mode 100644 index 0000000..5619f29 --- /dev/null +++ b/tests/signing_digest.rs @@ -0,0 +1,140 @@ +use xml_sec::c14n::{C14nAlgorithm, C14nMode}; +use xml_sec::xmldsig::mutation::append_signature_to_root; +use xml_sec::xmldsig::parse::{find_signature_node, parse_signed_info}; +use xml_sec::xmldsig::uri::UriReferenceResolver; +use xml_sec::xmldsig::verify::process_all_references; +use xml_sec::xmldsig::{ + DigestAlgorithm, ReferenceBuilder, SignatureAlgorithm, SignatureBuilder, SigningDigestError, + Transform, compute_reference_digest_values, fill_reference_digest_values, +}; + +fn exclusive_c14n() -> C14nAlgorithm { + C14nAlgorithm::new(C14nMode::Exclusive1_0, false) +} + +fn template_with_reference(reference: ReferenceBuilder) -> String { + SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::RsaSha256) + .add_reference(reference) + .build_template() + .expect("valid signature template") +} + +fn assert_reference_digests_verify(xml: &str) { + let document = roxmltree::Document::parse(xml).expect("filled XML must parse"); + let signature = find_signature_node(&document).expect("Signature element"); + let signed_info_node = signature + .children() + .find(|node| { + node.is_element() + && node.tag_name().name() == "SignedInfo" + && node.tag_name().namespace() == Some("http://www.w3.org/2000/09/xmldsig#") + }) + .expect("SignedInfo element"); + let signed_info = parse_signed_info(signed_info_node).expect("filled SignedInfo must parse"); + let resolver = UriReferenceResolver::new(&document); + let result = process_all_references(&signed_info.references, &resolver, signature, true) + .expect("reference verification must run"); + assert!(result.all_valid(), "filled digest values must verify"); +} + +#[test] +fn fills_single_same_document_reference_digest() { + // Signing templates start with an empty DigestValue; the digest pass must + // compute bytes from the referenced node and make the template parseable by + // the stricter verification parser. + let template = template_with_reference( + ReferenceBuilder::new(DigestAlgorithm::Sha256) + .uri("#payload") + .transform(Transform::C14n(exclusive_c14n())), + ); + let xml = append_signature_to_root( + "hello", + &template, + ) + .expect("append signature"); + + let digests = compute_reference_digest_values(&xml).expect("compute digest"); + assert_eq!(digests.len(), 1); + assert_eq!(digests[0].index, 0); + assert_eq!(digests[0].uri, "#payload"); + assert_eq!(digests[0].digest_method, DigestAlgorithm::Sha256); + assert!(!digests[0].digest_value.is_empty()); + + let filled = fill_reference_digest_values(&xml).expect("fill digest values"); + assert_reference_digests_verify(&filled); +} + +#[test] +fn preserves_multiple_reference_digest_order() { + // DigestValue replacement is positional; this prevents accidentally sorting + // or otherwise normalizing Reference order before the SignedInfo pass. + let template = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::RsaSha256) + .add_reference(ReferenceBuilder::new(DigestAlgorithm::Sha256).uri("#first")) + .add_reference(ReferenceBuilder::new(DigestAlgorithm::Sha384).uri("#second")) + .build_template() + .expect("valid signature template"); + let xml = append_signature_to_root( + "onetwo", + &template, + ) + .expect("append signature"); + + let digests = compute_reference_digest_values(&xml).expect("compute digests"); + assert_eq!(digests.len(), 2); + assert_eq!(digests[0].index, 0); + assert_eq!(digests[0].uri, "#first"); + assert_eq!(digests[0].digest_method, DigestAlgorithm::Sha256); + assert_eq!(digests[1].index, 1); + assert_eq!(digests[1].uri, "#second"); + assert_eq!(digests[1].digest_method, DigestAlgorithm::Sha384); + assert_ne!(digests[0].digest_value, digests[1].digest_value); + + let filled = fill_reference_digest_values(&xml).expect("fill digest values"); + assert_reference_digests_verify(&filled); +} + +#[test] +fn computes_enveloped_signature_digest_for_whole_document() { + // URI="" signs the full document; the enveloped transform must exclude the + // generated Signature subtree before digesting, matching verification. + let template = template_with_reference( + ReferenceBuilder::new(DigestAlgorithm::Sha512) + .uri("") + .transform(Transform::Enveloped) + .transform(Transform::C14n(exclusive_c14n())), + ); + let xml = append_signature_to_root("hello", &template) + .expect("append signature"); + + let filled = fill_reference_digest_values(&xml).expect("fill digest values"); + assert_reference_digests_verify(&filled); +} + +#[test] +fn rejects_reference_without_uri() { + // External/object reference support is not implicit: signing must know what + // bytes are being digested, so an omitted URI fails before mutation. + let template = template_with_reference(ReferenceBuilder::new(DigestAlgorithm::Sha256)); + let xml = append_signature_to_root("hello", &template) + .expect("append signature"); + + let err = compute_reference_digest_values(&xml).expect_err("missing URI must fail"); + assert!( + matches!(err, SigningDigestError::InvalidStructure(message) if message.contains("URI")) + ); +} + +#[test] +fn rejects_sha1_digest_for_signing_template() { + // SHA-1 remains verify-only. This manually crafted template bypasses the + // builder, so the digest pass must enforce the same policy before signing. + let xml = r##"hello"##; + + let err = compute_reference_digest_values(xml).expect_err("SHA-1 signing digest must fail"); + assert!(matches!( + err, + SigningDigestError::SigningAlgorithmDisabled { + uri: "http://www.w3.org/2000/09/xmldsig#sha1" + } + )); +} From 366b79518690c957e636e9fc35dd5999974a402a Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Tue, 7 Jul 2026 13:10:13 +0300 Subject: [PATCH 2/7] feat(xmldsig): add signing pipeline - Add SignContext for template and builder-based signing - Add RSA PKCS1v15 and ECDSA P-256/P-384 signing keys - Fill SignatureValue after SignedInfo canonicalization - Verify RSA-SHA256 and ECDSA-P256 signing round trips --- src/xmldsig/mod.rs | 3 +- src/xmldsig/sign.rs | 272 +++++++++++++++++++++++++++++++++++++++- tests/signing_digest.rs | 67 +++++++++- 3 files changed, 337 insertions(+), 5 deletions(-) diff --git a/src/xmldsig/mod.rs b/src/xmldsig/mod.rs index 807667f..174a157 100644 --- a/src/xmldsig/mod.rs +++ b/src/xmldsig/mod.rs @@ -30,7 +30,8 @@ pub use parse::{ X509DataInfo, find_signature_node, parse_key_info, parse_signed_info, }; pub use sign::{ - ComputedReferenceDigest, SigningDigestError, compute_reference_digest_values, + ComputedReferenceDigest, EcdsaP256SigningKey, EcdsaP384SigningKey, RsaSigningKey, SignContext, + SigningDigestError, SigningError, SigningKey, SigningKeyError, compute_reference_digest_values, fill_reference_digest_values, }; pub use signature::{ diff --git a/src/xmldsig/sign.rs b/src/xmldsig/sign.rs index dd1e805..240cdf2 100644 --- a/src/xmldsig/sign.rs +++ b/src/xmldsig/sign.rs @@ -6,11 +6,24 @@ //! must continue to reject empty or malformed stored digest values. use base64::Engine; +use p256::ecdsa::{Signature as P256Signature, SigningKey as P256SigningKey}; +use p256::pkcs8::DecodePrivateKey; +use p384::ecdsa::{Signature as P384Signature, SigningKey as P384SigningKey}; use roxmltree::{Document, Node}; +use rsa::RsaPrivateKey; +use rsa::pkcs1v15::SigningKey as RsaPkcs1v15SigningKey; +use rsa::signature::{SignatureEncoding, Signer}; +use sha2::{Sha256, Sha384, Sha512}; +use std::collections::HashSet; +use crate::c14n::canonicalize; + +use super::builder::{SignatureBuilder, SignatureBuilderError}; use super::digest::{DigestAlgorithm, compute_digest}; -use super::mutation::{XmlMutationError, fill_digest_values}; -use super::parse::XMLDSIG_NS; +use super::mutation::{ + XmlMutationError, append_signature_to_root, fill_digest_values, fill_signature_values, +}; +use super::parse::{SignatureAlgorithm, XMLDSIG_NS, parse_signed_info}; use super::transforms::{Transform, execute_transforms, parse_transforms}; use super::types::TransformError; use super::uri::UriReferenceResolver; @@ -70,6 +83,229 @@ pub enum SigningDigestError { XmlMutation(#[from] XmlMutationError), } +/// Errors returned by the full XMLDSig signing pipeline. +#[derive(Debug, thiserror::Error)] +pub enum SigningError { + /// Reference digest computation failed. + #[error("signing digest pass failed: {0}")] + Digest(#[from] SigningDigestError), + + /// Parsing the digest-filled `` failed. + #[error("failed to parse SignedInfo after digest fill: {0}")] + ParseSignedInfo(#[from] super::parse::ParseError), + + /// SignedInfo canonicalization failed. + #[error("SignedInfo canonicalization failed: {0}")] + Canonicalization(#[from] crate::c14n::C14nError), + + /// Signing key preparation or signing failed. + #[error("signing key error: {0}")] + Key(#[from] SigningKeyError), + + /// Writing `` failed. + #[error("XML mutation error: {0}")] + XmlMutation(#[from] XmlMutationError), + + /// Signature template generation failed. + #[error("signature template error: {0}")] + Template(#[from] SignatureBuilderError), +} + +/// Errors while parsing or using XMLDSig signing keys. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum SigningKeyError { + /// PEM input could not be parsed. + #[error("invalid PEM private key")] + InvalidKeyPem, + + /// PEM block was not an unencrypted PKCS#8 private key. + #[error("invalid key format: expected PRIVATE KEY PEM, got {label}")] + InvalidKeyFormat { + /// Actual PEM label. + label: String, + }, + + /// DER bytes could not be decoded for the requested key type. + #[error("invalid PKCS#8 private key DER")] + InvalidKeyDer, + + /// The signing key cannot produce the requested XMLDSig algorithm. + #[error("signing key does not support algorithm: {uri}")] + UnsupportedAlgorithm { + /// XMLDSig signature algorithm URI. + uri: String, + }, +} + +/// Private key abstraction used by [`SignContext`]. +pub trait SigningKey { + /// Sign canonicalized `` bytes for the declared XMLDSig method. + fn sign( + &self, + algorithm: SignatureAlgorithm, + canonical_signed_info: &[u8], + ) -> Result, SigningKeyError>; +} + +/// RSA PKCS#1 v1.5 private key for XMLDSig signing. +pub struct RsaSigningKey { + key: RsaPrivateKey, +} + +impl RsaSigningKey { + /// Parse an unencrypted PKCS#8 `PRIVATE KEY` PEM block. + pub fn from_pkcs8_pem(private_key_pem: &str) -> Result { + let private_key_der = parse_private_key_pem(private_key_pem)?; + Self::from_pkcs8_der(&private_key_der) + } + + /// Parse unencrypted PKCS#8 private key DER. + pub fn from_pkcs8_der(private_key_der: &[u8]) -> Result { + let key = RsaPrivateKey::from_pkcs8_der(private_key_der) + .map_err(|_| SigningKeyError::InvalidKeyDer)?; + Ok(Self { key }) + } +} + +impl SigningKey for RsaSigningKey { + fn sign( + &self, + algorithm: SignatureAlgorithm, + canonical_signed_info: &[u8], + ) -> Result, SigningKeyError> { + match algorithm { + SignatureAlgorithm::RsaSha256 => { + Ok(RsaPkcs1v15SigningKey::::new(self.key.clone()) + .sign(canonical_signed_info) + .to_vec()) + } + SignatureAlgorithm::RsaSha384 => { + Ok(RsaPkcs1v15SigningKey::::new(self.key.clone()) + .sign(canonical_signed_info) + .to_vec()) + } + SignatureAlgorithm::RsaSha512 => { + Ok(RsaPkcs1v15SigningKey::::new(self.key.clone()) + .sign(canonical_signed_info) + .to_vec()) + } + _ => Err(SigningKeyError::UnsupportedAlgorithm { + uri: algorithm.uri().to_string(), + }), + } + } +} + +/// ECDSA P-256 private key for XMLDSig signing. +pub struct EcdsaP256SigningKey { + key: P256SigningKey, +} + +impl EcdsaP256SigningKey { + /// Parse an unencrypted PKCS#8 `PRIVATE KEY` PEM block. + pub fn from_pkcs8_pem(private_key_pem: &str) -> Result { + let private_key_der = parse_private_key_pem(private_key_pem)?; + Self::from_pkcs8_der(&private_key_der) + } + + /// Parse unencrypted PKCS#8 private key DER. + pub fn from_pkcs8_der(private_key_der: &[u8]) -> Result { + let key = P256SigningKey::from_pkcs8_der(private_key_der) + .map_err(|_| SigningKeyError::InvalidKeyDer)?; + Ok(Self { key }) + } +} + +impl SigningKey for EcdsaP256SigningKey { + fn sign( + &self, + algorithm: SignatureAlgorithm, + canonical_signed_info: &[u8], + ) -> Result, SigningKeyError> { + if algorithm != SignatureAlgorithm::EcdsaP256Sha256 { + return Err(SigningKeyError::UnsupportedAlgorithm { + uri: algorithm.uri().to_string(), + }); + } + let signature: P256Signature = self.key.sign(canonical_signed_info); + Ok(signature.to_bytes().to_vec()) + } +} + +/// ECDSA P-384 private key for XMLDSig signing. +pub struct EcdsaP384SigningKey { + key: P384SigningKey, +} + +impl EcdsaP384SigningKey { + /// Parse an unencrypted PKCS#8 `PRIVATE KEY` PEM block. + pub fn from_pkcs8_pem(private_key_pem: &str) -> Result { + let private_key_der = parse_private_key_pem(private_key_pem)?; + Self::from_pkcs8_der(&private_key_der) + } + + /// Parse unencrypted PKCS#8 private key DER. + pub fn from_pkcs8_der(private_key_der: &[u8]) -> Result { + let key = P384SigningKey::from_pkcs8_der(private_key_der) + .map_err(|_| SigningKeyError::InvalidKeyDer)?; + Ok(Self { key }) + } +} + +impl SigningKey for EcdsaP384SigningKey { + fn sign( + &self, + algorithm: SignatureAlgorithm, + canonical_signed_info: &[u8], + ) -> Result, SigningKeyError> { + if algorithm != SignatureAlgorithm::EcdsaP384Sha384 { + return Err(SigningKeyError::UnsupportedAlgorithm { + uri: algorithm.uri().to_string(), + }); + } + let signature: P384Signature = self.key.sign(canonical_signed_info); + Ok(signature.to_bytes().to_vec()) + } +} + +/// XMLDSig signing context. +pub struct SignContext<'a> { + signing_key: &'a dyn SigningKey, +} + +impl<'a> SignContext<'a> { + /// Create a signing context using the supplied private key. + pub fn new(signing_key: &'a dyn SigningKey) -> Self { + Self { signing_key } + } + + /// Sign XML that already contains a `` template. + /// + /// The template must include empty `` and `` + /// targets. The pipeline fills reference digests, reparses the result, + /// canonicalizes ``, signs those canonical bytes, and fills the + /// base64 ``. + pub fn sign_template(&self, xml: &str) -> Result { + let with_digests = fill_reference_digest_values(xml)?; + let (algorithm, canonical_signed_info) = canonicalize_signed_info(&with_digests)?; + let signature_value = self.signing_key.sign(algorithm, &canonical_signed_info)?; + let signature_b64 = base64::engine::general_purpose::STANDARD.encode(signature_value); + Ok(fill_signature_values(&with_digests, [signature_b64])?) + } + + /// Build a signature template, append it to the source root, then sign it. + pub fn sign_with_builder( + &self, + xml: &str, + builder: &SignatureBuilder, + ) -> Result { + let template = builder.build_template()?; + let templated = append_signature_to_root(xml, &template)?; + self.sign_template(&templated) + } +} + #[derive(Debug)] struct SigningReference { uri: String, @@ -122,6 +358,38 @@ pub fn fill_reference_digest_values(xml: &str) -> Result Result<(SignatureAlgorithm, Vec), SigningError> { + let doc = Document::parse(xml).map_err(SigningDigestError::XmlParse)?; + let signature = find_single_signature_node(&doc).map_err(SigningError::Digest)?; + let signed_info_node = + find_required_child(signature, "SignedInfo").map_err(SigningError::Digest)?; + let signed_info = parse_signed_info(signed_info_node)?; + let signed_info_subtree: HashSet<_> = signed_info_node + .descendants() + .map(|node: Node<'_, '_>| node.id()) + .collect(); + let mut canonical_signed_info = Vec::new(); + canonicalize( + &doc, + Some(&|node| signed_info_subtree.contains(&node.id())), + &signed_info.c14n_method, + &mut canonical_signed_info, + )?; + Ok((signed_info.signature_method, canonical_signed_info)) +} + +fn parse_private_key_pem(private_key_pem: &str) -> Result, SigningKeyError> { + let (rest, pem) = x509_parser::pem::parse_x509_pem(private_key_pem.as_bytes()) + .map_err(|_| SigningKeyError::InvalidKeyPem)?; + if !rest.iter().all(|byte| byte.is_ascii_whitespace()) { + return Err(SigningKeyError::InvalidKeyPem); + } + if pem.label != "PRIVATE KEY" { + return Err(SigningKeyError::InvalidKeyFormat { label: pem.label }); + } + Ok(pem.contents) +} + fn find_single_signature_node<'a>( doc: &'a Document<'a>, ) -> Result, SigningDigestError> { diff --git a/tests/signing_digest.rs b/tests/signing_digest.rs index 5619f29..1f5dfdc 100644 --- a/tests/signing_digest.rs +++ b/tests/signing_digest.rs @@ -4,8 +4,9 @@ use xml_sec::xmldsig::parse::{find_signature_node, parse_signed_info}; use xml_sec::xmldsig::uri::UriReferenceResolver; use xml_sec::xmldsig::verify::process_all_references; use xml_sec::xmldsig::{ - DigestAlgorithm, ReferenceBuilder, SignatureAlgorithm, SignatureBuilder, SigningDigestError, - Transform, compute_reference_digest_values, fill_reference_digest_values, + DigestAlgorithm, DsigStatus, EcdsaP256SigningKey, ReferenceBuilder, RsaSigningKey, SignContext, + SignatureAlgorithm, SignatureBuilder, SigningDigestError, Transform, + compute_reference_digest_values, fill_reference_digest_values, verify_signature_with_pem_key, }; fn exclusive_c14n() -> C14nAlgorithm { @@ -37,6 +38,10 @@ fn assert_reference_digests_verify(xml: &str) { assert!(result.all_valid(), "filled digest values must verify"); } +fn read_fixture(path: &str) -> String { + std::fs::read_to_string(path).unwrap_or_else(|err| panic!("failed to read {path}: {err}")) +} + #[test] fn fills_single_same_document_reference_digest() { // Signing templates start with an empty DigestValue; the digest pass must @@ -138,3 +143,61 @@ fn rejects_sha1_digest_for_signing_template() { } )); } + +#[test] +fn signs_rsa_sha256_template_and_verifies_round_trip() { + // Full signing pipeline: append template, compute Reference digest, + // canonicalize SignedInfo, RSA-sign it, fill SignatureValue, then verify + // the final XML through the existing end-to-end verifier. + let private_key = + RsaSigningKey::from_pkcs8_pem(&read_fixture("tests/fixtures/keys/rsa/rsa-2048-key.pem")) + .expect("RSA private key fixture must parse"); + let public_key_pem = read_fixture("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"); + let builder = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::RsaSha256) + .add_reference( + ReferenceBuilder::new(DigestAlgorithm::Sha256) + .uri("") + .transform(Transform::Enveloped) + .transform(Transform::C14n(exclusive_c14n())), + ); + + let signed = SignContext::new(&private_key) + .sign_with_builder("hello", &builder) + .expect("RSA signing pipeline must succeed"); + let verify_result = verify_signature_with_pem_key(&signed, &public_key_pem, true) + .expect("signed RSA XML must verify without pipeline errors"); + + assert_eq!(verify_result.status, DsigStatus::Valid); + assert!(signed.contains("")); + assert!(!signed.contains("")); +} + +#[test] +fn signs_ecdsa_p256_template_and_verifies_round_trip() { + // ECDSA XMLDSig SignatureValue must be fixed-width r||s bytes, not ASN.1 + // DER. The verifier accepts the generated value as a final interop check. + let private_key = EcdsaP256SigningKey::from_pkcs8_pem(&read_fixture( + "tests/fixtures/keys/ec/ec-prime256v1-key.pem", + )) + .expect("P-256 private key fixture must parse"); + let public_key_pem = read_fixture("tests/fixtures/keys/ec/ec-prime256v1-pubkey.pem"); + let builder = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::EcdsaP256Sha256) + .add_reference( + ReferenceBuilder::new(DigestAlgorithm::Sha256) + .uri("#payload") + .transform(Transform::C14n(exclusive_c14n())), + ); + + let signed = SignContext::new(&private_key) + .sign_with_builder( + "hello", + &builder, + ) + .expect("ECDSA signing pipeline must succeed"); + let verify_result = verify_signature_with_pem_key(&signed, &public_key_pem, true) + .expect("signed ECDSA XML must verify without pipeline errors"); + + assert_eq!(verify_result.status, DsigStatus::Valid); + assert!(signed.contains("")); + assert!(!signed.contains("")); +} From 0f66d59af4f185bdedbe773933cf8d29450cb568 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 8 Jul 2026 10:57:18 +0300 Subject: [PATCH 3/7] fix(xmldsig): harden signing key operations - Use randomized RSA PKCS1v15 signing with SysRng - Propagate signing failures instead of using infallible sign - Add P-384 signing round-trip coverage - Update README signing status --- Cargo.toml | 2 ++ README.md | 6 +++-- src/xmldsig/sign.rs | 55 +++++++++++++++++++++++++++-------------- tests/signing_digest.rs | 37 ++++++++++++++++++++++++--- 4 files changed, 77 insertions(+), 23 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 129aab3..5626461 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ subtle = { version = "2", optional = true } x509-parser = { version = "0.18", features = ["verify"], optional = true } der = { version = "0.8", optional = true } crypto-bigint = { version = "0.7", optional = true } +crypto-common = { version = "0.2", optional = true } # Base64 encoding/decoding base64 = "0.22" @@ -47,6 +48,7 @@ default = ["xmldsig", "c14n"] xmldsig = [ # XML Digital Signatures (sign + verify) "dep:der", "dep:crypto-bigint", + "dep:crypto-common", "dep:p256", "dep:p384", "dep:p521", diff --git a/README.md b/README.md index a1157c1..767ca41 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Pure Rust XML Security library. Drop-in replacement for libxmlsec1. ## Features - **C14N** — XML Canonicalization (inclusive + exclusive, W3C compliant) -- **XMLDSig** — XML Digital Signatures (verify pipeline implemented; signing in progress, enveloped/enveloping/detached) +- **XMLDSig** — XML Digital Signatures (verify pipeline + template signing implemented; broader signing interop in progress) - **XMLEnc** — XML Encryption (symmetric + asymmetric) - **X.509** — Certificate-based key extraction and validation @@ -39,13 +39,15 @@ Currently implemented (core paths): - C14N 1.0, C14N 1.1, and Exclusive C14N - XMLDSig parsing, same-document URI dereference, transform chains, and digest verification - XMLDSig full verify pipeline (`SignedInfo` canonicalization + `SignatureValue` verification) +- XMLDSig template signing pipeline (`DigestValue` fill + `SignedInfo` canonicalization + `SignatureValue` fill) - Built-in verification-key resolution from embedded X.509/DER/`KeyValue` sources and configured `KeyName`, X.509 subject, issuer/serial, SKI, or digest selectors - RSA PKCS#1 v1.5 verification helpers for SHA-1 / SHA-256 / SHA-384 / SHA-512 - ECDSA verification helpers for P-256/SHA-256 and P-384/SHA-384 +- RSA PKCS#1 v1.5 and ECDSA P-256/P-384 signing from PKCS#8 private keys - Opt-in X.509 certificate-chain validation with explicit trust anchors, validity checks, CA constraints, and CRLs Still in progress: -- XMLDSig signing pipeline +- XMLDSig signing KeyInfo writer, examples, and broader donor/CLI interop coverage - XMLEnc encryption/decryption pipeline Current toolchain target: latest stable Rust. diff --git a/src/xmldsig/sign.rs b/src/xmldsig/sign.rs index 240cdf2..0895809 100644 --- a/src/xmldsig/sign.rs +++ b/src/xmldsig/sign.rs @@ -6,13 +6,15 @@ //! must continue to reject empty or malformed stored digest values. use base64::Engine; +use crypto_common::getrandom::SysRng; use p256::ecdsa::{Signature as P256Signature, SigningKey as P256SigningKey}; use p256::pkcs8::DecodePrivateKey; use p384::ecdsa::{Signature as P384Signature, SigningKey as P384SigningKey}; use roxmltree::{Document, Node}; use rsa::RsaPrivateKey; +use rsa::pkcs1v15::Signature as RsaPkcs1v15Signature; use rsa::pkcs1v15::SigningKey as RsaPkcs1v15SigningKey; -use rsa::signature::{SignatureEncoding, Signer}; +use rsa::signature::{RandomizedSigner, SignatureEncoding, Signer}; use sha2::{Sha256, Sha384, Sha512}; use std::collections::HashSet; @@ -136,6 +138,10 @@ pub enum SigningKeyError { /// XMLDSig signature algorithm URI. uri: String, }, + + /// The private-key signing operation failed. + #[error("private-key signing operation failed")] + SigningFailed, } /// Private key abstraction used by [`SignContext`]. @@ -175,21 +181,18 @@ impl SigningKey for RsaSigningKey { canonical_signed_info: &[u8], ) -> Result, SigningKeyError> { match algorithm { - SignatureAlgorithm::RsaSha256 => { - Ok(RsaPkcs1v15SigningKey::::new(self.key.clone()) - .sign(canonical_signed_info) - .to_vec()) - } - SignatureAlgorithm::RsaSha384 => { - Ok(RsaPkcs1v15SigningKey::::new(self.key.clone()) - .sign(canonical_signed_info) - .to_vec()) - } - SignatureAlgorithm::RsaSha512 => { - Ok(RsaPkcs1v15SigningKey::::new(self.key.clone()) - .sign(canonical_signed_info) - .to_vec()) - } + SignatureAlgorithm::RsaSha256 => sign_rsa_pkcs1v15_with_rng( + RsaPkcs1v15SigningKey::::new(self.key.clone()), + canonical_signed_info, + ), + SignatureAlgorithm::RsaSha384 => sign_rsa_pkcs1v15_with_rng( + RsaPkcs1v15SigningKey::::new(self.key.clone()), + canonical_signed_info, + ), + SignatureAlgorithm::RsaSha512 => sign_rsa_pkcs1v15_with_rng( + RsaPkcs1v15SigningKey::::new(self.key.clone()), + canonical_signed_info, + ), _ => Err(SigningKeyError::UnsupportedAlgorithm { uri: algorithm.uri().to_string(), }), @@ -197,6 +200,16 @@ impl SigningKey for RsaSigningKey { } } +fn sign_rsa_pkcs1v15_with_rng( + key: impl RandomizedSigner, + canonical_signed_info: &[u8], +) -> Result, SigningKeyError> { + let signature = key + .try_sign_with_rng(&mut SysRng, canonical_signed_info) + .map_err(|_| SigningKeyError::SigningFailed)?; + Ok(signature.to_vec()) +} + /// ECDSA P-256 private key for XMLDSig signing. pub struct EcdsaP256SigningKey { key: P256SigningKey, @@ -228,7 +241,10 @@ impl SigningKey for EcdsaP256SigningKey { uri: algorithm.uri().to_string(), }); } - let signature: P256Signature = self.key.sign(canonical_signed_info); + let signature: P256Signature = self + .key + .try_sign(canonical_signed_info) + .map_err(|_| SigningKeyError::SigningFailed)?; Ok(signature.to_bytes().to_vec()) } } @@ -264,7 +280,10 @@ impl SigningKey for EcdsaP384SigningKey { uri: algorithm.uri().to_string(), }); } - let signature: P384Signature = self.key.sign(canonical_signed_info); + let signature: P384Signature = self + .key + .try_sign(canonical_signed_info) + .map_err(|_| SigningKeyError::SigningFailed)?; Ok(signature.to_bytes().to_vec()) } } diff --git a/tests/signing_digest.rs b/tests/signing_digest.rs index 1f5dfdc..c982791 100644 --- a/tests/signing_digest.rs +++ b/tests/signing_digest.rs @@ -4,9 +4,10 @@ use xml_sec::xmldsig::parse::{find_signature_node, parse_signed_info}; use xml_sec::xmldsig::uri::UriReferenceResolver; use xml_sec::xmldsig::verify::process_all_references; use xml_sec::xmldsig::{ - DigestAlgorithm, DsigStatus, EcdsaP256SigningKey, ReferenceBuilder, RsaSigningKey, SignContext, - SignatureAlgorithm, SignatureBuilder, SigningDigestError, Transform, - compute_reference_digest_values, fill_reference_digest_values, verify_signature_with_pem_key, + DigestAlgorithm, DsigStatus, EcdsaP256SigningKey, EcdsaP384SigningKey, ReferenceBuilder, + RsaSigningKey, SignContext, SignatureAlgorithm, SignatureBuilder, SigningDigestError, + Transform, compute_reference_digest_values, fill_reference_digest_values, + verify_signature_with_pem_key, }; fn exclusive_c14n() -> C14nAlgorithm { @@ -201,3 +202,33 @@ fn signs_ecdsa_p256_template_and_verifies_round_trip() { assert!(signed.contains("")); assert!(!signed.contains("")); } + +#[test] +fn signs_ecdsa_p384_template_and_verifies_round_trip() { + // P-384 uses the XMLDSig ecdsa-sha384 URI and the same fixed-width r||s + // SignatureValue encoding as P-256, with a wider component size. + let private_key = EcdsaP384SigningKey::from_pkcs8_pem(&read_fixture( + "tests/fixtures/keys/ec/ec-prime384v1-key.pem", + )) + .expect("P-384 private key fixture must parse"); + let public_key_pem = read_fixture("tests/fixtures/keys/ec/ec-prime384v1-pubkey.pem"); + let builder = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::EcdsaP384Sha384) + .add_reference( + ReferenceBuilder::new(DigestAlgorithm::Sha384) + .uri("#payload") + .transform(Transform::C14n(exclusive_c14n())), + ); + + let signed = SignContext::new(&private_key) + .sign_with_builder( + "hello", + &builder, + ) + .expect("P-384 signing pipeline must succeed"); + let verify_result = verify_signature_with_pem_key(&signed, &public_key_pem, true) + .expect("signed P-384 XML must verify without pipeline errors"); + + assert_eq!(verify_result.status, DsigStatus::Valid); + assert!(signed.contains("")); + assert!(!signed.contains("")); +} From c764d1229e1220852424f65a9c7d82e768e38624 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 10 Jul 2026 10:11:48 +0300 Subject: [PATCH 4/7] fix(xmldsig): enable signing entropy feature --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 5626461..6bd30f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ subtle = { version = "2", optional = true } x509-parser = { version = "0.18", features = ["verify"], optional = true } der = { version = "0.8", optional = true } crypto-bigint = { version = "0.7", optional = true } -crypto-common = { version = "0.2", optional = true } +crypto-common = { version = "0.2", features = ["getrandom"], optional = true } # Base64 encoding/decoding base64 = "0.22" From 646b92fd591d237ca8c5ab2d38343f663ce6379e Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 10 Jul 2026 10:33:44 +0300 Subject: [PATCH 5/7] fix(xmldsig): import system rng directly --- Cargo.toml | 4 ++-- src/xmldsig/sign.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6bd30f8..0fb63b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,12 +26,12 @@ p384 = { version = "0.14", 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 } +getrandom = { version = "0.4", features = ["sys_rng"], 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 } -crypto-common = { version = "0.2", features = ["getrandom"], optional = true } # Base64 encoding/decoding base64 = "0.22" @@ -48,7 +48,7 @@ default = ["xmldsig", "c14n"] xmldsig = [ # XML Digital Signatures (sign + verify) "dep:der", "dep:crypto-bigint", - "dep:crypto-common", + "dep:getrandom", "dep:p256", "dep:p384", "dep:p521", diff --git a/src/xmldsig/sign.rs b/src/xmldsig/sign.rs index 0895809..3d13f55 100644 --- a/src/xmldsig/sign.rs +++ b/src/xmldsig/sign.rs @@ -6,7 +6,7 @@ //! must continue to reject empty or malformed stored digest values. use base64::Engine; -use crypto_common::getrandom::SysRng; +use getrandom::SysRng; use p256::ecdsa::{Signature as P256Signature, SigningKey as P256SigningKey}; use p256::pkcs8::DecodePrivateKey; use p384::ecdsa::{Signature as P384Signature, SigningKey as P384SigningKey}; From 16e79dde3d4bbb7460a5c20d5236c711c0eff93e Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 10 Jul 2026 11:03:07 +0300 Subject: [PATCH 6/7] fix(xmldsig): scope signing digest replacement --- src/xmldsig/mutation.rs | 105 ++++++++++++++++++++++++++++++++++++++-- src/xmldsig/sign.rs | 5 +- tests/signing_digest.rs | 26 ++++++++++ 3 files changed, 130 insertions(+), 6 deletions(-) diff --git a/src/xmldsig/mutation.rs b/src/xmldsig/mutation.rs index 0d0b9cd..af5346b 100644 --- a/src/xmldsig/mutation.rs +++ b/src/xmldsig/mutation.rs @@ -113,6 +113,31 @@ where fill_dsig_values(xml, "DigestValue", values) } +/// Fill `` elements for direct `/` children. +pub fn fill_signed_info_digest_values( + xml: &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_signed_info_digest_values(xml)?; + if expected != values.len() { + return Err(XmlMutationError::ValueCountMismatch { + element: "DigestValue", + expected, + actual: values.len(), + }); + } + + fill_dsig_values_matching(xml, "DigestValue", values, is_signed_info_reference_context) +} + /// Fill XMLDSig `` elements in document order. pub fn fill_signature_values(xml: &str, values: I) -> Result where @@ -144,11 +169,21 @@ where }); } + fill_dsig_values_matching(xml, local_name, values, |_, _| true) +} + +fn fill_dsig_values_matching( + xml: &str, + local_name: &'static str, + values: Vec, + mut should_replace: impl FnMut(&[(bool, Vec)], &ResolveResult<'_>) -> bool, +) -> Result { 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; + let mut element_stack: Vec<(bool, Vec)> = Vec::new(); loop { let (namespace, event) = reader.read_resolved_event_into(&mut buf)?; @@ -158,6 +193,7 @@ where Event::End(end) if *depth == 0 => { writer.write_event(Event::End(end))?; replacing_depth = None; + element_stack.pop(); } Event::End(_) => *depth -= 1, Event::Eof => break, @@ -169,21 +205,39 @@ where match event { Event::Start(element) - if is_dsig_element(&namespace, element.local_name().as_ref(), local_name) => + if is_dsig_element(&namespace, element.local_name().as_ref(), local_name) + && should_replace(&element_stack, &namespace) => { + element_stack.push(( + is_dsig_namespace(&namespace), + element.local_name().as_ref().to_vec(), + )); 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) => + if is_dsig_element(&namespace, element.local_name().as_ref(), local_name) + && should_replace(&element_stack, &namespace) => { 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::Start(element) => { + element_stack.push(( + is_dsig_namespace(&namespace), + element.local_name().as_ref().to_vec(), + )); + writer.write_event(Event::Start(element))?; + } + Event::Empty(element) => writer.write_event(Event::Empty(element))?, + Event::End(element) => { + element_stack.pop(); + writer.write_event(Event::End(element))?; + } Event::Eof => break, event => writer.write_event(event)?, } @@ -193,7 +247,7 @@ where if value_index != values.len() { return Err(XmlMutationError::ValueCountMismatch { element: local_name, - expected, + expected: values.len(), actual: value_index, }); } @@ -225,9 +279,52 @@ fn count_dsig_elements(xml: &str, local_name: &str) -> Result Result { + let document = roxmltree::Document::parse(xml)?; + Ok(document + .descendants() + .filter(|node| is_direct_signed_info_reference_digest(*node)) + .count()) +} + +fn is_direct_signed_info_reference_digest(node: roxmltree::Node<'_, '_>) -> bool { + node.is_element() + && node.tag_name().namespace() == Some(XMLDSIG_NS) + && node.tag_name().name() == "DigestValue" + && node + .parent() + .is_some_and(|parent| is_dsig_node(parent, "Reference")) + && node + .parent() + .and_then(|parent| parent.parent()) + .is_some_and(|grandparent| is_dsig_node(grandparent, "SignedInfo")) +} + +fn is_dsig_node(node: roxmltree::Node<'_, '_>, expected_local: &str) -> bool { + node.is_element() + && node.tag_name().namespace() == Some(XMLDSIG_NS) + && node.tag_name().name() == expected_local +} + +fn is_signed_info_reference_context( + element_stack: &[(bool, Vec)], + namespace: &ResolveResult<'_>, +) -> bool { + is_dsig_namespace(namespace) + && matches!( + element_stack, + [.., (true, signed_info), (true, reference)] + if signed_info.as_slice() == b"SignedInfo" + && reference.as_slice() == b"Reference" + ) +} + fn is_dsig_element(namespace: &ResolveResult<'_>, local: &[u8], expected_local: &str) -> bool { + is_dsig_namespace(namespace) && local == expected_local.as_bytes() +} + +fn is_dsig_namespace(namespace: &ResolveResult<'_>) -> bool { matches!(namespace, ResolveResult::Bound(Namespace(ns)) if *ns == XMLDSIG_NS.as_bytes()) - && local == expected_local.as_bytes() } #[cfg(test)] diff --git a/src/xmldsig/sign.rs b/src/xmldsig/sign.rs index 3d13f55..735f3bc 100644 --- a/src/xmldsig/sign.rs +++ b/src/xmldsig/sign.rs @@ -23,7 +23,8 @@ use crate::c14n::canonicalize; use super::builder::{SignatureBuilder, SignatureBuilderError}; use super::digest::{DigestAlgorithm, compute_digest}; use super::mutation::{ - XmlMutationError, append_signature_to_root, fill_digest_values, fill_signature_values, + XmlMutationError, append_signature_to_root, fill_signature_values, + fill_signed_info_digest_values, }; use super::parse::{SignatureAlgorithm, XMLDSIG_NS, parse_signed_info}; use super::transforms::{Transform, execute_transforms, parse_transforms}; @@ -374,7 +375,7 @@ pub fn fill_reference_digest_values(xml: &str) -> Result Result<(SignatureAlgorithm, Vec), SigningError> { diff --git a/tests/signing_digest.rs b/tests/signing_digest.rs index c982791..80b3140 100644 --- a/tests/signing_digest.rs +++ b/tests/signing_digest.rs @@ -116,6 +116,32 @@ fn computes_enveloped_signature_digest_for_whole_document() { assert_reference_digests_verify(&filled); } +#[test] +fn fills_only_signed_info_reference_digest_values() { + // Manifests can contain their own DigestValue elements inside the same + // Signature. Signing the outer SignedInfo must not treat those as template + // reference slots or overwrite their existing values. + let template = template_with_reference( + ReferenceBuilder::new(DigestAlgorithm::Sha256) + .uri("#payload") + .transform(Transform::C14n(exclusive_c14n())), + ); + let template = template.replace( + "", + "keep-manifest-digest", + ); + let xml = append_signature_to_root( + "hellomanifest", + &template, + ) + .expect("append signature"); + + let filled = fill_reference_digest_values(&xml).expect("fill only SignedInfo digest values"); + + assert!(filled.contains("keep-manifest-digest")); + assert_reference_digests_verify(&filled); +} + #[test] fn rejects_reference_without_uri() { // External/object reference support is not implicit: signing must know what From 8534220e7fe202e085ced462edf15a7380b2036c Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 10 Jul 2026 11:12:26 +0300 Subject: [PATCH 7/7] fix(xmldsig): scope signature replacement --- src/xmldsig/mutation.rs | 45 +++++++++++++++++++++++++++++++++++++++++ src/xmldsig/sign.rs | 4 ++-- tests/signing_digest.rs | 36 +++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/xmldsig/mutation.rs b/src/xmldsig/mutation.rs index af5346b..77bfe9c 100644 --- a/src/xmldsig/mutation.rs +++ b/src/xmldsig/mutation.rs @@ -147,6 +147,25 @@ where fill_dsig_values(xml, "SignatureValue", values) } +/// Fill the direct `/` child for a signing template. +pub fn fill_signature_value(xml: &str, value: &str) -> Result { + let expected = count_direct_signature_values(xml)?; + if expected != 1 { + return Err(XmlMutationError::ValueCountMismatch { + element: "SignatureValue", + expected, + actual: 1, + }); + } + + fill_dsig_values_matching( + xml, + "SignatureValue", + vec![value.to_owned()], + is_direct_signature_context, + ) +} + fn fill_dsig_values( xml: &str, local_name: &'static str, @@ -287,6 +306,21 @@ fn count_signed_info_digest_values(xml: &str) -> Result .count()) } +fn count_direct_signature_values(xml: &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() == "SignatureValue" + && node + .parent() + .is_some_and(|parent| is_dsig_node(parent, "Signature")) + }) + .count()) +} + fn is_direct_signed_info_reference_digest(node: roxmltree::Node<'_, '_>) -> bool { node.is_element() && node.tag_name().namespace() == Some(XMLDSIG_NS) @@ -319,6 +353,17 @@ fn is_signed_info_reference_context( ) } +fn is_direct_signature_context( + element_stack: &[(bool, Vec)], + namespace: &ResolveResult<'_>, +) -> bool { + is_dsig_namespace(namespace) + && matches!( + element_stack, + [.., (true, signature)] if signature.as_slice() == b"Signature" + ) +} + fn is_dsig_element(namespace: &ResolveResult<'_>, local: &[u8], expected_local: &str) -> bool { is_dsig_namespace(namespace) && local == expected_local.as_bytes() } diff --git a/src/xmldsig/sign.rs b/src/xmldsig/sign.rs index 735f3bc..b7d75f0 100644 --- a/src/xmldsig/sign.rs +++ b/src/xmldsig/sign.rs @@ -23,7 +23,7 @@ use crate::c14n::canonicalize; use super::builder::{SignatureBuilder, SignatureBuilderError}; use super::digest::{DigestAlgorithm, compute_digest}; use super::mutation::{ - XmlMutationError, append_signature_to_root, fill_signature_values, + XmlMutationError, append_signature_to_root, fill_signature_value, fill_signed_info_digest_values, }; use super::parse::{SignatureAlgorithm, XMLDSIG_NS, parse_signed_info}; @@ -311,7 +311,7 @@ impl<'a> SignContext<'a> { let (algorithm, canonical_signed_info) = canonicalize_signed_info(&with_digests)?; let signature_value = self.signing_key.sign(algorithm, &canonical_signed_info)?; let signature_b64 = base64::engine::general_purpose::STANDARD.encode(signature_value); - Ok(fill_signature_values(&with_digests, [signature_b64])?) + Ok(fill_signature_value(&with_digests, &signature_b64)?) } /// Build a signature template, append it to the source root, then sign it. diff --git a/tests/signing_digest.rs b/tests/signing_digest.rs index 80b3140..a0949e4 100644 --- a/tests/signing_digest.rs +++ b/tests/signing_digest.rs @@ -199,6 +199,42 @@ fn signs_rsa_sha256_template_and_verifies_round_trip() { assert!(!signed.contains("")); } +#[test] +fn signing_fills_only_top_level_signature_value() { + // Object payloads may contain SignatureValue-named XMLDSig elements. The + // signing pass must fill only the direct child of the selected Signature. + let private_key = + RsaSigningKey::from_pkcs8_pem(&read_fixture("tests/fixtures/keys/rsa/rsa-2048-key.pem")) + .expect("RSA private key fixture must parse"); + let public_key_pem = read_fixture("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"); + let template = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::RsaSha256) + .add_reference( + ReferenceBuilder::new(DigestAlgorithm::Sha256) + .uri("#payload") + .transform(Transform::C14n(exclusive_c14n())), + ) + .build_template() + .expect("valid signature template") + .replace( + "", + "keep-object-signature", + ); + let xml = append_signature_to_root( + "hello", + &template, + ) + .expect("append signature"); + + let signed = SignContext::new(&private_key) + .sign_template(&xml) + .expect("signing must ignore object SignatureValue"); + let verify_result = verify_signature_with_pem_key(&signed, &public_key_pem, true) + .expect("signed RSA XML must verify without pipeline errors"); + + assert_eq!(verify_result.status, DsigStatus::Valid); + assert!(signed.contains("keep-object-signature")); +} + #[test] fn signs_ecdsa_p256_template_and_verifies_round_trip() { // ECDSA XMLDSig SignatureValue must be fixed-width r||s bytes, not ASN.1