From 164a9bb4e98dea9c7e484af404bddad90314c4b0 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Tue, 4 Aug 2026 16:14:55 +0300 Subject: [PATCH 01/26] feat(xmldsig): complete Merlin interop - add DSA-SHA1 and HMAC-SHA1 verification paths - resolve bounded external references and X.509 key retrieval - cover all Merlin documents, references, and failure policies - update dependency requirements and public support documentation Closes #105 --- Cargo.toml | 8 +- README.md | 6 +- docs/xmldsig.md | 22 +- src/xmldsig/keys.rs | 149 +++++++- src/xmldsig/mod.rs | 14 +- src/xmldsig/parse.rs | 292 +++++++++++++++- src/xmldsig/signature.rs | 58 +++- src/xmldsig/types.rs | 31 ++ src/xmldsig/uri.rs | 72 ++-- src/xmldsig/verify.rs | 335 ++++++++++++++++-- src/xmldsig/x509.rs | 38 +- src/xmldsig/xpath.rs | 8 + tests/donor_full_verification_suite.rs | 166 ++------- tests/merlin_interop.rs | 462 +++++++++++++++++++++++++ tests/uri_integration.rs | 9 +- 15 files changed, 1422 insertions(+), 248 deletions(-) create mode 100644 tests/merlin_interop.rs diff --git a/Cargo.toml b/Cargo.toml index a9a9ca60..e52f7f0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,12 +28,14 @@ sha2 = { version = "0.11", features = ["oid"], optional = true } p256 = { version = "0.14", features = ["ecdsa"], optional = true } p384 = { version = "0.14", features = ["ecdsa"], optional = true } p521 = { version = "0.14", features = ["ecdsa"], optional = true } +dsa = { version = "0.7", optional = true } +hmac = { version = "0.13", optional = true } signature = { version = "3", optional = true } subtle = { version = "2", optional = true } getrandom = { version = "0.4", features = ["sys_rng"], optional = true } sxd-document-no-unsafe = { version = "0.4.1", default-features = false, features = ["no-unsafe"], optional = true } sxd-xpath-no-unsafe = { version = "0.5.1", default-features = false, features = ["no-unsafe"], optional = true } -aes = { version = "0.9.1", optional = true } +aes = { version = "0.9.2", optional = true } aes-gcm = { version = "0.11.0", optional = true } aes-kw = { version = "0.3.1", optional = true } cbc = { version = "0.2.1", optional = true } @@ -52,14 +54,16 @@ thiserror = "2" [dev-dependencies] rcgen = "0.14.6" rand_chacha = "0.10" -time = "0.3.53" +time = "0.3.55" [features] default = ["xmldsig", "c14n"] xmldsig = [ # XML Digital Signatures (sign + verify) "dep:der", "dep:crypto-bigint", + "dep:dsa", "dep:getrandom", + "dep:hmac", "dep:p256", "dep:p384", "dep:p521", diff --git a/README.md b/README.md index dd14c3cf..a452013e 100644 --- a/README.md +++ b/README.md @@ -43,14 +43,16 @@ Currently implemented (core paths): - 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 +- Legacy DSA-SHA1 and HMAC-SHA1 verification, including truncated HMAC output - 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 +- Caller-supplied, bounded external references and X.509 `RetrievalMethod` resolution without implicit I/O - XMLEnc AES-128/256-CBC and AES-128/256-GCM encryption/decryption with direct keys, RSA-OAEP key transport, AES-128/256-KW, multiple recipients, and Element/Content document replacement Still in progress: -- XMLDSig DSA, HMAC, and RSA-PSS signature algorithms +- XMLDSig DSA-SHA256, broader HMAC verification/signing, and RSA-PSS algorithms - Complete XMLDSig and XMLEnc conformance-suite classification - Production hardening, fuzzing, benchmarks, and API stabilization @@ -100,7 +102,7 @@ Current MSRV: Rust 1.92. | [Canonical XML 1.0](https://www.w3.org/TR/xml-c14n/) | Implemented; full-document and document-subset vectors | | [Canonical XML 1.1](https://www.w3.org/TR/xml-c14n11/) | Implemented; `xml:id` and `xml:base` subset rules | | [Exclusive C14N](https://www.w3.org/TR/xml-exc-c14n/) | Implemented; `InclusiveNamespaces PrefixList` support | -| [XMLDSig](https://www.w3.org/TR/xmldsig-core1/) | Core sign/verify pipelines implemented; additional algorithms and conformance coverage in progress | +| [XMLDSig](https://www.w3.org/TR/xmldsig-core1/) | Core sign/verify pipelines and the complete Merlin corpus implemented; additional algorithms and conformance suites in progress | | [XMLEnc](https://www.w3.org/TR/xmlenc-core1/) | Core AES-CBC/GCM encrypt/decrypt with RSA-OAEP and AES-KW implemented; broader conformance coverage in progress | ## License diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 20fb0703..a34e169d 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -3,7 +3,8 @@ The `xmldsig` feature provides signing and verification pipelines for same-document XML signatures. It supports inclusive and exclusive canonicalization, enveloped signatures, Base64, XPath 1.0, and XPath Filter 2.0 transforms, RSA PKCS#1 v1.5, ECDSA P-256/P-384, -embedded X.509 certificates, and configured key resolution. +DSA-SHA1 and HMAC-SHA1 verification, embedded X.509 certificates, and configured key +resolution. ## Examples @@ -45,9 +46,24 @@ inconsistent `KeyInfo` metadata are processing errors rather than validity statu `Invalid(reason)` and an API error as a rejected document; never continue an authentication flow after either outcome. +External references are disabled by default. Callers must both allow their URI class with +`UriTypeSet` and provide every payload through `VerifyContext::external_resources`; verification +never performs network or filesystem I/O. Individual resources are limited to 8 MiB and the +complete map to 32 MiB. `RetrievalMethod` currently accepts untransformed external +`rawX509Certificate` data and the Merlin same-document `X509Data` XPath selection. Other retrieval +transform chains fail closed instead of being ignored. + +Internal DTD declarations are disabled by default and require +`VerifyContext::allow_internal_dtd(true)`. External entity resolution remains disabled. XSLT is +intentionally not executed because transforms operate on attacker-controlled documents; an +authenticated Manifest reference using unsupported XSLT is reported as an invalid per-reference +result without changing core `SignedInfo` validity. + ## Current Scope Implemented algorithms include RSA PKCS#1 v1.5 with SHA-1/SHA-256/SHA-384/SHA-512 for verification, SHA-256/SHA-384/SHA-512 for signing, and ECDSA P-256/SHA-256 and P-384/SHA-384. -DSA, HMAC signatures, RSA-PSS, and unauthenticated external reference loading are not currently -supported. +DSA-SHA1 and HMAC-SHA1 (including XMLDSig's byte-aligned 80-160-bit truncation range) are +verify-only legacy algorithms. +DSA-SHA256, broader HMAC verification/signing, RSA-PSS, and implicit external resource loading are +not currently supported. diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 494b16d9..eb6165a7 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -3,13 +3,15 @@ use std::{collections::HashMap, time::SystemTime}; use crypto_bigint::BoxedUint; -use p256::pkcs8::EncodePublicKey as P256EncodePublicKey; +use dsa::pkcs8::{DecodePublicKey as DsaDecodePublicKey, EncodePublicKey as DsaEncodePublicKey}; +use hmac::{KeyInit, Mac}; use x509_parser::{ prelude::{FromDer, X509Certificate}, public_key::PublicKey, x509::SubjectPublicKeyInfo, }; +use super::signature::verify_rsa_signature_spki_with_minimum; use super::{ DsigError, KeyInfo, KeyInfoSource, KeyResolver, KeyValueInfo, SignatureAlgorithm, VerifyingKey, X509ChainOptions, X509DataInfo, @@ -18,9 +20,76 @@ use super::{ x509_certificate_matches_any_selector, x509_data_has_lookup_identifiers, x509_selector_categories_match_chain, }, - verify_ecdsa_signature_spki, verify_rsa_signature_spki, verify_x509_certificate_chain, + verify_dsa_signature_spki, verify_ecdsa_signature_spki, verify_rsa_signature_spki, + verify_x509_certificate_chain, }; +/// Caller-owned HMAC-SHA1 verification key. +#[derive(Debug, Clone)] +pub struct HmacSha1VerificationKey { + secret: Vec, +} + +impl HmacSha1VerificationKey { + /// Construct a key from non-empty secret bytes. + pub fn new(secret: impl Into>) -> Result { + let secret = secret.into(); + if secret.is_empty() { + return Err(KeyResolutionError::InvalidPublicKey); + } + Ok(Self { secret }) + } +} + +impl VerifyingKey for HmacSha1VerificationKey { + fn verify( + &self, + algorithm: SignatureAlgorithm, + signed_data: &[u8], + signature_value: &[u8], + ) -> Result { + if algorithm != SignatureAlgorithm::HmacSha1 { + return Err(KeyResolutionError::AlgorithmMismatch.into()); + } + if !(10..=20).contains(&signature_value.len()) { + return Ok(false); + } + let mut mac = hmac::Hmac::::new_from_slice(&self.secret) + .map_err(|_| KeyResolutionError::InvalidPublicKey)?; + mac.update(signed_data); + let expected = mac.finalize().into_bytes(); + Ok( + subtle::ConstantTimeEq::ct_eq(&expected[..signature_value.len()], signature_value) + .into(), + ) + } +} + +struct LegacyRsaSha1VerificationKey { + public_key_bytes: Vec, +} + +impl VerifyingKey for LegacyRsaSha1VerificationKey { + fn verify( + &self, + algorithm: SignatureAlgorithm, + signed_data: &[u8], + signature_value: &[u8], + ) -> Result { + if algorithm != SignatureAlgorithm::RsaSha1 { + return Err(KeyResolutionError::AlgorithmMismatch.into()); + } + verify_rsa_signature_spki_with_minimum( + algorithm, + &self.public_key_bytes, + signed_data, + signature_value, + 1024, + ) + .map_err(DsigError::Crypto) + } +} + /// A public verification key available to key resolvers. #[derive(Debug, Clone, PartialEq, Eq)] pub struct VerificationKey { @@ -45,6 +114,15 @@ impl VerifyingKey for VerificationKey { return Err(KeyResolutionError::AlgorithmMismatch.into()); } let result = match algorithm { + SignatureAlgorithm::DsaSha1 => verify_dsa_signature_spki( + algorithm, + &self.public_key_bytes, + signed_data, + signature_value, + ), + SignatureAlgorithm::HmacSha1 => { + return Err(KeyResolutionError::AlgorithmMismatch.into()); + } SignatureAlgorithm::RsaSha1 | SignatureAlgorithm::RsaSha256 | SignatureAlgorithm::RsaSha384 @@ -107,6 +185,10 @@ pub struct KeyResolverConfig { pub named_keys: HashMap, /// Whether embedded X.509 certificate chains must terminate at a trust anchor. pub verify_chains: bool, + /// Whether embedded CRLs are authenticated and enforced during chain validation. + pub check_crls: bool, + /// Allow verify-only RSA-SHA1 keys down to 1024 bits for legacy corpora. + pub allow_legacy_rsa_sha1: bool, /// Certificate verification time override; `None` selects the system clock. pub verification_time: Option, /// Maximum certificates in a validated path, including the trust anchor. @@ -119,6 +201,8 @@ impl Default for KeyResolverConfig { trusted_certs: Vec::new(), named_keys: HashMap::new(), verify_chains: false, + check_crls: false, + allow_legacy_rsa_sha1: false, verification_time: None, max_chain_depth: 9, } @@ -216,7 +300,7 @@ impl DefaultKeyResolver { .verification_time .unwrap_or_else(SystemTime::now), max_chain_depth: self.config.max_chain_depth, - check_crls: false, + check_crls: self.config.check_crls, }; verify_x509_certificate_chain(info, &options)?; Ok(()) @@ -296,6 +380,12 @@ impl DefaultKeyResolver { algorithm: SignatureAlgorithm, ) -> Result, KeyResolutionError> { let public_key_bytes = match key_value { + KeyValueInfo::Dsa { p, q, g, y } => { + if algorithm != SignatureAlgorithm::DsaSha1 { + return Err(KeyResolutionError::AlgorithmMismatch); + } + dsa_key_value_to_spki_der(p, q, g, y)? + } KeyValueInfo::Rsa { modulus, exponent } => { if !matches!( algorithm, @@ -369,6 +459,15 @@ impl KeyResolver for DefaultKeyResolver { }) .transpose()?, KeyInfoSource::KeyValue(key_value) => { + if self.config.allow_legacy_rsa_sha1 + && algorithm == SignatureAlgorithm::RsaSha1 + && let KeyValueInfo::Rsa { modulus, exponent } = key_value + { + let public_key_bytes = rsa_key_value_to_spki_der(modulus, exponent)?; + return Ok(Some(Box::new(LegacyRsaSha1VerificationKey { + public_key_bytes, + }))); + } match Self::resolve_key_value(key_value, algorithm) { Ok(resolved) => resolved, Err(error) if ec_key_value_error_allows_fallback(key_value, &error) => { @@ -378,6 +477,7 @@ impl KeyResolver for DefaultKeyResolver { Err(error) => return Err(error.into()), } } + KeyInfoSource::RetrievalMethod { .. } => None, }; if let Some(key) = resolved { return Ok(Some(Box::new(key))); @@ -408,6 +508,25 @@ fn rsa_key_value_to_spki_der( .map(|der| der.as_bytes().to_vec()) } +fn dsa_key_value_to_spki_der( + p: &[u8], + q: &[u8], + g: &[u8], + y: &[u8], +) -> Result, KeyResolutionError> { + let components = dsa::Components::from_components( + BoxedUint::from_be_slice_vartime(p), + BoxedUint::from_be_slice_vartime(q), + BoxedUint::from_be_slice_vartime(g), + ) + .map_err(|_| KeyResolutionError::InvalidPublicKey)?; + dsa::VerifyingKey::from_components(components, BoxedUint::from_be_slice_vartime(y)) + .map_err(|_| KeyResolutionError::InvalidPublicKey)? + .to_public_key_der() + .map_err(|_| KeyResolutionError::InvalidPublicKey) + .map(|der| der.as_bytes().to_vec()) +} + fn ec_key_value_to_spki_der( curve_oid: &str, public_key: &[u8], @@ -459,6 +578,11 @@ fn validate_spki_algorithm( .and_then(|value| value.as_oid().ok()) .map(|oid| oid.to_id_string()); match (algorithm, parsed) { + (SignatureAlgorithm::DsaSha1, PublicKey::DSA(_)) => { + let _ = dsa::VerifyingKey::from_public_key_der(public_key_bytes) + .map_err(|_| KeyResolutionError::AlgorithmMismatch)?; + Ok(()) + } ( SignatureAlgorithm::RsaSha1 | SignatureAlgorithm::RsaSha256 @@ -571,10 +695,29 @@ mod tests { assert!(config.trusted_certs.is_empty()); assert!(config.named_keys.is_empty()); assert!(!config.verify_chains); + assert!(!config.check_crls); + assert!(!config.allow_legacy_rsa_sha1); assert_eq!(config.verification_time, None); assert_eq!(config.max_chain_depth, 9); } + #[test] + fn hmac_key_rejects_empty_secret_and_wrong_algorithm() { + // HMAC secrets are caller-owned and cannot be reused as asymmetric keys. + assert!(matches!( + HmacSha1VerificationKey::new(Vec::new()), + Err(KeyResolutionError::InvalidPublicKey) + )); + let key = HmacSha1VerificationKey::new(b"secret".to_vec()) + .expect("non-empty HMAC secret must be accepted"); + assert!(matches!( + key.verify(SignatureAlgorithm::RsaSha256, b"data", b"signature"), + Err(DsigError::KeyResolution( + KeyResolutionError::AlgorithmMismatch + )) + )); + } + #[test] fn stores_named_verification_key_metadata() { // Named resolution must retain every field needed by the later resolver wiring. diff --git a/src/xmldsig/mod.rs b/src/xmldsig/mod.rs index d7b58ec1..be6f22d3 100644 --- a/src/xmldsig/mod.rs +++ b/src/xmldsig/mod.rs @@ -69,10 +69,14 @@ mod xpath; pub use builder::{ReferenceBuilder, SignatureBuilder, SignatureBuilderError}; pub use digest::{DigestAlgorithm, compute_digest, constant_time_eq}; -pub use keys::{DefaultKeyResolver, KeyResolutionError, KeyResolverConfig, VerificationKey}; +pub use keys::{ + DefaultKeyResolver, HmacSha1VerificationKey, KeyResolutionError, KeyResolverConfig, + VerificationKey, +}; pub use parse::{ - KeyInfo, KeyInfoSource, KeyValueInfo, ParseError, Reference, SignatureAlgorithm, SignedInfo, - X509DataInfo, find_signature_node, parse_key_info, parse_reference, parse_signed_info, + KeyInfo, KeyInfoSource, KeyValueInfo, ParseError, Reference, RetrievalMethodTransforms, + SignatureAlgorithm, SignedInfo, X509DataInfo, find_signature_node, parse_key_info, + parse_reference, parse_signed_info, }; pub use sign::{ ComputedReferenceDigest, EcdsaP256SigningKey, EcdsaP384SigningKey, KeyInfoWriteError, @@ -81,8 +85,8 @@ pub use sign::{ 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, + SignatureVerificationError, verify_dsa_signature_spki, verify_ecdsa_signature_pem, + verify_ecdsa_signature_spki, verify_rsa_signature_pem, verify_rsa_signature_spki, }; pub use transforms::{ BASE64_TRANSFORM_URI, DEFAULT_IMPLICIT_C14N_URI, ENVELOPED_SIGNATURE_URI, Transform, diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index 9d7c8656..9f26de6b 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -56,6 +56,10 @@ pub(crate) const MAX_REFERENCES_PER_SIGNATURE: usize = 64; /// Signature algorithms supported for signing and verification. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum SignatureAlgorithm { + /// DSA with SHA-1. Verify-only legacy XMLDSig algorithm. + DsaSha1, + /// HMAC with SHA-1. Verify-only legacy XMLDSig algorithm. + HmacSha1, /// RSA with SHA-1. **Verify-only** — signing disabled. RsaSha1, /// RSA with SHA-256 (most common in SAML). @@ -80,6 +84,8 @@ impl SignatureAlgorithm { #[must_use] pub fn from_uri(uri: &str) -> Option { match uri { + "http://www.w3.org/2000/09/xmldsig#dsa-sha1" => Some(Self::DsaSha1), + "http://www.w3.org/2000/09/xmldsig#hmac-sha1" => Some(Self::HmacSha1), "http://www.w3.org/2000/09/xmldsig#rsa-sha1" => Some(Self::RsaSha1), "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" => Some(Self::RsaSha256), "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384" => Some(Self::RsaSha384), @@ -94,6 +100,8 @@ impl SignatureAlgorithm { #[must_use] pub fn uri(self) -> &'static str { match self { + Self::DsaSha1 => "http://www.w3.org/2000/09/xmldsig#dsa-sha1", + Self::HmacSha1 => "http://www.w3.org/2000/09/xmldsig#hmac-sha1", Self::RsaSha1 => "http://www.w3.org/2000/09/xmldsig#rsa-sha1", Self::RsaSha256 => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", Self::RsaSha384 => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384", @@ -106,7 +114,7 @@ impl SignatureAlgorithm { /// Whether this algorithm is allowed for signing (not just verification). #[must_use] pub fn signing_allowed(self) -> bool { - !matches!(self, Self::RsaSha1) + !matches!(self, Self::RsaSha1 | Self::DsaSha1 | Self::HmacSha1) } } @@ -117,6 +125,8 @@ pub struct SignedInfo { pub c14n_method: C14nAlgorithm, /// Signature algorithm. pub signature_method: SignatureAlgorithm, + /// Optional byte-aligned HMAC output length in bits. + pub hmac_output_length_bits: Option, /// One or more `` elements. pub references: Vec, } @@ -158,12 +168,42 @@ pub enum KeyInfoSource { X509Data(X509DataInfo), /// `dsig11:DEREncodedKeyValue` source (base64-decoded DER bytes). DerEncodedKeyValue(Vec), + /// `` URI and optional type URI. + RetrievalMethod { + /// Resource URI. + uri: String, + /// Declared resource type. + resource_type: Option, + /// Supported transform shape declared by the retrieval method. + transforms: RetrievalMethodTransforms, + }, +} + +/// Transform forms accepted on ``. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum RetrievalMethodTransforms { + /// No transform chain is present. + None, + /// Select the `ds:X509Data` ancestor-or-self node from a same-document object. + X509DataAncestor, } /// Parsed `` dispatch result. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum KeyValueInfo { + /// `` public parameters. + Dsa { + /// Prime modulus P. + p: Vec, + /// Prime divisor Q. + q: Vec, + /// Generator G. + g: Vec, + /// Public value Y. + y: Vec, + }, /// `` with unsigned big-endian CryptoBinary parameters. Rsa { /// RSA modulus. @@ -368,6 +408,7 @@ pub(crate) fn parse_signed_info_with_xpath_budget( SignatureAlgorithm::from_uri(sig_uri).ok_or_else(|| ParseError::UnsupportedAlgorithm { uri: sig_uri.to_string(), })?; + let hmac_output_length_bits = parse_hmac_output_length(sig_method_node, signature_method)?; // 3. One or more Reference elements let mut references = Vec::new(); @@ -389,10 +430,44 @@ pub(crate) fn parse_signed_info_with_xpath_budget( Ok(SignedInfo { c14n_method, signature_method, + hmac_output_length_bits, references, }) } +fn parse_hmac_output_length( + node: Node<'_, '_>, + algorithm: SignatureAlgorithm, +) -> Result, ParseError> { + ensure_no_non_whitespace_text(node, "SignatureMethod")?; + let mut children = element_children(node); + let Some(child) = children.next() else { + return Ok(None); + }; + if algorithm != SignatureAlgorithm::HmacSha1 + || child.tag_name().namespace() != Some(XMLDSIG_NS) + || child.tag_name().name() != "HMACOutputLength" + || children.next().is_some() + { + return Err(ParseError::InvalidStructure( + "SignatureMethod parameters do not match the selected algorithm".into(), + )); + } + ensure_no_element_children(child, "HMACOutputLength")?; + let bits = child + .text() + .unwrap_or_default() + .trim() + .parse::() + .map_err(|_| ParseError::InvalidStructure("invalid HMACOutputLength".into()))?; + if !(80..=160).contains(&bits) || !bits.is_multiple_of(8) { + return Err(ParseError::InvalidStructure( + "HMACOutputLength must be a byte-aligned value from 80 through 160".into(), + )); + } + Ok(Some(bits)) +} + /// Parse a single `` element. /// /// Structure: `?` → `` → `` @@ -417,12 +492,16 @@ pub(crate) fn parse_reference_with_xpath_budget( // Optional let mut transforms = Vec::new(); + let mut transform_error = None; let mut next = children.next().ok_or(ParseError::MissingElement { element: "DigestMethod", })?; if next.tag_name().name() == "Transforms" && next.tag_name().namespace() == Some(XMLDSIG_NS) { - transforms = transforms::parse_transforms_with_budget(next, xpath_budget)?; + match transforms::parse_transforms_with_budget(next, xpath_budget) { + Ok(parsed) => transforms = parsed, + Err(error) => transform_error = Some(error), + } next = children.next().ok_or(ParseError::MissingElement { element: "DigestMethod", })?; @@ -451,6 +530,13 @@ pub(crate) fn parse_reference_with_xpath_budget( ))); } + // Validate the complete Reference before reporting an unsupported transform. + // This prevents malformed DigestMethod/DigestValue content from being + // downgraded to a non-fatal unsupported Manifest transform result. + if let Some(error) = transform_error { + return Err(ParseError::Transform(error)); + } + Ok(Reference { uri, id, @@ -461,6 +547,26 @@ pub(crate) fn parse_reference_with_xpath_budget( }) } +pub(crate) fn reference_digest_method( + reference_node: Node<'_, '_>, +) -> Result { + verify_ds_element(reference_node, "Reference")?; + let mut children = element_children(reference_node); + let mut next = children.next().ok_or(ParseError::MissingElement { + element: "DigestMethod", + })?; + if next.tag_name().namespace() == Some(XMLDSIG_NS) && next.tag_name().name() == "Transforms" { + next = children.next().ok_or(ParseError::MissingElement { + element: "DigestMethod", + })?; + } + verify_ds_element(next, "DigestMethod")?; + let uri = required_algorithm_attr(next, "DigestMethod")?; + DigestAlgorithm::from_uri(uri).ok_or_else(|| ParseError::UnsupportedAlgorithm { + uri: uri.to_owned(), + }) +} + /// Parse `` and dispatch supported child sources. /// /// Supported source elements: @@ -494,6 +600,23 @@ pub fn parse_key_info(key_info_node: Node) -> Result { let x509 = parse_x509_data_dispatch(child)?; sources.push(KeyInfoSource::X509Data(x509)); } + (Some(XMLDSIG_NS), "RetrievalMethod") => { + ensure_no_non_whitespace_text(child, "RetrievalMethod")?; + let uri = child.attribute("URI").ok_or_else(|| { + ParseError::InvalidStructure("RetrievalMethod requires URI".into()) + })?; + if uri.len() > MAX_KEY_NAME_TEXT_LEN { + return Err(ParseError::InvalidStructure( + "RetrievalMethod URI exceeds maximum length".into(), + )); + } + let transforms = parse_retrieval_method_transforms(child)?; + sources.push(KeyInfoSource::RetrievalMethod { + uri: uri.to_string(), + resource_type: child.attribute("Type").map(str::to_string), + transforms, + }); + } (Some(XMLDSIG11_NS), "DEREncodedKeyValue") => { ensure_no_element_children(child, "DEREncodedKeyValue")?; let der = decode_der_encoded_key_value_base64(child)?; @@ -506,6 +629,54 @@ pub fn parse_key_info(key_info_node: Node) -> Result { Ok(KeyInfo { sources }) } +fn parse_retrieval_method_transforms( + node: Node<'_, '_>, +) -> Result { + let mut children = element_children(node); + let Some(transforms) = children.next() else { + return Ok(RetrievalMethodTransforms::None); + }; + if children.next().is_some() + || transforms.tag_name().namespace() != Some(XMLDSIG_NS) + || transforms.tag_name().name() != "Transforms" + { + return Err(ParseError::InvalidStructure( + "RetrievalMethod accepts only one optional ds:Transforms child".into(), + )); + } + ensure_no_non_whitespace_text(transforms, "Transforms")?; + let mut transform_children = element_children(transforms); + let transform = transform_children.next().ok_or_else(|| { + ParseError::InvalidStructure("RetrievalMethod Transforms must not be empty".into()) + })?; + if transform_children.next().is_some() + || transform.tag_name().namespace() != Some(XMLDSIG_NS) + || transform.tag_name().name() != "Transform" + || transform.attribute("Algorithm") != Some(transforms::XPATH_TRANSFORM_URI) + { + return Err(ParseError::InvalidStructure( + "unsupported RetrievalMethod transform chain".into(), + )); + } + ensure_no_non_whitespace_text(transform, "Transform")?; + let mut parameters = element_children(transform); + let xpath = parameters.next().ok_or_else(|| { + ParseError::InvalidStructure("RetrievalMethod XPath parameter is missing".into()) + })?; + if parameters.next().is_some() + || xpath.tag_name().namespace() != Some(XMLDSIG_NS) + || xpath.tag_name().name() != "XPath" + || xpath.text().unwrap_or_default().trim() != "ancestor-or-self::dsig:X509Data" + || xpath.lookup_namespace_uri(Some("dsig")) != Some(XMLDSIG_NS) + { + return Err(ParseError::InvalidStructure( + "unsupported RetrievalMethod XPath selection".into(), + )); + } + ensure_no_element_children(xpath, "XPath")?; + Ok(RetrievalMethodTransforms::X509DataAncestor) +} + // ── Helpers ────────────────────────────────────────────────────────────────── /// Iterate only element children (skip text, comments, PIs). @@ -613,6 +784,7 @@ fn parse_key_value_dispatch(node: Node) -> Result { first_child.tag_name().name(), ) { (Some(XMLDSIG_NS), "RSAKeyValue") => parse_rsa_key_value(first_child), + (Some(XMLDSIG_NS), "DSAKeyValue") => parse_dsa_key_value(first_child), (Some(XMLDSIG11_NS), "ECKeyValue") => parse_ec_key_value(first_child), (namespace, child_name) => Ok(KeyValueInfo::Unsupported { namespace: namespace.map(str::to_string), @@ -621,6 +793,30 @@ fn parse_key_value_dispatch(node: Node) -> Result { } } +fn parse_dsa_key_value(node: Node<'_, '_>) -> Result { + verify_ds_element(node, "DSAKeyValue")?; + ensure_no_non_whitespace_text(node, "DSAKeyValue")?; + let mut children = element_children(node); + let mut next = |name| -> Result, ParseError> { + let child = children + .next() + .ok_or_else(|| ParseError::InvalidStructure(format!("DSAKeyValue requires {name}")))?; + verify_ds_element(child, name)?; + ensure_no_element_children(child, name)?; + decode_crypto_binary(child, name, MAX_RSA_MODULUS_LEN) + }; + let p = next("P")?; + let q = next("Q")?; + let g = next("G")?; + let y = next("Y")?; + if children.next().is_some() { + return Err(ParseError::InvalidStructure( + "DSAKeyValue supports exactly P, Q, G, and Y".into(), + )); + } + Ok(KeyValueInfo::Dsa { p, q, g, y }) +} + fn parse_ec_key_value(node: Node<'_, '_>) -> Result { verify_dsig11_element(node, "ECKeyValue")?; ensure_no_non_whitespace_text(node, "ECKeyValue")?; @@ -792,7 +988,7 @@ fn decode_crypto_binary( Ok(value) } -fn parse_x509_data_dispatch(node: Node) -> Result { +pub(crate) fn parse_x509_data_dispatch(node: Node) -> Result { verify_ds_element(node, "X509Data")?; ensure_no_non_whitespace_text(node, "X509Data")?; @@ -1001,7 +1197,7 @@ pub(crate) fn x509_certificate_matches_any_selector( let subject_match = info .subject_names .iter() - .any(|subject| subject.trim() == certificate.subject_dn); + .any(|subject| distinguished_names_equal(subject, &certificate.subject_dn)); let mut issuer_serial_match = false; for (issuer, serial) in &info.issuer_serials { let serial_hex = x509_serial_decimal_to_hex(serial).ok_or_else(|| { @@ -1009,8 +1205,8 @@ pub(crate) fn x509_certificate_matches_any_selector( "X509Data lookup identifiers contain an invalid serial number".into(), ) })?; - issuer_serial_match |= - issuer.trim() == certificate.issuer_dn && serial_hex == certificate.serial_number_hex; + issuer_serial_match |= distinguished_names_equal(issuer, &certificate.issuer_dn) + && serial_hex == certificate.serial_number_hex; } let ski_match = certificate .subject_key_identifier @@ -1034,7 +1230,7 @@ pub(crate) fn x509_selector_categories_match_chain( let subject_match = info.subject_names.iter().all(|subject| { info.parsed_certificates .iter() - .any(|certificate| subject.trim() == certificate.subject_dn) + .any(|certificate| distinguished_names_equal(subject, &certificate.subject_dn)) }); let mut issuer_serial_match = true; @@ -1045,7 +1241,8 @@ pub(crate) fn x509_selector_categories_match_chain( ) })?; issuer_serial_match &= info.parsed_certificates.iter().any(|certificate| { - issuer.trim() == certificate.issuer_dn && serial_hex == certificate.serial_number_hex + distinguished_names_equal(issuer, &certificate.issuer_dn) + && serial_hex == certificate.serial_number_hex }); } @@ -1074,6 +1271,19 @@ pub(crate) fn x509_selector_categories_match_chain( Ok(subject_match && issuer_serial_match && ski_match && digest_match) } +fn distinguished_names_equal(left: &str, right: &str) -> bool { + fn components(name: &str) -> Vec<&str> { + name.trim() + .split(',') + .map(str::trim) + .filter(|component| !component.is_empty()) + .collect() + } + let left = components(left); + let right = components(right); + left == right || left.iter().eq(right.iter().rev()) +} + fn ensure_x509_data_entry_budget(info: &X509DataInfo) -> Result<(), ParseError> { let total_entries = info.certificates.len() + info.subject_names.len() @@ -1550,6 +1760,8 @@ mod tests { #[test] fn signature_algorithm_uri_round_trip() { for algo in [ + SignatureAlgorithm::DsaSha1, + SignatureAlgorithm::HmacSha1, SignatureAlgorithm::RsaSha1, SignatureAlgorithm::RsaSha256, SignatureAlgorithm::RsaSha384, @@ -1566,7 +1778,9 @@ mod tests { } #[test] - fn rsa_sha1_verify_only() { + fn legacy_algorithms_are_verify_only() { + assert!(!SignatureAlgorithm::DsaSha1.signing_allowed()); + assert!(!SignatureAlgorithm::HmacSha1.signing_allowed()); assert!(!SignatureAlgorithm::RsaSha1.signing_allowed()); assert!(SignatureAlgorithm::RsaSha256.signing_allowed()); assert!(SignatureAlgorithm::EcdsaP256Sha256.signing_allowed()); @@ -1709,13 +1923,13 @@ mod tests { #[test] fn parse_rsa_key_value_preserves_wrapped_crypto_binary() { // CryptoBinary is unsigned big-endian data and XML whitespace is insignificant. - let xml = r#" + let xml = r##" AQID BA== AQAB - "#; + "##; let doc = Document::parse(xml).unwrap(); assert_eq!( @@ -1730,11 +1944,11 @@ BA== #[test] fn parse_rsa_key_value_rejects_reordered_parameters() { // XMLDSig defines Modulus followed by Exponent; accepting reordered input is ambiguous. - let xml = r#" + let xml = r##" AQABAQID - "#; + "##; let doc = Document::parse(xml).unwrap(); assert!(matches!( @@ -1746,9 +1960,9 @@ BA== #[test] fn parse_rsa_key_value_rejects_missing_exponent() { // Both RSA public parameters are required to construct a usable key. - let xml = r#" + let xml = r##" AQID - "#; + "##; let doc = Document::parse(xml).unwrap(); assert!(matches!( @@ -1760,11 +1974,11 @@ BA== #[test] fn parse_rsa_key_value_rejects_duplicate_exponent() { // RSAKeyValue has a closed two-child schema; duplicate parameters are invalid. - let xml = r#" + let xml = r##" AQIDAQABAQAB - "#; + "##; let doc = Document::parse(xml).unwrap(); assert!(matches!( @@ -2664,7 +2878,7 @@ BA== fn parse_key_info_keeps_unsupported_keyvalue_child_as_marker() { let xml = r#" - + "#; let doc = Document::parse(xml).unwrap(); @@ -2674,11 +2888,51 @@ BA== key_info.sources, vec![KeyInfoSource::KeyValue(KeyValueInfo::Unsupported { namespace: Some(XMLDSIG_NS.to_string()), - local_name: "DSAKeyValue".into(), + local_name: "FutureKeyValue".into(), })] ); } + #[test] + fn parse_key_info_accepts_supported_x509_retrieval_xpath() { + // Merlin's same-document RetrievalMethod selects only X509Data nodes. + let xml = r##" + + + ancestor-or-self::dsig:X509Data + + + "##; + let doc = Document::parse(xml).unwrap(); + + let key_info = parse_key_info(doc.root_element()).unwrap(); + assert!(matches!( + key_info.sources.as_slice(), + [KeyInfoSource::RetrievalMethod { + uri, + resource_type: Some(resource_type), + transforms: RetrievalMethodTransforms::X509DataAncestor, + }] if uri == "#keys" + && resource_type == "http://www.w3.org/2000/09/xmldsig#X509Data" + )); + } + + #[test] + fn parse_key_info_rejects_unimplemented_retrieval_transform() { + // Retrieval transforms must never be silently ignored when choosing a key. + let xml = r##" + + + + "##; + let doc = Document::parse(xml).unwrap(); + + assert!(matches!( + parse_key_info(doc.root_element()), + Err(ParseError::InvalidStructure(_)) + )); + } + #[test] fn parse_key_info_rejects_keyname_with_child_elements() { let xml = r#" diff --git a/src/xmldsig/signature.rs b/src/xmldsig/signature.rs index a37addab..b03548a4 100644 --- a/src/xmldsig/signature.rs +++ b/src/xmldsig/signature.rs @@ -1,8 +1,7 @@ //! Signature verification helpers for XMLDSig. //! -//! This module currently covers roadmap task P1-019 (RSA PKCS#1 v1.5) and -//! P1-020 (ECDSA P-256/P-384) verification, plus donor P-521 interop under -//! the XMLDSig `ecdsa-sha384` URI. +//! This module covers RSA PKCS#1 v1.5, DSA-SHA1, and ECDSA verification, +//! including donor P-521 interoperability under the XMLDSig `ecdsa-sha384` URI. //! //! Input public keys are accepted in SubjectPublicKeyInfo (SPKI) form because //! that is how the vendored PEM fixtures are stored. @@ -134,6 +133,22 @@ pub fn verify_rsa_signature_spki( public_key_spki_der: &[u8], signed_data: &[u8], signature_value: &[u8], +) -> Result { + verify_rsa_signature_spki_with_minimum( + algorithm, + public_key_spki_der, + signed_data, + signature_value, + 2048, + ) +} + +pub(crate) fn verify_rsa_signature_spki_with_minimum( + algorithm: SignatureAlgorithm, + public_key_spki_der: &[u8], + signed_data: &[u8], + signature_value: &[u8], + minimum_modulus_bits: usize, ) -> Result { let (rest, spki) = SubjectPublicKeyInfo::from_der(public_key_spki_der) .map_err(|_| SignatureVerificationError::InvalidKeyDer)?; @@ -146,7 +161,7 @@ pub fn verify_rsa_signature_spki( match public_key { PublicKey::RSA(rsa) => { - validate_rsa_public_key(&rsa, algorithm)?; + validate_rsa_public_key(&rsa, algorithm, minimum_modulus_bits)?; let key = rsa::RsaPublicKey::from_public_key_der(public_key_spki_der) .map_err(|_| SignatureVerificationError::InvalidKeyDer)?; let Ok(signature) = RsaPkcs1v15Signature::try_from(signature_value) else { @@ -185,6 +200,36 @@ pub fn verify_rsa_signature_spki( } } +/// Verify an XMLDSig DSA-SHA1 signature using a DER SPKI public key. +/// +/// XMLDSig 1.0 encodes the signature as the fixed-width 20-byte `r` followed +/// by the fixed-width 20-byte `s`, rather than ASN.1 DER. +#[must_use = "discarding the verification result skips signature validation"] +pub fn verify_dsa_signature_spki( + algorithm: SignatureAlgorithm, + public_key_spki_der: &[u8], + signed_data: &[u8], + signature_value: &[u8], +) -> Result { + if algorithm != SignatureAlgorithm::DsaSha1 { + return Err(SignatureVerificationError::UnsupportedAlgorithm { + uri: algorithm.uri().to_string(), + }); + } + if signature_value.len() != 40 { + return Ok(false); + } + let key = dsa::VerifyingKey::from_public_key_der(public_key_spki_der) + .map_err(|_| SignatureVerificationError::InvalidKeyDer)?; + let signature = dsa::Signature::from_components( + crypto_bigint::BoxedUint::from_be_slice_vartime(&signature_value[..20]), + crypto_bigint::BoxedUint::from_be_slice_vartime(&signature_value[20..]), + ) + .ok_or(SignatureVerificationError::InvalidSignatureFormat)?; + let digest = Sha1::digest(signed_data); + Ok(key.verify_prehash(&digest, &signature).is_ok()) +} + /// Verify an ECDSA XMLDSig signature using DER-encoded SPKI public key bytes. /// /// The input must be an X.509 `SubjectPublicKeyInfo` wrapping an EC key. The @@ -253,8 +298,9 @@ pub fn verify_ecdsa_signature_spki( fn validate_rsa_public_key( rsa: &x509_parser::public_key::RSAPublicKey<'_>, algorithm: SignatureAlgorithm, + minimum_modulus_bits: usize, ) -> Result<(), SignatureVerificationError> { - let min_modulus_bits = minimum_rsa_modulus_bits(algorithm)?; + minimum_rsa_modulus_bits(algorithm)?; let modulus_start = rsa .modulus .iter() @@ -271,7 +317,7 @@ fn validate_rsa_public_key( .len() .checked_mul(8) .ok_or(SignatureVerificationError::InvalidKeyDer)?; - if !(min_modulus_bits..=8192).contains(&modulus_bits) { + if !(minimum_modulus_bits..=8192).contains(&modulus_bits) { return Err(SignatureVerificationError::InvalidKeyDer); } diff --git a/src/xmldsig/types.rs b/src/xmldsig/types.rs index 8ddd8899..89f2c1e2 100644 --- a/src/xmldsig/types.rs +++ b/src/xmldsig/types.rs @@ -202,6 +202,37 @@ impl<'a> NodeSet<'a> { Ok(Self::collect_subtree(element)) } + /// Create a bare-name same-document fragment node-set, which excludes + /// comment nodes before any transforms are applied. + pub(crate) fn subtree_without_comments_with_budget( + element: Node<'a, 'a>, + budget: Option<&NodeSetMaterializationBudget>, + ) -> Result { + match budget { + Some(budget) => Self::charge_subtree_materialization(element, budget)?, + None => { + Self::ensure_subtree_materialization_fits(element)?; + } + } + let mut set = Self { + doc: element.document(), + nodes: HashSet::new(), + with_comments: false, + }; + for node in element.descendants().filter(|node| !node.is_comment()) { + set.insert_node(node); + if node.is_element() { + for attribute in node.attributes() { + set.insert_attribute(node, attribute.namespace(), attribute.name()); + } + for namespace in node.namespaces() { + set.insert_namespace(node, namespace.name().unwrap_or(""), namespace.uri()); + } + } + } + Ok(set) + } + pub(crate) fn subtree_with_budget( element: Node<'a, 'a>, budget: &NodeSetMaterializationBudget, diff --git a/src/xmldsig/uri.rs b/src/xmldsig/uri.rs index a27881fe..7426eb67 100644 --- a/src/xmldsig/uri.rs +++ b/src/xmldsig/uri.rs @@ -4,12 +4,13 @@ //! [XMLDSig §4.3.3.2](https://www.w3.org/TR/xmldsig-core1/#sec-Same-Document): //! //! - **Empty URI** (`""` or absent): the entire document, excluding comments. -//! - **Bare-name `#id`**: the element whose ID attribute matches `id`, as a subtree. +//! - **Bare-name `#id`**: the element whose ID attribute matches `id`, as a subtree +//! with comments removed by the XMLDSig same-document dereference rule. //! - **`#xpointer(/)`**: the entire document, including comments. -//! - **`#xpointer(id('id'))` / `#xpointer(id("id"))`**: element by ID (equivalent to bare-name). +//! - **`#xpointer(id('id'))` / `#xpointer(id("id"))`**: element by ID, with comments retained. //! -//! External URIs (http://, file://, etc.) are not supported — only same-document -//! references are needed for SAML signature verification. +//! External URI bytes are resolved only from an explicit caller-owned map; this +//! module never performs network or filesystem I/O. use std::collections::hash_map::Entry; use std::collections::{HashMap, HashSet}; @@ -51,6 +52,7 @@ pub struct UriReferenceResolver<'a> { doc: &'a Document<'a>, /// ID → element node mapping for O(1) fragment lookups. id_map: HashMap<&'a str, Node<'a, 'a>>, + external_resources: Option<&'a HashMap>>, } impl<'a> UriReferenceResolver<'a> { @@ -117,7 +119,19 @@ impl<'a> UriReferenceResolver<'a> { } } - Self { doc, id_map } + Self { + doc, + id_map, + external_resources: None, + } + } + + /// Attach an explicit caller-owned external-resource map. + /// + /// No network or filesystem access is performed by this resolver. + pub fn with_external_resources(mut self, resources: &'a HashMap>) -> Self { + self.external_resources = Some(resources); + self } /// Dereference a URI string to a [`TransformData`]. @@ -127,9 +141,10 @@ impl<'a> UriReferenceResolver<'a> { /// | URI | Result | /// |-----|--------| /// | `""` (empty) | Entire document, comments excluded | - /// | `"#foo"` | Subtree rooted at element with ID `foo` | + /// | `"#foo"` | Subtree rooted at element with ID `foo`, comments excluded | /// | `"#xpointer(/)"` | Entire document, comments included | - /// | `"#xpointer(id('foo'))"` | Subtree rooted at element with ID `foo` | + /// | `"#xpointer(id('foo'))"` | Subtree rooted at element with ID `foo`, comments included | + /// | external URI in caller map | A copy of the mapped bytes | /// | other | `Err(UnsupportedUri)` | pub fn dereference(&self, uri: &str) -> Result, TransformError> { self.dereference_with_optional_budget(uri, None) @@ -166,7 +181,10 @@ impl<'a> UriReferenceResolver<'a> { // xmlsec1 also passes fragments through without decoding. self.dereference_fragment(fragment, budget) } else { - Err(TransformError::UnsupportedUri(uri.to_string())) + self.external_resources + .and_then(|resources| resources.get(uri)) + .map(|bytes| TransformData::Binary(bytes.clone())) + .ok_or_else(|| TransformError::UnsupportedUri(uri.to_string())) } } @@ -174,7 +192,7 @@ impl<'a> UriReferenceResolver<'a> { /// /// Handles: /// - `xpointer(/)` → entire document (with comments, per XPointer spec) - /// - `xpointer(id('foo'))` → element by ID (equivalent to bare-name `#foo`) + /// - `xpointer(id('foo'))` → element by ID, retaining comments /// - bare name `foo` → element by ID attribute fn dereference_fragment( &self, @@ -198,18 +216,18 @@ impl<'a> UriReferenceResolver<'a> { }; Ok(TransformData::NodeSet(nodes)) } else if let Some(id) = parse_xpointer_id_fragment(fragment) { - // xpointer(id('foo')) → same as bare-name #foo + // XPointer dereference retains comments, unlike a bare-name fragment. // Reject empty parsed ID (e.g., xpointer(id(''))) — not a valid XML Name if id.is_empty() { return Err(TransformError::UnsupportedUri(format!("#{fragment}"))); } - self.resolve_id(id, budget) + self.resolve_id(id, budget, true) } else if fragment.starts_with("xpointer(") { // Any other XPointer expression is unsupported Err(TransformError::UnsupportedUri(format!("#{fragment}"))) } else { // Bare-name fragment: #foo → element by ID - self.resolve_id(fragment, budget) + self.resolve_id(fragment, budget, false) } } @@ -218,12 +236,17 @@ impl<'a> UriReferenceResolver<'a> { &self, id: &str, budget: Option<&NodeSetMaterializationBudget>, + with_comments: bool, ) -> Result, TransformError> { match self.id_map.get(id) { Some(&element) => { - let nodes = match budget { - Some(budget) => NodeSet::subtree_with_budget(element, budget)?, - None => NodeSet::subtree(element)?, + let nodes = if with_comments { + match budget { + Some(budget) => NodeSet::subtree_with_budget(element, budget)?, + None => NodeSet::subtree(element)?, + } + } else { + NodeSet::subtree_without_comments_with_budget(element, budget)? }; Ok(TransformData::NodeSet(nodes)) } @@ -244,6 +267,10 @@ impl<'a> UriReferenceResolver<'a> { self.id_map.get(id).map(|node| node.id()) } + pub(crate) fn node_for_id(&self, id: &str) -> Option> { + self.id_map.get(id).copied() + } + /// Get the number of registered IDs. pub fn id_count(&self) -> usize { self.id_map.len() @@ -564,8 +591,8 @@ mod tests { } #[test] - fn subtree_includes_comments() { - // Subtree dereference (via #id) includes comments, unlike empty URI + fn bare_name_subtree_excludes_comments() { + // XMLDSig's bare-name same-document shortcut removes comment nodes. let xml = r#""#; let doc = Document::parse(xml).unwrap(); let resolver = UriReferenceResolver::new(&doc); @@ -576,8 +603,8 @@ mod tests { for node in doc.descendants() { if node.is_comment() { assert!( - node_set.contains(node), - "comment should be included in #id subtree" + !node_set.contains(node), + "comment must be excluded from #id" ); } } @@ -606,7 +633,8 @@ mod tests { #[test] fn xpointer_id_single_quotes() { - let xml = r#"content"#; + // XPointer ID dereference retains comments, unlike bare-name fragments. + let xml = r#"content"#; let doc = Document::parse(xml).unwrap(); let resolver = UriReferenceResolver::new(&doc); @@ -618,6 +646,10 @@ mod tests { .find(|n| n.attribute("ID") == Some("abc")) .unwrap(); assert!(node_set.contains(elem)); + assert!( + elem.children() + .any(|node| node.is_comment() && node_set.contains(node)) + ); } #[test] diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index cc4710cc..c09271b2 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -12,19 +12,22 @@ use base64::Engine; use roxmltree::{Document, Node, NodeId}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use crate::c14n::canonicalize; use super::digest::{DigestAlgorithm, compute_digest, constant_time_eq}; use super::parse::{ - KeyInfo, MAX_REFERENCES_PER_SIGNATURE, ParseError, Reference, SignatureAlgorithm, XMLDSIG_NS, + KeyInfo, MAX_REFERENCES_PER_SIGNATURE, ParseError, Reference, RetrievalMethodTransforms, + SignatureAlgorithm, XMLDSIG_NS, }; use super::parse::{ parse_key_info, parse_reference_with_xpath_budget, parse_signed_info_with_xpath_budget, + parse_x509_certificate, parse_x509_data_dispatch, reference_digest_method, }; use super::signature::{ - SignatureVerificationError, verify_ecdsa_signature_pem, verify_rsa_signature_pem, + SignatureVerificationError, verify_dsa_signature_spki, verify_ecdsa_signature_pem, + verify_rsa_signature_pem, }; use super::transforms::{ BASE64_TRANSFORM_URI, DEFAULT_IMPLICIT_C14N_URI, Transform, TransformExecutionBudget, @@ -36,6 +39,8 @@ use super::whitespace::{is_xml_whitespace_only, normalize_xml_base64_bytes}; const MAX_SIGNATURE_VALUE_LEN: usize = 8192; const MAX_SIGNATURE_VALUE_TEXT_LEN: usize = 65_536; +const MAX_EXTERNAL_RESOURCE_LEN: usize = 8 * 1024 * 1024; +const MAX_EXTERNAL_RESOURCE_TOTAL_LEN: usize = 32 * 1024 * 1024; /// Cryptographic verifier used by [`VerifyContext`]. /// /// This trait intentionally has no `Send + Sync` supertraits so lightweight @@ -80,9 +85,8 @@ pub trait KeyResolver { /// Allowed URI classes for ``. /// -/// Note: `UriReferenceResolver` currently supports only same-document URIs. -/// Allowing external URIs via this policy only disables the early policy -/// rejection; dereference still fails until an external resolver path is added. +/// External URIs resolve only from bytes supplied through +/// [`VerifyContext::external_resources`]; allowing them never enables I/O. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[must_use = "pass the policy to VerifyContext::allowed_uri_types(), or store it for reuse"] pub struct UriTypeSet { @@ -110,8 +114,7 @@ impl UriTypeSet { /// Allow all URI classes. /// - /// This includes external URI classes at policy level, but external - /// dereference is not implemented yet by the default resolver. + /// External URIs still require an explicit caller-owned resource map. pub const ALL: Self = Self { allow_empty: true, allow_same_document: true, @@ -145,6 +148,8 @@ pub struct VerifyContext<'a> { allowed_transforms: Option>, store_pre_digest: bool, transform_options: TransformOptions, + external_resources: Option<&'a HashMap>>, + allow_internal_dtd: bool, } impl<'a> VerifyContext<'a> { @@ -165,6 +170,8 @@ impl<'a> VerifyContext<'a> { allowed_transforms: None, store_pre_digest: false, transform_options: TransformOptions::default(), + external_resources: None, + allow_internal_dtd: false, } } @@ -221,6 +228,23 @@ impl<'a> VerifyContext<'a> { self } + /// Provide external URI payloads explicitly. + /// + /// The map is the complete external I/O boundary: verification never + /// performs network or filesystem access. External URIs must also be + /// enabled through [`UriTypeSet`]. + pub fn external_resources(mut self, resources: &'a HashMap>) -> Self { + self.external_resources = Some(resources); + self + } + + /// Allow bounded internal DTD declarations while keeping external entity + /// resolution disabled. This is off by default. + pub fn allow_internal_dtd(mut self, enabled: bool) -> Self { + self.allow_internal_dtd = enabled; + self + } + /// Restrict allowed transform algorithms by URI. /// /// Example values: @@ -710,7 +734,14 @@ fn verify_signature_with_context( xml: &str, ctx: &VerifyContext<'_>, ) -> Result { - let doc = Document::parse(xml)?; + let doc = Document::parse_with_options( + xml, + roxmltree::ParsingOptions { + allow_dtd: ctx.allow_internal_dtd, + nodes_limit: 100_000, + entity_resolver: None, + }, + )?; let mut signatures = doc.descendants().filter(|node| { node.is_element() && node.tag_name().name() == "Signature" @@ -737,7 +768,7 @@ fn verify_signature_with_context( (None, Some(resolver)) => resolver.consumes_document_key_info(), (None, None) => true, }; - let key_info = if should_parse_key_info { + let mut key_info = if should_parse_key_info { signature_children .key_info_node .map(parse_key_info) @@ -756,7 +787,38 @@ fn verify_signature_with_context( ctx.allowed_transform_uris(), )?; - let resolver = UriReferenceResolver::new(&doc); + if let Some(resources) = ctx.external_resources { + let mut total = 0usize; + for bytes in resources.values() { + if bytes.len() > MAX_EXTERNAL_RESOURCE_LEN { + return Err(SignatureVerificationPipelineError::InvalidStructure { + reason: "external resource exceeds maximum allowed length", + }); + } + total = total.checked_add(bytes.len()).ok_or( + SignatureVerificationPipelineError::InvalidStructure { + reason: "external resource total length overflow", + }, + )?; + } + if total > MAX_EXTERNAL_RESOURCE_TOTAL_LEN { + return Err(SignatureVerificationPipelineError::InvalidStructure { + reason: "external resources exceed maximum aggregate length", + }); + } + } + let resolver = match ctx.external_resources { + Some(resources) => UriReferenceResolver::new(&doc).with_external_resources(resources), + None => UriReferenceResolver::new(&doc), + }; + if let Some(info) = key_info.as_mut() { + materialize_retrieval_methods( + info, + &resolver, + ctx.external_resources, + ctx.allowed_uri_types, + )?; + } let execution_budget = TransformExecutionBudget::default(); let execution = ReferenceExecutionContext { store_pre_digest: ctx.store_pre_digest, @@ -793,6 +855,14 @@ fn verify_signature_with_context( )?; let signature_value = decode_signature_value(signature_children.signature_value_node)?; + if signed_info.signature_method == SignatureAlgorithm::HmacSha1 { + let expected_bits = signed_info.hmac_output_length_bits.unwrap_or(160); + if signature_value.len() != expected_bits / 8 { + return Err(SignatureVerificationPipelineError::InvalidStructure { + reason: "SignatureValue length does not match HMACOutputLength", + }); + } + } let Some(resolved_key) = resolve_verifying_key(ctx, key_info.as_ref(), signed_info.signature_method)? else { @@ -854,6 +924,99 @@ fn verify_signature_with_context( }) } +fn materialize_retrieval_methods( + key_info: &mut KeyInfo, + resolver: &UriReferenceResolver<'_>, + external_resources: Option<&HashMap>>, + allowed_uri_types: UriTypeSet, +) -> Result<(), SignatureVerificationPipelineError> { + let retrievals = key_info + .sources + .iter() + .filter_map(|source| match source { + super::parse::KeyInfoSource::RetrievalMethod { + uri, + resource_type, + transforms, + } => Some((uri.clone(), resource_type.clone(), *transforms)), + _ => None, + }) + .collect::>(); + for (uri, resource_type, transforms) in retrievals { + if resource_type.as_deref() == Some("http://www.w3.org/2000/09/xmldsig#rawX509Certificate") + { + if !allowed_uri_types.allows(&uri) { + return Err(SignatureVerificationPipelineError::DisallowedUri { uri }); + } + if transforms != RetrievalMethodTransforms::None || uri.starts_with('#') { + return Err(SignatureVerificationPipelineError::InvalidStructure { + reason: "raw X509 RetrievalMethod requires an untransformed external URI", + }); + } + let certificate = external_resources + .and_then(|resources| resources.get(&uri)) + .ok_or_else(|| { + SignatureVerificationPipelineError::Reference( + ReferenceProcessingError::Transform(super::TransformError::UnsupportedUri( + uri.clone(), + )), + ) + })?; + let parsed = parse_x509_certificate(certificate) + .map_err(SignatureVerificationPipelineError::ParseKeyInfo)?; + key_info.sources.push(super::parse::KeyInfoSource::X509Data( + super::parse::X509DataInfo { + certificates: vec![certificate.clone()], + parsed_certificates: vec![parsed], + certificate_chain: vec![0], + ..super::parse::X509DataInfo::default() + }, + )); + } else if resource_type.as_deref() == Some("http://www.w3.org/2000/09/xmldsig#X509Data") { + if !allowed_uri_types.allows(&uri) { + return Err(SignatureVerificationPipelineError::DisallowedUri { uri }); + } + if transforms != RetrievalMethodTransforms::X509DataAncestor { + return Err(SignatureVerificationPipelineError::InvalidStructure { + reason: "X509Data RetrievalMethod requires the supported XPath selection", + }); + } + let id = uri.strip_prefix('#').ok_or( + SignatureVerificationPipelineError::InvalidStructure { + reason: "X509Data RetrievalMethod requires a same-document URI", + }, + )?; + let target = resolver.node_for_id(id).ok_or( + SignatureVerificationPipelineError::InvalidStructure { + reason: "X509Data RetrievalMethod target is missing or ambiguous", + }, + )?; + let mut selected = target.descendants().filter(|candidate| { + candidate.is_element() + && candidate.tag_name().namespace() == Some(XMLDSIG_NS) + && candidate.tag_name().name() == "X509Data" + }); + let node = + selected + .next() + .ok_or(SignatureVerificationPipelineError::InvalidStructure { + reason: "X509Data RetrievalMethod selected no X509Data element", + })?; + if selected.next().is_some() { + return Err(SignatureVerificationPipelineError::InvalidStructure { + reason: "X509Data RetrievalMethod selected multiple X509Data elements", + }); + } + let data = parse_x509_data_dispatch(node) + .map_err(SignatureVerificationPipelineError::ParseKeyInfo)?; + key_info + .sources + .push(super::parse::KeyInfoSource::X509Data(data)); + } + } + Ok(()) +} + fn process_manifest_references( signature_node: Node<'_, '_>, resolver: &UriReferenceResolver<'_>, @@ -862,16 +1025,18 @@ fn process_manifest_references( execution: &ReferenceExecutionContext<'_>, xpath_parse_budget: &mut XPathSignatureParseBudget, ) -> Result, SignatureVerificationPipelineError> { - let manifest_references = parse_manifest_references( + let parsed = parse_manifest_references( signature_node, signed_info_reference_nodes, xpath_parse_budget, )?; - if manifest_references.is_empty() { + let manifest_references = parsed.references; + let mut results = parsed.invalid_results; + if manifest_references.is_empty() && results.is_empty() { return Ok(Vec::new()); } - let mut results = Vec::with_capacity(manifest_references.len()); - for (index, reference) in manifest_references.iter().enumerate() { + results.reserve(manifest_references.len()); + for (index, reference) in &manifest_references { match enforce_reference_policies( std::slice::from_ref(reference), ctx.allowed_uri_types, @@ -884,8 +1049,8 @@ fn process_manifest_references( ) => { results.push(manifest_reference_invalid_result( reference, - index, - FailureReason::ReferencePolicyViolation { ref_index: index }, + *index, + FailureReason::ReferencePolicyViolation { ref_index: *index }, )); continue; } @@ -894,8 +1059,8 @@ fn process_manifest_references( )) => { results.push(manifest_reference_invalid_result( reference, - index, - FailureReason::ReferenceProcessingFailure { ref_index: index }, + *index, + FailureReason::ReferenceProcessingFailure { ref_index: *index }, )); continue; } @@ -904,8 +1069,8 @@ fn process_manifest_references( // record as non-fatal per-reference processing failure instead of aborting. results.push(manifest_reference_invalid_result( reference, - index, - FailureReason::ReferenceProcessingFailure { ref_index: index }, + *index, + FailureReason::ReferenceProcessingFailure { ref_index: *index }, )); continue; } @@ -916,17 +1081,18 @@ fn process_manifest_references( resolver, signature_node, ReferenceSet::Manifest, - index, + *index, execution, ) { Ok(result) => results.push(result), Err(_) => results.push(manifest_reference_invalid_result( reference, - index, - FailureReason::ReferenceProcessingFailure { ref_index: index }, + *index, + FailureReason::ReferenceProcessingFailure { ref_index: *index }, )), } } + results.sort_by_key(|result| result.reference_index); Ok(results) } @@ -952,8 +1118,10 @@ fn parse_manifest_references( signature_node: Node<'_, '_>, signed_info_reference_nodes: &HashSet, xpath_parse_budget: &mut XPathSignatureParseBudget, -) -> Result, SignatureVerificationPipelineError> { +) -> Result { let mut references = Vec::new(); + let mut invalid = Vec::new(); + let mut reference_index = 0usize; for object_node in signature_node.children().filter(|node| { node.is_element() && node.tag_name().namespace() == Some(XMLDSIG_NS) @@ -1002,14 +1170,44 @@ fn parse_manifest_references( reason: "signed Manifests exceed the per-signature Reference limit", }); } - references.push( - parse_reference_with_xpath_budget(child, xpath_parse_budget) - .map_err(SignatureVerificationPipelineError::ParseManifestReference)?, - ); + match parse_reference_with_xpath_budget(child, xpath_parse_budget) { + Ok(reference) => references.push((reference_index, reference)), + Err(ParseError::Transform(super::TransformError::UnsupportedTransform(_))) => { + let digest_algorithm = reference_digest_method(child).map_err(|error| { + SignatureVerificationPipelineError::ParseManifestReference(error) + })?; + invalid.push(ReferenceResult { + reference_set: ReferenceSet::Manifest, + reference_index, + uri: child.attribute("URI").unwrap_or("").to_owned(), + digest_algorithm, + status: DsigStatus::Invalid( + FailureReason::ReferenceProcessingFailure { + ref_index: reference_index, + }, + ), + pre_digest_data: None, + }); + } + Err(error) => { + return Err(SignatureVerificationPipelineError::ParseManifestReference( + error, + )); + } + } + reference_index += 1; } } } - Ok(references) + Ok(ParsedManifestReferences { + references, + invalid_results: invalid, + }) +} + +struct ParsedManifestReferences { + references: Vec<(usize, Reference)>, + invalid_results: Vec, } fn collect_authenticated_signed_info_reference_nodes( @@ -1324,6 +1522,23 @@ fn verify_with_algorithm( signature_value: &[u8], ) -> Result { match algorithm { + SignatureAlgorithm::DsaSha1 => { + let (rest, pem) = x509_parser::pem::parse_x509_pem(public_key_pem.as_bytes()) + .map_err(|_| SignatureVerificationError::InvalidKeyPem)?; + if !rest.iter().all(|byte| byte.is_ascii_whitespace()) || pem.label != "PUBLIC KEY" { + return Err(SignatureVerificationError::InvalidKeyPem.into()); + } + Ok(verify_dsa_signature_spki( + algorithm, + &pem.contents, + signed_data, + signature_value, + )?) + } + SignatureAlgorithm::HmacSha1 => Err(SignatureVerificationError::UnsupportedAlgorithm { + uri: algorithm.uri().to_string(), + } + .into()), SignatureAlgorithm::RsaSha1 | SignatureAlgorithm::RsaSha256 | SignatureAlgorithm::RsaSha384 @@ -2121,6 +2336,66 @@ mod tests { )); } + #[test] + fn verify_context_reports_unsupported_manifest_transform_with_declared_digest() { + // Unsupported optional Manifest transforms do not invalidate core + // SignedInfo, but their result must preserve the declared digest method. + let xml = signature_with_manifest_xml_with_manifest_mutation(true, |xml| { + let xml = xml.replacen( + "", + "", + 1, + ); + let xml = xml.replacen( + "\n ", + "\n ", + 1, + ); + replace_fixture_manifest_digest(&xml, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + }); + assert!(xml.contains("urn:unsupported")); + assert!(xml.contains("http://www.w3.org/2001/04/xmlenc#sha256")); + + let result = VerifyContext::new() + .key(&AcceptingKey) + .process_manifests(true) + .verify(&xml) + .expect("unsupported Manifest transform is a per-reference result"); + assert_eq!(result.status, DsigStatus::Valid); + assert_eq!(result.manifest_references.len(), 1); + assert_eq!( + result.manifest_references[0].digest_algorithm, + DigestAlgorithm::Sha256 + ); + assert!(matches!( + result.manifest_references[0].status, + DsigStatus::Invalid(FailureReason::ReferenceProcessingFailure { ref_index: 0 }) + )); + } + + #[test] + fn verify_context_does_not_hide_malformed_digest_behind_unsupported_transform() { + // A bad DigestValue remains a parse error even when its transform URI is unsupported. + let broken_xml = signature_with_manifest_xml_with_manifest_mutation(true, |xml| { + let xml = xml.replacen( + "", + "", + 1, + ); + replace_fixture_manifest_digest(&xml, "!!!") + }); + + let error = VerifyContext::new() + .key(&AcceptingKey) + .process_manifests(true) + .verify(&broken_xml) + .expect_err("malformed Manifest digest must not become a validity result"); + assert!(matches!( + error, + SignatureVerificationPipelineError::ParseManifestReference(_) + )); + } + #[test] fn verify_context_rejects_manifest_non_whitespace_mixed_content() { // Authenticated mixed content is still structurally invalid under the diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index f90207e9..b09f63bf 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -2,6 +2,11 @@ use std::time::{SystemTime, UNIX_EPOCH}; +use der::Decode; +use dsa::pkcs8::DecodePublicKey; +use sha1::{Digest, Sha1}; +use signature::hazmat::PrehashVerifier; + use x509_parser::{ certificate::X509Certificate, extensions::ParsedExtension, prelude::FromDer, revocation_list::CertificateRevocationList, time::ASN1Time, @@ -121,7 +126,7 @@ pub fn verify_x509_certificate_chain( && last.verify_signature(None).is_ok() { let child = parse_certificate(path_der[path_der.len() - 2])?; - child.issuer() == last.subject() && child.verify_signature(Some(last.public_key())).is_ok() + child.issuer() == last.subject() && verify_certificate_signature(&child, &last) } else { false }; @@ -140,9 +145,7 @@ pub fn verify_x509_certificate_chain( let mut first_validation_error = None; for (anchor_der, _) in trusted_anchors.iter().filter(|(_, cert)| { cert.subject() == candidate_child.issuer() - && candidate_child - .verify_signature(Some(cert.public_key())) - .is_ok() + && verify_certificate_signature(&candidate_child, cert) }) { let mut candidate_path = candidate_base.to_vec(); candidate_path.push(anchor_der); @@ -185,9 +188,7 @@ fn validate_path( let [child, issuer] = pair else { unreachable!() }; - if child.issuer() != issuer.subject() - || child.verify_signature(Some(issuer.public_key())).is_err() - { + if child.issuer() != issuer.subject() || !verify_certificate_signature(child, issuer) { return Err(X509ChainError::InvalidSignature(position)); } } @@ -198,6 +199,29 @@ fn validate_path( Ok(()) } +fn verify_certificate_signature( + certificate: &X509Certificate<'_>, + issuer: &X509Certificate<'_>, +) -> bool { + if certificate + .verify_signature(Some(issuer.public_key())) + .is_ok() + { + return true; + } + if certificate.signature_algorithm.algorithm.to_id_string() != "1.2.840.10040.4.3" { + return false; + } + let Ok(key) = dsa::VerifyingKey::from_public_key_der(issuer.public_key().raw) else { + return false; + }; + let Ok(signature) = dsa::Signature::from_der(&certificate.signature_value.data) else { + return false; + }; + let digest = Sha1::digest(certificate.tbs_certificate.as_ref()); + key.verify_prehash(&digest, &signature).is_ok() +} + fn validate_leaf_key_usage(cert: &X509Certificate<'_>) -> Result<(), X509ChainError> { // RFC 5280 section 4.2.1.3 restricts key purpose only when KeyUsage is present. if cert diff --git a/src/xmldsig/xpath.rs b/src/xmldsig/xpath.rs index 4e692767..24a70345 100644 --- a/src/xmldsig/xpath.rs +++ b/src/xmldsig/xpath.rs @@ -716,6 +716,14 @@ impl<'d> Mirror<'d> { match namespace.name() { Some(prefix) => element.register_prefix(prefix, namespace.uri()), None => { + // XPath 1.0's namespace axis does not expose an + // `xmlns=""` undeclaration as a namespace node. + // Registering it in SXD changes canonicalized + // node-sets compared with libxml2/xmlsec. + if namespace.uri().is_empty() { + element.set_default_namespace_uri(None); + continue; + } element.set_default_namespace_uri(Some(namespace.uri())); // SXD's namespace axis enumerates only registered // prefixes and otherwise omits the default binding. diff --git a/tests/donor_full_verification_suite.rs b/tests/donor_full_verification_suite.rs index 75abf85d..5f7e3514 100644 --- a/tests/donor_full_verification_suite.rs +++ b/tests/donor_full_verification_suite.rs @@ -1,7 +1,4 @@ -//! Donor full verification suite for ROADMAP task P1-025. -//! -//! This suite tracks pass/fail/skip accounting across donor vectors and -//! enforces that all supported donor vectors verify end-to-end. +//! End-to-end verification for the supported Aleksey donor vectors. use std::{ path::{Path, PathBuf}, @@ -9,34 +6,24 @@ use std::{ }; use xml_sec::xmldsig::{ - DefaultKeyResolver, DsigError, DsigStatus, KeyResolverConfig, ParseError, SignatureAlgorithm, - VerificationKey, VerifyContext, + DefaultKeyResolver, DsigStatus, KeyResolverConfig, SignatureAlgorithm, VerificationKey, + VerifyContext, }; -#[derive(Clone, Copy)] -enum SkipProbe { - WeakRsaKey, - UnsupportedSignatureAlgorithm, -} - #[derive(Clone, Copy)] enum Expectation { - ValidEmbedded, - ValidNamed { + Embedded, + Named { key_name: &'static str, key_path: &'static str, algorithm: SignatureAlgorithm, }, - ValidSelected { + Selected { certificate_paths: &'static [&'static str], }, - ValidChain { + Chain { trust_anchor_path: &'static str, }, - Skip { - reason: &'static str, - probe: SkipProbe, - }, } struct VectorCase { @@ -69,7 +56,7 @@ fn cases() -> Vec { VectorCase { name: "aleksey-rsa-sha1", xml_path: "tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloped-sha1-rsa-sha1.xml", - expectation: Expectation::ValidNamed { + expectation: Expectation::Named { key_name: "TestKeyName-rsa-4096", key_path: "tests/fixtures/keys/rsa/rsa-4096-pubkey.pem", algorithm: SignatureAlgorithm::RsaSha1, @@ -78,22 +65,22 @@ fn cases() -> Vec { VectorCase { name: "aleksey-rsa-sha256", xml_path: "tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.xml", - expectation: Expectation::ValidEmbedded, + expectation: Expectation::Embedded, }, VectorCase { name: "aleksey-rsa-sha384", xml_path: "tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha384-rsa-sha384.xml", - expectation: Expectation::ValidEmbedded, + expectation: Expectation::Embedded, }, VectorCase { name: "aleksey-rsa-sha512", xml_path: "tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha512-rsa-sha512.xml", - expectation: Expectation::ValidEmbedded, + expectation: Expectation::Embedded, }, VectorCase { name: "aleksey-ecdsa-p256-sha256", xml_path: "tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloped-sha256-ecdsa-sha256.xml", - expectation: Expectation::ValidNamed { + expectation: Expectation::Named { key_name: "TestKeyName-ec-prime256v1", key_path: "tests/fixtures/keys/ec/ec-prime256v1-pubkey.pem", algorithm: SignatureAlgorithm::EcdsaP256Sha256, @@ -102,7 +89,7 @@ fn cases() -> Vec { VectorCase { name: "aleksey-ecdsa-p521-sha384", xml_path: "tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloped-sha384-ecdsa-sha384.xml", - expectation: Expectation::ValidNamed { + expectation: Expectation::Named { key_name: "TestKeyName-ec-prime521v1", key_path: "tests/fixtures/keys/ec/ec-prime521v1-pubkey.pem", algorithm: SignatureAlgorithm::EcdsaP384Sha384, @@ -111,7 +98,7 @@ fn cases() -> Vec { VectorCase { name: "aleksey-rsa-sha512-x509-digest", xml_path: "tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloped-x509-digest-sha512.xml", - expectation: Expectation::ValidSelected { + expectation: Expectation::Selected { certificate_paths: &[ "tests/fixtures/keys/rsa/rsa-4096-cert.pem", "tests/fixtures/keys/ca2cert.pem", @@ -122,86 +109,27 @@ fn cases() -> Vec { VectorCase { name: "aleksey-rsa-sha1-x509-chain-tofu", xml_path: "tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-rsa-x509chain.xml", - expectation: Expectation::ValidEmbedded, + expectation: Expectation::Embedded, }, VectorCase { name: "aleksey-rsa-sha1-x509-chain-anchored", xml_path: "tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-rsa-x509chain.xml", - expectation: Expectation::ValidChain { + expectation: Expectation::Chain { trust_anchor_path: "tests/fixtures/keys/cacert.pem", }, }, - // Merlin "basic signatures" required by P1-025. - // These are tracked explicitly as skips until P2/P4 capabilities exist. - VectorCase { - name: "merlin-enveloped-dsa", - xml_path: "tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloped-dsa.xml", - expectation: Expectation::Skip { - reason: "DSA signature method is not implemented yet (planned P4-009)", - probe: SkipProbe::UnsupportedSignatureAlgorithm, - }, - }, - VectorCase { - name: "merlin-enveloping-rsa-keyvalue", - xml_path: "tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-rsa.xml", - expectation: Expectation::Skip { - reason: "RSAKeyValue resolves but its legacy 1024-bit modulus is below policy", - probe: SkipProbe::WeakRsaKey, - }, - }, - VectorCase { - name: "merlin-x509-crt", - xml_path: "tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt.xml", - expectation: Expectation::Skip { - reason: "DSA signature method is not implemented yet (planned P4-009); X509 KeyInfo resolution is not implemented yet (planned P2-009)", - probe: SkipProbe::UnsupportedSignatureAlgorithm, - }, - }, - VectorCase { - name: "merlin-x509-crt-crl", - xml_path: "tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt-crl.xml", - expectation: Expectation::Skip { - reason: "DSA signature method is not implemented yet (planned P4-009); X509/CRL KeyInfo resolution is not implemented yet (planned P2-009/P2-005)", - probe: SkipProbe::UnsupportedSignatureAlgorithm, - }, - }, - VectorCase { - name: "merlin-x509-is", - xml_path: "tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-is.xml", - expectation: Expectation::Skip { - reason: "DSA signature method is not implemented yet (planned P4-009); X509IssuerSerial resolution is not implemented yet (planned P2-009)", - probe: SkipProbe::UnsupportedSignatureAlgorithm, - }, - }, - VectorCase { - name: "merlin-x509-ski", - xml_path: "tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-ski.xml", - expectation: Expectation::Skip { - reason: "DSA signature method is not implemented yet (planned P4-009); X509SKI resolution is not implemented yet (planned P2-009)", - probe: SkipProbe::UnsupportedSignatureAlgorithm, - }, - }, - VectorCase { - name: "merlin-x509-sn", - xml_path: "tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-sn.xml", - expectation: Expectation::Skip { - reason: "DSA signature method is not implemented yet (planned P4-009); X509SubjectName resolution is not implemented yet (planned P2-009)", - probe: SkipProbe::UnsupportedSignatureAlgorithm, - }, - }, ] } #[test] -fn donor_full_verification_suite_tracks_pass_fail_skip_counts() { +fn donor_full_verification_suite_accepts_every_supported_case() { let root = project_root(); let mut passed = 0usize; let mut failed = Vec::::new(); - let mut skipped = Vec::::new(); for case in cases() { match case.expectation { - Expectation::ValidEmbedded => { + Expectation::Embedded => { let xml = read_fixture(&root.join(case.xml_path)); let resolver = DefaultKeyResolver::default(); match VerifyContext::new().key_resolver(&resolver).verify(&xml) { @@ -219,7 +147,7 @@ fn donor_full_verification_suite_tracks_pass_fail_skip_counts() { } } } - Expectation::ValidNamed { + Expectation::Named { key_name, key_path, algorithm, @@ -247,7 +175,7 @@ fn donor_full_verification_suite_tracks_pass_fail_skip_counts() { } } } - Expectation::ValidSelected { certificate_paths } => { + Expectation::Selected { certificate_paths } => { let xml = read_fixture(&root.join(case.xml_path)); let resolver = DefaultKeyResolver::new(KeyResolverConfig { trusted_certs: certificate_paths @@ -267,7 +195,7 @@ fn donor_full_verification_suite_tracks_pass_fail_skip_counts() { } } } - Expectation::ValidChain { trust_anchor_path } => { + Expectation::Chain { trust_anchor_path } => { let xml = read_fixture(&root.join(case.xml_path)); let resolver = DefaultKeyResolver::new(KeyResolverConfig { trusted_certs: vec![read_pem_der(&root.join(trust_anchor_path), "CERTIFICATE")], @@ -289,44 +217,6 @@ fn donor_full_verification_suite_tracks_pass_fail_skip_counts() { } } } - Expectation::Skip { reason, probe } => { - let xml = read_fixture(&root.join(case.xml_path)); - roxmltree::Document::parse(&xml) - .unwrap_or_else(|err| panic!("{}: fixture XML must parse: {err}", case.name)); - match probe { - SkipProbe::WeakRsaKey => match VerifyContext::new() - .key_resolver(&DefaultKeyResolver::default()) - .verify(&xml) - { - Err(DsigError::Crypto( - xml_sec::xmldsig::SignatureVerificationError::InvalidKeyDer, - )) => {} - Ok(result) => failed.push(format!( - "{}: expected weak RSA key error for skipped vector, got {:?}", - case.name, result.status - )), - Err(err) => failed.push(format!( - "{}: expected weak RSA key error for skipped vector, got {err}", - case.name - )), - }, - SkipProbe::UnsupportedSignatureAlgorithm => match VerifyContext::new().verify(&xml) - { - Err(DsigError::ParseSignedInfo(ParseError::UnsupportedAlgorithm { - .. - })) => {} - Ok(result) => failed.push(format!( - "{}: expected unsupported signature algorithm error for skipped vector, got {:?}", - case.name, result.status - )), - Err(err) => failed.push(format!( - "{}: expected unsupported signature algorithm error for skipped vector, got {err}", - case.name - )), - }, - } - skipped.push(format!("{}: {}", case.name, reason)); - } } } @@ -337,19 +227,5 @@ fn donor_full_verification_suite_tracks_pass_fail_skip_counts() { failed.join("\n") ); - let expected_skipped = vec![ - "merlin-enveloped-dsa: DSA signature method is not implemented yet (planned P4-009)", - "merlin-enveloping-rsa-keyvalue: RSAKeyValue resolves but its legacy 1024-bit modulus is below policy", - "merlin-x509-crt: DSA signature method is not implemented yet (planned P4-009); X509 KeyInfo resolution is not implemented yet (planned P2-009)", - "merlin-x509-crt-crl: DSA signature method is not implemented yet (planned P4-009); X509/CRL KeyInfo resolution is not implemented yet (planned P2-009/P2-005)", - "merlin-x509-is: DSA signature method is not implemented yet (planned P4-009); X509IssuerSerial resolution is not implemented yet (planned P2-009)", - "merlin-x509-ski: DSA signature method is not implemented yet (planned P4-009); X509SKI resolution is not implemented yet (planned P2-009)", - "merlin-x509-sn: DSA signature method is not implemented yet (planned P4-009); X509SubjectName resolution is not implemented yet (planned P2-009)", - ]; - - // P1-025 minimum expected accounting: - // - all supported aleksey RSA/ECDSA vectors pass - // - unsupported/deferred merlin vectors are tracked as skips with explicit reasons assert_eq!(passed, 9, "unexpected pass count"); - assert_eq!(skipped, expected_skipped, "unexpected skip inventory"); } diff --git a/tests/merlin_interop.rs b/tests/merlin_interop.rs new file mode 100644 index 00000000..084a7c27 --- /dev/null +++ b/tests/merlin_interop.rs @@ -0,0 +1,462 @@ +//! End-to-end coverage for the upstream Merlin XMLDSig interoperability corpus. + +use std::{ + collections::HashMap, + path::PathBuf, + time::{Duration, SystemTime}, +}; + +use x509_parser::prelude::{FromDer, X509Certificate}; +use xml_sec::xmldsig::{ + DefaultKeyResolver, DsigError, DsigStatus, FailureReason, HmacSha1VerificationKey, + KeyResolutionError, KeyResolverConfig, SignatureAlgorithm, SignatureVerificationError, + UriTypeSet, VerificationKey, VerifyContext, X509ChainError, XPathHereSemantics, +}; + +const MERLIN: &str = "donors/xmlsec/tests/merlin-xmldsig-twenty-three"; +const DONOR_EXTERNAL: &str = "donors/xmlsec/tests/external-data"; +const VERIFY_2005: u64 = 1_104_580_800; + +fn root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn bytes(path: &str) -> Vec { + std::fs::read(root().join(path)).unwrap_or_else(|error| panic!("read {path}: {error}")) +} + +fn xml(name: &str) -> String { + String::from_utf8(bytes(&format!("{MERLIN}/{name}.xml"))).expect("fixture is UTF-8") +} + +fn cert(name: &str) -> Vec { + let path = format!("{MERLIN}/certs/{name}"); + let data = bytes(&path); + if name.ends_with(".der") { + return data; + } + let (rest, pem) = x509_parser::pem::parse_x509_pem(&data).expect("certificate PEM"); + assert!(rest.iter().all(u8::is_ascii_whitespace)); + pem.contents +} + +fn verification_key(name: &str, algorithm: SignatureAlgorithm) -> VerificationKey { + let der = cert(name); + let (rest, certificate) = X509Certificate::from_der(&der).expect("certificate DER"); + assert!(rest.is_empty()); + VerificationKey { + algorithm, + public_key_bytes: certificate.public_key().raw.to_vec(), + certificate_der: Some(der), + name: None, + } +} + +fn external_resources() -> HashMap> { + HashMap::from([ + ( + "http://www.w3.org/TR/xml-stylesheet".into(), + bytes(&format!("{DONOR_EXTERNAL}/xml-stylesheet-2005")), + ), + ( + "http://www.w3.org/Signature/2002/04/xml-stylesheet.b64".into(), + bytes(&format!("{DONOR_EXTERNAL}/xml-stylesheet-2005.b64")), + ), + ( + "tests/merlin-xmldsig-twenty-three/certs/balor.der".into(), + cert("balor.der"), + ), + ]) +} + +fn assert_valid( + name: &str, + result: Result, +) { + let result = result.unwrap_or_else(|error| panic!("{name}: {error}")); + assert_eq!(result.status, DsigStatus::Valid, "{name}"); + assert!( + result + .signed_info_references + .iter() + .all(|reference| reference.status == DsigStatus::Valid), + "{name}: SignedInfo reference failure" + ); +} + +#[test] +fn verifies_all_merlin_documents_with_upstream_expectations() { + // Every signed document used by xmlsec's Merlin runner is classified here. + let default = DefaultKeyResolver::default(); + for name in [ + "signature-enveloped-dsa", + "signature-enveloping-dsa", + "signature-enveloping-b64-dsa", + ] { + assert_valid( + name, + VerifyContext::new() + .key_resolver(&default) + .verify(&xml(name)), + ); + } + let legacy_rsa = DefaultKeyResolver::new(KeyResolverConfig { + allow_legacy_rsa_sha1: true, + ..KeyResolverConfig::default() + }); + assert_valid( + "signature-enveloping-rsa", + VerifyContext::new() + .key_resolver(&legacy_rsa) + .verify(&xml("signature-enveloping-rsa")), + ); + + let hmac = HmacSha1VerificationKey::new(b"secret".to_vec()).expect("valid HMAC key"); + for name in [ + "signature-enveloping-hmac-sha1", + "signature-enveloping-hmac-sha1-40", + ] { + assert_valid(name, VerifyContext::new().key(&hmac).verify(&xml(name))); + } + + let resources = external_resources(); + for name in ["signature-external-dsa", "signature-external-b64-dsa"] { + assert_valid( + name, + VerifyContext::new() + .key_resolver(&default) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&xml(name)), + ); + } + + let mut named = KeyResolverConfig::default(); + named.named_keys.insert( + "Lugh".into(), + verification_key("lugh-cert.pem", SignatureAlgorithm::DsaSha1), + ); + let named = DefaultKeyResolver::new(named); + assert_valid( + "signature-keyname", + VerifyContext::new() + .key_resolver(&named) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&xml("signature-keyname")), + ); + + for (name, selected) in [ + ("signature-x509-crt", None), + ("signature-x509-sn", Some("badb.pem")), + ("signature-x509-is", Some("macha.pem")), + ("signature-x509-ski", Some("nemain.pem")), + ] { + let mut trusted_certs = vec![cert("ca.pem")]; + if let Some(selected) = selected { + trusted_certs.push(cert(selected)); + } + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + trusted_certs, + verify_chains: true, + verification_time: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(VERIFY_2005)), + ..KeyResolverConfig::default() + }); + assert_valid( + name, + VerifyContext::new() + .key_resolver(&resolver) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&xml(name)), + ); + } + + let retrieval = DefaultKeyResolver::new(KeyResolverConfig { + trusted_certs: vec![cert("ca.pem"), cert("balor.pem")], + verify_chains: true, + verification_time: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(VERIFY_2005)), + ..KeyResolverConfig::default() + }); + assert_valid( + "signature-retrievalmethod-rawx509crt", + VerifyContext::new() + .key_resolver(&retrieval) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&xml("signature-retrievalmethod-rawx509crt")), + ); + + // The upstream runner's newer detached resource also mismatches the old + // digest. Use the signed 2005 bytes and a certificate-valid timestamp so + // this assertion reaches and proves the embedded CRL decision itself. + let revoked_resources = external_resources(); + let revoked = DefaultKeyResolver::new(KeyResolverConfig { + trusted_certs: vec![cert("ca.pem")], + verify_chains: true, + check_crls: true, + verification_time: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(VERIFY_2005)), + ..KeyResolverConfig::default() + }); + let revoked_error = VerifyContext::new() + .key_resolver(&revoked) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&revoked_resources) + .verify(&xml("signature-x509-crt-crl")) + .expect_err("the donor CRL revokes the signing certificate"); + // Merlin's CA restricts KeyUsage to keyCertSign, so RFC 5280 requires + // rejecting its CRL before trusting the listed revoked serial. Dedicated + // chain tests cover the Revoked result for an authorized cRLSign issuer. + assert!( + matches!( + revoked_error, + DsigError::KeyResolution(KeyResolutionError::Chain(X509ChainError::InvalidKeyUsage { + position: 1, + required: "cRLSign" + })) + ), + "unexpected revoked-vector error: {revoked_error:?}" + ); + + let complex = DefaultKeyResolver::new(KeyResolverConfig { + trusted_certs: vec![cert("merlin.pem")], + verification_time: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(VERIFY_2005)), + ..KeyResolverConfig::default() + }); + let result = VerifyContext::new() + .key_resolver(&complex) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .process_manifests(true) + .store_pre_digest(true) + .allow_internal_dtd(true) + .xpath_here_semantics(XPathHereSemantics::XmlSecLegacy) + .verify(&xml("signature")) + .expect("complex signature pipeline"); + assert_eq!(result.status, DsigStatus::Valid); + let expected_signed_info_uris = [ + "http://www.w3.org/TR/xml-stylesheet", + "http://www.w3.org/Signature/2002/04/xml-stylesheet.b64", + "#object-1", + "", + "#object-2", + "#manifest-1", + "#signature-properties-1", + "", + "", + "#xpointer(/)", + "#xpointer(/)", + "#object-3", + "#object-3", + "#xpointer(id('object-3'))", + "#xpointer(id('object-3'))", + "#reference-2", + "#manifest-reference-1", + "#reference-1", + ]; + assert_eq!( + result.signed_info_references.len(), + expected_signed_info_uris.len() + ); + for (reference, expected_uri) in result + .signed_info_references + .iter() + .zip(expected_signed_info_uris) + { + assert_eq!(reference.uri, expected_uri); + assert_eq!(reference.status, DsigStatus::Valid, "{expected_uri}"); + } + + let expected_manifest = [ + ("http://www.w3.org/TR/xml-stylesheet", true), + ("#reference-1", true), + ("#notaries", false), + ]; + assert_eq!(result.manifest_references.len(), expected_manifest.len()); + for (reference, (expected_uri, expected_valid)) in + result.manifest_references.iter().zip(expected_manifest) + { + assert_eq!(reference.uri, expected_uri); + assert_eq!( + reference.status == DsigStatus::Valid, + expected_valid, + "{expected_uri}" + ); + } +} + +#[test] +fn rejects_missing_or_tampered_external_resources() { + // Detached references cannot trigger I/O and must fail on absent or altered caller bytes. + let default = DefaultKeyResolver::default(); + let document = xml("signature-external-dsa"); + let missing = HashMap::new(); + assert!( + VerifyContext::new() + .key_resolver(&default) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&missing) + .verify(&document) + .is_err() + ); + + let mut tampered = external_resources(); + tampered.insert( + "http://www.w3.org/TR/xml-stylesheet".into(), + b"tampered".to_vec(), + ); + let result = VerifyContext::new() + .key_resolver(&default) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&tampered) + .verify(&document) + .expect("tampering is a validation result"); + assert_ne!(result.status, DsigStatus::Valid); +} + +#[test] +fn bounds_external_resources_before_dereference() { + // Resource limits are enforced for the complete caller map, not only the referenced entry. + let default = DefaultKeyResolver::default(); + let mut oversized = external_resources(); + oversized.insert("urn:oversized".into(), vec![0; 8 * 1024 * 1024 + 1]); + assert!( + VerifyContext::new() + .key_resolver(&default) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&oversized) + .verify(&xml("signature-external-dsa")) + .is_err() + ); + + let aggregate = (0..5) + .map(|index| (format!("urn:aggregate:{index}"), vec![0; 7 * 1024 * 1024])) + .collect(); + assert!( + VerifyContext::new() + .key_resolver(&default) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&aggregate) + .verify(&xml("signature-external-dsa")) + .is_err() + ); +} + +#[test] +fn rejects_wrong_hmac_key_and_invalid_output_length() { + // MAC mismatch is an invalid status; malformed truncation is a processing error. + let wrong = HmacSha1VerificationKey::new(b"wrong".to_vec()).expect("valid HMAC key"); + let result = VerifyContext::new() + .key(&wrong) + .verify(&xml("signature-enveloping-hmac-sha1")) + .expect("wrong MAC is a validation result"); + assert_ne!(result.status, DsigStatus::Valid); + + let malformed = xml("signature-enveloping-hmac-sha1-40").replacen( + "80", + "72", + 1, + ); + assert!(malformed.contains("72")); + assert!(VerifyContext::new().key(&wrong).verify(&malformed).is_err()); + + let implicit_full_length = xml("signature-enveloping-hmac-sha1-40").replacen( + "80", + "", + 1, + ); + assert!( + VerifyContext::new() + .key(&wrong) + .verify(&implicit_full_length) + .is_err() + ); +} + +#[test] +fn rejects_malformed_dsa_key_value() { + // Invalid CryptoBinary input must be rejected before DSA key construction. + let malformed = xml("signature-enveloped-dsa").replacen("cfYpihpAQeep", "!!!!ihpAQeep", 1); + assert!(malformed.contains("!!!!ihpAQeep")); + assert!( + VerifyContext::new() + .key_resolver(&DefaultKeyResolver::default()) + .verify(&malformed) + .is_err() + ); +} + +#[test] +fn rejects_missing_ambiguous_and_weak_key_resolution() { + // KeyName, RetrievalMethod IDs, and legacy RSA policy each fail closed. + let resources = external_resources(); + let missing = VerifyContext::new() + .key_resolver(&DefaultKeyResolver::default()) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&xml("signature-keyname")); + assert!(matches!( + missing, + Ok(result) if result.status == DsigStatus::Invalid(FailureReason::KeyNotFound) + )); + + let ambiguous = xml("signature").replacen( + "", + "", + 1, + ); + assert!( + VerifyContext::new() + .key_resolver(&DefaultKeyResolver::default()) + .allow_internal_dtd(true) + .verify(&ambiguous) + .is_err() + ); + + let weak = VerifyContext::new() + .key_resolver(&DefaultKeyResolver::default()) + .verify(&xml("signature-enveloping-rsa")); + assert!(matches!( + weak, + Err(DsigError::Crypto(SignatureVerificationError::InvalidKeyDer)) + )); +} + +#[test] +fn rejects_dtd_and_unsupported_retrieval_defaults() { + // Internal DTD parsing and RetrievalMethod transform compatibility require exact opt-ins. + assert!( + VerifyContext::new() + .key_resolver(&DefaultKeyResolver::default()) + .verify(&xml("signature")) + .is_err() + ); + + let unsupported = xml("signature").replacen( + "ancestor-or-self::dsig:X509Data", + "descendant-or-self::dsig:X509Data", + 1, + ); + assert!( + VerifyContext::new() + .key_resolver(&DefaultKeyResolver::default()) + .allow_internal_dtd(true) + .verify(&unsupported) + .is_err() + ); + + let resources = external_resources(); + let retrieval = DefaultKeyResolver::new(KeyResolverConfig { + trusted_certs: vec![cert("ca.pem"), cert("balor.pem")], + verify_chains: true, + verification_time: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(VERIFY_2005)), + ..KeyResolverConfig::default() + }); + assert!(matches!( + VerifyContext::new() + .key_resolver(&retrieval) + .external_resources(&resources) + .verify(&xml("signature-retrievalmethod-rawx509crt")), + Err(DsigError::DisallowedUri { .. }) + )); +} diff --git a/tests/uri_integration.rs b/tests/uri_integration.rs index eeff8a96..18211f0b 100644 --- a/tests/uri_integration.rs +++ b/tests/uri_integration.rs @@ -106,14 +106,11 @@ fn fragment_id_canonicalizes_subtree_only() { } #[test] -fn fragment_id_includes_comments_in_subtree() { - // Unlike empty URI, #id subtrees include comments +fn fragment_id_excludes_comments_in_subtree() { + // XMLDSig bare-name dereference strips comments even when C14N retains them. let xml = r#""#; let result = deref_and_canonicalize_with_comments(xml, "#x"); - assert_eq!( - result, - r#""# - ); + assert_eq!(result, r#""#); } #[test] From d9a013c335b9302585d2d4737cf015739560b602 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 5 Aug 2026 10:59:00 +0300 Subject: [PATCH 02/26] fix(xmldsig): address Merlin review findings - track the complete Merlin fixture snapshot for hermetic CI\n- harden HMAC, legacy RSA, X509, RetrievalMethod, and Manifest paths\n- add regression coverage for every reviewed failure mode --- .gitattributes | 1 + docs/xmldsig.md | 3 +- scripts/import-donor-fixtures.sh | 3 + src/xmldsig/keys.rs | 172 ++++++++- src/xmldsig/parse.rs | 145 ++++++-- src/xmldsig/signature.rs | 35 +- src/xmldsig/verify.rs | 118 +++++- src/xmldsig/x509.rs | 67 +++- .../xmldsig/external-data/xml-stylesheet-2005 | 341 ++++++++++++++++++ .../external-data/xml-stylesheet-2005.b64 | 274 ++++++++++++++ .../merlin-xmldsig-twenty-three/Readme.txt | 63 ++++ .../certs/badb.der | Bin 0 -> 850 bytes .../certs/badb.pem | 20 + .../certs/balor.der | Bin 0 -> 851 bytes .../certs/balor.pem | 20 + .../certs/bres.pem | 20 + .../merlin-xmldsig-twenty-three/certs/ca.der | Bin 0 -> 862 bytes .../merlin-xmldsig-twenty-three/certs/ca.pem | 20 + .../certs/lugh-cert.der | Bin 0 -> 851 bytes .../certs/lugh-cert.pem | 20 + .../certs/lugh.der | Bin 0 -> 442 bytes .../certs/lugh.pem | 12 + .../certs/macha.der | Bin 0 -> 852 bytes .../certs/macha.pem | 20 + .../certs/merlin.der | Bin 0 -> 847 bytes .../certs/merlin.pem | 21 ++ .../certs/morigu.pem | 20 + .../certs/nemain.der | Bin 0 -> 852 bytes .../certs/nemain.pem | 20 + .../signature-enveloped-dsa.tmpl | 23 ++ .../signature-enveloping-b64-dsa.tmpl | 22 ++ .../signature-enveloping-b64-dsa.xml | 42 +++ .../signature-enveloping-dsa.tmpl | 19 + .../signature-enveloping-dsa.xml | 39 ++ .../signature-enveloping-hmac-sha1-40.tmpl | 19 + .../signature-enveloping-hmac-sha1-40.xml | 17 + .../signature-enveloping-hmac-sha1.tmpl | 17 + .../signature-enveloping-hmac-sha1.xml | 15 + .../signature-enveloping-rsa.tmpl | 19 + .../signature-external-b64-dsa.tmpl | 21 ++ .../signature-external-b64-dsa.xml | 41 +++ .../signature-external-dsa.tmpl | 18 + .../signature-external-dsa.xml | 38 ++ .../signature-keyname.tmpl | 18 + .../signature-keyname.xml | 17 + .../signature-retrievalmethod-rawx509crt.tmpl | 16 + .../signature-retrievalmethod-rawx509crt.xml | 17 + .../signature-x509-crt-crl.tmpl | 18 + .../signature-x509-crt.tmpl | 18 + .../signature-x509-is.tmpl | 18 + .../signature-x509-ski.tmpl | 18 + .../signature-x509-sn.tmpl | 18 + .../signature.tmpl | 252 +++++++++++++ .../merlin-xmldsig-twenty-three/signature.xml | 269 ++++++++++++++ tests/fixtures_smoke.rs | 23 +- tests/merlin_interop.rs | 87 +++-- 56 files changed, 2459 insertions(+), 95 deletions(-) create mode 100644 tests/fixtures/xmldsig/external-data/xml-stylesheet-2005 create mode 100644 tests/fixtures/xmldsig/external-data/xml-stylesheet-2005.b64 create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/Readme.txt create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/badb.der create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/badb.pem create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/balor.der create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/balor.pem create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/bres.pem create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/ca.der create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/ca.pem create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh-cert.der create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh-cert.pem create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh.der create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh.pem create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/macha.der create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/macha.pem create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/merlin.der create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/merlin.pem create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/morigu.pem create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/nemain.der create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/nemain.pem create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloped-dsa.tmpl create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-b64-dsa.tmpl create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-b64-dsa.xml create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-dsa.tmpl create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-dsa.xml create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1-40.tmpl create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1-40.xml create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1.tmpl create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1.xml create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-rsa.tmpl create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-external-b64-dsa.tmpl create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-external-b64-dsa.xml create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-external-dsa.tmpl create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-external-dsa.xml create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-keyname.tmpl create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-keyname.xml create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-retrievalmethod-rawx509crt.tmpl create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-retrievalmethod-rawx509crt.xml create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt-crl.tmpl create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt.tmpl create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-is.tmpl create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-ski.tmpl create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-sn.tmpl create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature.tmpl create mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature.xml diff --git a/.gitattributes b/.gitattributes index c08178c5..f52693ee 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ tests/fixtures/xmlenc/aleksey-xmlenc-01/*.tmpl -text whitespace=-trailing-space,-space-before-tab tests/fixtures/xmlenc/01-phaos-xmlenc-3/** -text whitespace=-trailing-space,-space-before-tab +tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/** -text whitespace=-blank-at-eof diff --git a/docs/xmldsig.md b/docs/xmldsig.md index a34e169d..3f24d1c9 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -1,7 +1,8 @@ # XML Digital Signatures The `xmldsig` feature provides signing and verification pipelines for same-document XML -signatures. It supports inclusive and exclusive canonicalization, enveloped signatures, +signatures and detached references whose payloads the caller supplies. It supports inclusive and +exclusive canonicalization, enveloped signatures, Base64, XPath 1.0, and XPath Filter 2.0 transforms, RSA PKCS#1 v1.5, ECDSA P-256/P-384, DSA-SHA1 and HMAC-SHA1 verification, embedded X.509 certificates, and configured key resolution. diff --git a/scripts/import-donor-fixtures.sh b/scripts/import-donor-fixtures.sh index d62f639b..9359ca14 100755 --- a/scripts/import-donor-fixtures.sh +++ b/scripts/import-donor-fixtures.sh @@ -39,6 +39,9 @@ fixture_paths=("$@") if (( ${#fixture_paths[@]} == 0 )); then fixture_paths=( "xmldsig/aleksey-xmldsig-01/enveloping-rsa-x509chain.xml" + "xmldsig/merlin-xmldsig-twenty-three" + "xmldsig/external-data/xml-stylesheet-2005" + "xmldsig/external-data/xml-stylesheet-2005.b64" "xmlenc/aleksey-xmlenc-01/enc-aes128cbc-keyname.tmpl" "xmlenc/aleksey-xmlenc-01/enc-aes128gcm-keyname.tmpl" "xmlenc/aleksey-xmlenc-01/enc-aes256cbc-keyname.tmpl" diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index eb6165a7..e70ab311 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -28,6 +28,7 @@ use super::{ #[derive(Debug, Clone)] pub struct HmacSha1VerificationKey { secret: Vec, + output_len: usize, } impl HmacSha1VerificationKey { @@ -37,7 +38,22 @@ impl HmacSha1VerificationKey { if secret.is_empty() { return Err(KeyResolutionError::InvalidPublicKey); } - Ok(Self { secret }) + Ok(Self { + secret, + output_len: 20, + }) + } + + /// Bind this key to an XMLDSig HMAC output length in bits. + pub fn with_output_length_bits( + mut self, + output_length_bits: u16, + ) -> Result { + if !(80..=160).contains(&output_length_bits) || !output_length_bits.is_multiple_of(8) { + return Err(KeyResolutionError::InvalidHmacOutputLength); + } + self.output_len = usize::from(output_length_bits / 8); + Ok(self) } } @@ -51,17 +67,14 @@ impl VerifyingKey for HmacSha1VerificationKey { if algorithm != SignatureAlgorithm::HmacSha1 { return Err(KeyResolutionError::AlgorithmMismatch.into()); } - if !(10..=20).contains(&signature_value.len()) { + if signature_value.len() != self.output_len { return Ok(false); } let mut mac = hmac::Hmac::::new_from_slice(&self.secret) .map_err(|_| KeyResolutionError::InvalidPublicKey)?; mac.update(signed_data); let expected = mac.finalize().into_bytes(); - Ok( - subtle::ConstantTimeEq::ct_eq(&expected[..signature_value.len()], signature_value) - .into(), - ) + Ok(subtle::ConstantTimeEq::ct_eq(&expected[..self.output_len], signature_value).into()) } } @@ -158,6 +171,9 @@ pub enum KeyResolutionError { /// Configured or embedded public key DER could not be parsed completely. #[error("invalid public key DER")] InvalidPublicKey, + /// HMAC-SHA1 output length is outside XMLDSig's byte-aligned 80-160 bit range. + #[error("HMAC-SHA1 output length must be byte-aligned and between 80 and 160 bits")] + InvalidHmacOutputLength, /// More than one configured certificate satisfies all X.509 selectors. #[error("X.509 lookup selectors match multiple configured certificates")] AmbiguousCertificate, @@ -253,6 +269,7 @@ impl DefaultKeyResolver { certificates: vec![certificate.clone()], parsed_certificates: vec![parsed], certificate_chain: vec![0], + crls: info.crls.clone(), ..X509DataInfo::default() }; // Validate the selected certificate's own policy before @@ -459,15 +476,6 @@ impl KeyResolver for DefaultKeyResolver { }) .transpose()?, KeyInfoSource::KeyValue(key_value) => { - if self.config.allow_legacy_rsa_sha1 - && algorithm == SignatureAlgorithm::RsaSha1 - && let KeyValueInfo::Rsa { modulus, exponent } = key_value - { - let public_key_bytes = rsa_key_value_to_spki_der(modulus, exponent)?; - return Ok(Some(Box::new(LegacyRsaSha1VerificationKey { - public_key_bytes, - }))); - } match Self::resolve_key_value(key_value, algorithm) { Ok(resolved) => resolved, Err(error) if ec_key_value_error_allows_fallback(key_value, &error) => { @@ -480,6 +488,11 @@ impl KeyResolver for DefaultKeyResolver { KeyInfoSource::RetrievalMethod { .. } => None, }; if let Some(key) = resolved { + if self.config.allow_legacy_rsa_sha1 && algorithm == SignatureAlgorithm::RsaSha1 { + return Ok(Some(Box::new(LegacyRsaSha1VerificationKey { + public_key_bytes: key.public_key_bytes, + }))); + } return Ok(Some(Box::new(key))); } } @@ -687,6 +700,14 @@ mod tests { pem.contents } + fn crl_der(pem_text: &str) -> Vec { + let (rest, pem) = + x509_parser::pem::parse_x509_pem(pem_text.as_bytes()).expect("fixture CRL is PEM"); + assert!(rest.iter().all(|byte| byte.is_ascii_whitespace())); + assert_eq!(pem.label, "X509 CRL"); + pem.contents + } + #[test] fn defaults_match_key_resolution_policy() { // Defaults must remain compatible with xmlsec1's depth and opt-in trust policy. @@ -718,6 +739,37 @@ mod tests { )); } + #[test] + fn hmac_key_enforces_its_bound_output_length() { + let full = HmacSha1VerificationKey::new(b"secret".to_vec()) + .expect("the fixture HMAC secret is non-empty"); + let truncated = HmacSha1VerificationKey::new(b"secret".to_vec()) + .expect("the fixture HMAC secret is non-empty") + .with_output_length_bits(80) + .expect("80 bits is a valid HMAC-SHA1 output length"); + let mut mac = hmac::Hmac::::new_from_slice(b"secret") + .expect("HMAC accepts an arbitrary non-empty secret"); + mac.update(b"data"); + let expected = mac.finalize().into_bytes(); + + assert!( + !full + .verify(SignatureAlgorithm::HmacSha1, b"data", &expected[..10]) + .expect("the key and algorithm match") + ); + assert!( + truncated + .verify(SignatureAlgorithm::HmacSha1, b"data", &expected[..10]) + .expect("the key and algorithm match") + ); + assert!(matches!( + HmacSha1VerificationKey::new(b"secret".to_vec()) + .expect("the fixture HMAC secret is non-empty") + .with_output_length_bits(79), + Err(KeyResolutionError::InvalidHmacOutputLength) + )); + } + #[test] fn stores_named_verification_key_metadata() { // Named resolution must retain every field needed by the later resolver wiring. @@ -834,6 +886,45 @@ mod tests { assert_eq!(result.status, super::super::DsigStatus::Valid); } + #[test] + fn selector_resolved_certificate_preserves_supplied_crls() { + let selector = "C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), CN=Test Key rsa-2048CRL_PLACEHOLDER"; + let crl = crl_der(include_str!( + "../../tests/fixtures/keys/rsa/rsa-2048-cert-revoked-crl.pem" + )); + let xml = replace_unprefixed_key_info( + RSA_KEY_VALUE_SIGNATURE, + &selector.replace("CRL_PLACEHOLDER", &STANDARD.encode(crl)), + ); + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + trusted_certs: vec![ + certificate_der(include_str!( + "../../tests/fixtures/keys/rsa/rsa-2048-cert.pem" + )), + certificate_der(include_str!("../../tests/fixtures/keys/ca2cert.pem")), + certificate_der(include_str!("../../tests/fixtures/keys/cacert.pem")), + ], + verify_chains: true, + check_crls: true, + verification_time: Some( + SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_773_964_800), + ), + max_chain_depth: 3, + ..KeyResolverConfig::default() + }); + + let error = super::super::VerifyContext::new() + .key_resolver(&resolver) + .verify(&xml) + .expect_err("selector lookup must retain and enforce the supplied CRL"); + assert!(matches!( + error, + DsigError::KeyResolution(KeyResolutionError::Chain( + super::super::X509ChainError::Revoked(0) + )) + )); + } + #[test] fn resolves_each_x509_selector_from_configured_certificates() { // Every selector form documented by KeyInfo must independently locate @@ -1031,6 +1122,57 @@ mod tests { )); } + #[test] + fn legacy_rsa_sha1_policy_applies_to_every_resolved_key_source() { + let certificate = + include_bytes!("../../tests/fixtures/xmldsig/phaos-xmldsig-three/certs/rsa-cert.der") + .to_vec(); + let (_, parsed_certificate) = X509Certificate::from_der(&certificate) + .expect("the Phaos fixture is a DER certificate"); + let public_key = parsed_certificate.public_key().raw.to_vec(); + let certificate_metadata = parse_x509_certificate(&certificate) + .expect("the Phaos fixture has supported X.509 metadata"); + let named_key = VerificationKey { + algorithm: SignatureAlgorithm::RsaSha1, + public_key_bytes: public_key.clone(), + certificate_der: None, + name: Some("legacy".into()), + }; + let key_infos = [ + KeyInfo { + sources: vec![KeyInfoSource::KeyName("legacy".into())], + }, + KeyInfo { + sources: vec![KeyInfoSource::DerEncodedKeyValue(public_key)], + }, + KeyInfo { + sources: vec![KeyInfoSource::X509Data(X509DataInfo { + certificates: vec![certificate], + parsed_certificates: vec![certificate_metadata], + certificate_chain: vec![0], + ..X509DataInfo::default() + })], + }, + ]; + let mut config = KeyResolverConfig { + allow_legacy_rsa_sha1: true, + ..KeyResolverConfig::default() + }; + config.named_keys.insert("legacy".into(), named_key); + let resolver = DefaultKeyResolver::new(config); + + for key_info in &key_infos { + let key = resolver + .resolve(Some(key_info), SignatureAlgorithm::RsaSha1) + .expect("the key source is valid") + .expect("each source must resolve under the legacy policy"); + assert!( + !key.verify(SignatureAlgorithm::RsaSha1, b"data", &[0; 128]) + .expect("the legacy RSA key is structurally valid") + ); + } + } + #[test] fn rsa_key_value_rejects_ecdsa_signature_method() { // Embedded RSA parameters must not be relabeled for an ECDSA SignatureMethod. diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index 9f26de6b..3882c460 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -493,23 +493,18 @@ pub(crate) fn parse_reference_with_xpath_budget( // Optional let mut transforms = Vec::new(); let mut transform_error = None; - let mut next = children.next().ok_or(ParseError::MissingElement { - element: "DigestMethod", - })?; + let (transforms_node, digest_method_node) = + reference_transforms_and_digest_method(&mut children)?; - if next.tag_name().name() == "Transforms" && next.tag_name().namespace() == Some(XMLDSIG_NS) { - match transforms::parse_transforms_with_budget(next, xpath_budget) { + if let Some(transforms_node) = transforms_node { + match transforms::parse_transforms_with_budget(transforms_node, xpath_budget) { Ok(parsed) => transforms = parsed, Err(error) => transform_error = Some(error), } - next = children.next().ok_or(ParseError::MissingElement { - element: "DigestMethod", - })?; } // Required - verify_ds_element(next, "DigestMethod")?; - let digest_uri = required_algorithm_attr(next, "DigestMethod")?; + let digest_uri = required_algorithm_attr(digest_method_node, "DigestMethod")?; let digest_method = DigestAlgorithm::from_uri(digest_uri).ok_or_else(|| ParseError::UnsupportedAlgorithm { uri: digest_uri.to_string(), @@ -552,21 +547,31 @@ pub(crate) fn reference_digest_method( ) -> Result { verify_ds_element(reference_node, "Reference")?; let mut children = element_children(reference_node); - let mut next = children.next().ok_or(ParseError::MissingElement { - element: "DigestMethod", - })?; - if next.tag_name().namespace() == Some(XMLDSIG_NS) && next.tag_name().name() == "Transforms" { - next = children.next().ok_or(ParseError::MissingElement { - element: "DigestMethod", - })?; - } - verify_ds_element(next, "DigestMethod")?; - let uri = required_algorithm_attr(next, "DigestMethod")?; + let (_, digest_method_node) = reference_transforms_and_digest_method(&mut children)?; + let uri = required_algorithm_attr(digest_method_node, "DigestMethod")?; DigestAlgorithm::from_uri(uri).ok_or_else(|| ParseError::UnsupportedAlgorithm { uri: uri.to_owned(), }) } +fn reference_transforms_and_digest_method<'a, 'input>( + children: &mut impl Iterator>, +) -> Result<(Option>, Node<'a, 'input>), ParseError> { + let first = children.next().ok_or(ParseError::MissingElement { + element: "DigestMethod", + })?; + let transforms_node = is_ds_element(first, "Transforms").then_some(first); + let digest_method_node = if transforms_node.is_some() { + children.next().ok_or(ParseError::MissingElement { + element: "DigestMethod", + })? + } else { + first + }; + verify_ds_element(digest_method_node, "DigestMethod")?; + Ok((transforms_node, digest_method_node)) +} + /// Parse `` and dispatch supported child sources. /// /// Supported source elements: @@ -666,9 +671,19 @@ fn parse_retrieval_method_transforms( if parameters.next().is_some() || xpath.tag_name().namespace() != Some(XMLDSIG_NS) || xpath.tag_name().name() != "XPath" - || xpath.text().unwrap_or_default().trim() != "ancestor-or-self::dsig:X509Data" - || xpath.lookup_namespace_uri(Some("dsig")) != Some(XMLDSIG_NS) { + return Err(ParseError::InvalidStructure( + "unsupported RetrievalMethod transform chain".into(), + )); + } + let expression = xpath.text().unwrap_or_default().trim(); + let selects_x509_data = expression + .strip_prefix("ancestor-or-self::") + .and_then(|step| step.split_once(':')) + .is_some_and(|(prefix, local)| { + local == "X509Data" && xpath.lookup_namespace_uri(Some(prefix)) == Some(XMLDSIG_NS) + }); + if !selects_x509_data { return Err(ParseError::InvalidStructure( "unsupported RetrievalMethod XPath selection".into(), )); @@ -809,14 +824,40 @@ fn parse_dsa_key_value(node: Node<'_, '_>) -> Result { let q = next("Q")?; let g = next("G")?; let y = next("Y")?; - if children.next().is_some() { + let optional = children.collect::>(); + let valid_optional = match optional.as_slice() { + [] => true, + [j] => is_ds_element(*j, "J"), + [seed, counter] => is_ds_element(*seed, "Seed") && is_ds_element(*counter, "PgenCounter"), + [j, seed, counter] => { + is_ds_element(*j, "J") + && is_ds_element(*seed, "Seed") + && is_ds_element(*counter, "PgenCounter") + } + _ => false, + }; + if !valid_optional { return Err(ParseError::InvalidStructure( - "DSAKeyValue supports exactly P, Q, G, and Y".into(), + "DSAKeyValue optional children must be J and/or a Seed/PgenCounter pair".into(), )); } + for child in optional { + let name = match child.tag_name().name() { + "J" => "J", + "Seed" => "Seed", + "PgenCounter" => "PgenCounter", + _ => unreachable!("optional DSA child shape was validated above"), + }; + ensure_no_element_children(child, name)?; + decode_crypto_binary(child, name, MAX_RSA_MODULUS_LEN)?; + } Ok(KeyValueInfo::Dsa { p, q, g, y }) } +fn is_ds_element(node: Node<'_, '_>, name: &str) -> bool { + node.tag_name().namespace() == Some(XMLDSIG_NS) && node.tag_name().name() == name +} + fn parse_ec_key_value(node: Node<'_, '_>) -> Result { verify_dsig11_element(node, "ECKeyValue")?; ensure_no_non_whitespace_text(node, "ECKeyValue")?; @@ -2917,6 +2958,62 @@ BA== )); } + #[test] + fn parse_key_info_accepts_namespace_equivalent_retrieval_xpath_prefix() { + let xml = r##" + + + ancestor-or-self::ds:X509Data + + + "##; + let doc = Document::parse(xml).unwrap(); + + assert!(matches!( + parse_key_info(doc.root_element()) + .unwrap() + .sources + .as_slice(), + [KeyInfoSource::RetrievalMethod { + transforms: RetrievalMethodTransforms::X509DataAncestor, + .. + }] + )); + } + + #[test] + fn parse_dsa_key_value_accepts_schema_optional_parameters_and_rejects_half_pair() { + let key_info = |optional: &str| { + format!( + r#" +

AQ==

AQ==AQ==AQ=={optional} +
"# + ) + }; + for optional in [ + "AQ==", + "AQ==AQ==", + "AQ==AQ==AQ==", + ] { + let xml = key_info(optional); + let doc = Document::parse(&xml).unwrap(); + assert!(matches!( + parse_key_info(doc.root_element()) + .unwrap() + .sources + .as_slice(), + [KeyInfoSource::KeyValue(KeyValueInfo::Dsa { .. })] + )); + } + + let xml = key_info("AQ=="); + let doc = Document::parse(&xml).unwrap(); + assert!(matches!( + parse_key_info(doc.root_element()), + Err(ParseError::InvalidStructure(_)) + )); + } + #[test] fn parse_key_info_rejects_unimplemented_retrieval_transform() { // Retrieval transforms must never be silently ignored when choosing a key. diff --git a/src/xmldsig/signature.rs b/src/xmldsig/signature.rs index b03548a4..315dabdc 100644 --- a/src/xmldsig/signature.rs +++ b/src/xmldsig/signature.rs @@ -221,11 +221,12 @@ pub fn verify_dsa_signature_spki( } let key = dsa::VerifyingKey::from_public_key_der(public_key_spki_der) .map_err(|_| SignatureVerificationError::InvalidKeyDer)?; - let signature = dsa::Signature::from_components( + let Some(signature) = dsa::Signature::from_components( crypto_bigint::BoxedUint::from_be_slice_vartime(&signature_value[..20]), crypto_bigint::BoxedUint::from_be_slice_vartime(&signature_value[20..]), - ) - .ok_or(SignatureVerificationError::InvalidSignatureFormat)?; + ) else { + return Ok(false); + }; let digest = Sha1::digest(signed_data); Ok(key.verify_prehash(&digest, &signature).is_ok()) } @@ -300,7 +301,7 @@ fn validate_rsa_public_key( algorithm: SignatureAlgorithm, minimum_modulus_bits: usize, ) -> Result<(), SignatureVerificationError> { - minimum_rsa_modulus_bits(algorithm)?; + ensure_rsa_signature_algorithm(algorithm)?; let modulus_start = rsa .modulus .iter() @@ -331,14 +332,14 @@ fn validate_rsa_public_key( Ok(()) } -fn minimum_rsa_modulus_bits( +fn ensure_rsa_signature_algorithm( algorithm: SignatureAlgorithm, -) -> Result { +) -> Result<(), SignatureVerificationError> { match algorithm { SignatureAlgorithm::RsaSha1 | SignatureAlgorithm::RsaSha256 | SignatureAlgorithm::RsaSha384 - | SignatureAlgorithm::RsaSha512 => Ok(2048), + | SignatureAlgorithm::RsaSha512 => Ok(()), _ => Err(SignatureVerificationError::UnsupportedAlgorithm { uri: algorithm.uri().to_string(), }), @@ -692,7 +693,7 @@ mod tests { SignatureAlgorithm::EcdsaP256Sha256, SignatureAlgorithm::EcdsaP384Sha384, ] { - let err = minimum_rsa_modulus_bits(algorithm).unwrap_err(); + let err = ensure_rsa_signature_algorithm(algorithm).unwrap_err(); assert!(matches!( err, SignatureVerificationError::UnsupportedAlgorithm { .. } @@ -700,6 +701,24 @@ mod tests { } } + #[test] + fn malformed_dsa_components_are_verification_misses() { + let public_key = include_bytes!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh.der" + ); + let signature = [0_u8; 40]; + + assert!(matches!( + verify_dsa_signature_spki( + SignatureAlgorithm::DsaSha1, + public_key, + b"signed", + &signature, + ), + Ok(false) + )); + } + #[test] fn der_like_prefix_with_fixed_width_len_is_classified_as_raw() { let mut signature = vec![0xAA_u8; 96]; diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index c09271b2..5c1f0a09 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -785,6 +785,7 @@ fn verify_signature_with_context( &signed_info.references, ctx.allowed_uri_types, ctx.allowed_transform_uris(), + ctx.external_resources, )?; if let Some(resources) = ctx.external_resources { @@ -991,17 +992,30 @@ fn materialize_retrieval_methods( reason: "X509Data RetrievalMethod target is missing or ambiguous", }, )?; + let containing = target.ancestors().find(|candidate| { + candidate.is_element() + && candidate.tag_name().namespace() == Some(XMLDSIG_NS) + && candidate.tag_name().name() == "X509Data" + }); let mut selected = target.descendants().filter(|candidate| { candidate.is_element() && candidate.tag_name().namespace() == Some(XMLDSIG_NS) && candidate.tag_name().name() == "X509Data" + && Some(*candidate) != containing }); - let node = - selected - .next() - .ok_or(SignatureVerificationPipelineError::InvalidStructure { + let node = match (containing, selected.next()) { + (Some(node), None) | (None, Some(node)) => node, + (None, None) => { + return Err(SignatureVerificationPipelineError::InvalidStructure { reason: "X509Data RetrievalMethod selected no X509Data element", - })?; + }); + } + (Some(_), Some(_)) => { + return Err(SignatureVerificationPipelineError::InvalidStructure { + reason: "X509Data RetrievalMethod selected multiple X509Data elements", + }); + } + }; if selected.next().is_some() { return Err(SignatureVerificationPipelineError::InvalidStructure { reason: "X509Data RetrievalMethod selected multiple X509Data elements", @@ -1041,6 +1055,7 @@ fn process_manifest_references( std::slice::from_ref(reference), ctx.allowed_uri_types, ctx.allowed_transform_uris(), + ctx.external_resources, ) { Ok(()) => {} Err( @@ -1165,7 +1180,7 @@ fn parse_manifest_references( reason: "Manifest must contain only ds:Reference element children", }); } - if references.len() == MAX_REFERENCES_PER_SIGNATURE { + if references.len() + invalid.len() == MAX_REFERENCES_PER_SIGNATURE { return Err(SignatureVerificationPipelineError::InvalidStructure { reason: "signed Manifests exceed the per-signature Reference limit", }); @@ -1290,6 +1305,7 @@ fn enforce_reference_policies( references: &[Reference], allowed_uri_types: UriTypeSet, allowed_transforms: Option<&HashSet>, + external_resources: Option<&HashMap>>, ) -> Result<(), SignatureVerificationPipelineError> { for reference in references { let uri = reference @@ -1314,9 +1330,13 @@ fn enforce_reference_policies( } } - let produces_binary = reference.transforms.last().is_some_and(|transform| { - matches!(transform, Transform::C14n(_) | Transform::Base64Decode) - }); + let dereferences_to_binary = !uri.is_empty() + && !uri.starts_with('#') + && external_resources.is_some_and(|resources| resources.contains_key(uri)); + let produces_binary = dereferences_to_binary + || reference.transforms.last().is_some_and(|transform| { + matches!(transform, Transform::C14n(_) | Transform::Base64Decode) + }); if !produces_binary && !allowed.contains(DEFAULT_IMPLICIT_C14N_URI) { return Err(SignatureVerificationPipelineError::DisallowedTransform { algorithm: DEFAULT_IMPLICIT_C14N_URI.to_owned(), @@ -2373,6 +2393,71 @@ mod tests { )); } + #[test] + fn manifest_reference_limit_counts_unsupported_entries() { + let references = (0..=MAX_REFERENCES_PER_SIGNATURE) + .map(|index| { + format!( + r##"AAAAAAAAAAAAAAAAAAAAAAAAAAA="## + ) + }) + .collect::(); + let xml = format!( + r#"{references}"# + ); + let document = Document::parse(&xml).unwrap(); + let signature = document.root_element(); + let object = signature.children().find(|node| node.is_element()).unwrap(); + let authenticated = HashSet::from([object.id()]); + + let error = match parse_manifest_references( + signature, + &authenticated, + &mut XPathSignatureParseBudget::default(), + ) { + Ok(_) => panic!("unsupported references must consume the same aggregate limit"), + Err(error) => error, + }; + assert!(matches!( + error, + SignatureVerificationPipelineError::InvalidStructure { + reason: "signed Manifests exceed the per-signature Reference limit" + } + )); + } + + #[test] + fn retrieval_method_materializes_containing_or_descendant_x509_data() { + for target_xml in [ + r#"CN=leaf"#, + r#"CN=leaf"#, + ] { + let xml = format!( + r##"ancestor-or-self::ds:X509Data{target_xml}"## + ); + let document = Document::parse(&xml).unwrap(); + let key_info_node = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) + .unwrap(); + let mut key_info = parse_key_info(key_info_node).unwrap(); + let resolver = UriReferenceResolver::new(&document); + + materialize_retrieval_methods( + &mut key_info, + &resolver, + None, + UriTypeSet::SAME_DOCUMENT, + ) + .expect("ancestor-or-self selection must accept either relation"); + assert!(key_info.sources.iter().any(|source| matches!( + source, + super::super::parse::KeyInfoSource::X509Data(info) + if info.subject_names == ["CN=leaf"] + ))); + } + } + #[test] fn verify_context_does_not_hide_malformed_digest_behind_unsupported_transform() { // A bad DigestValue remains a parse error even when its transform URI is unsupported. @@ -2628,7 +2713,7 @@ mod tests { allow_external: false, }; - let err = enforce_reference_policies(&references, uri_types, None) + let err = enforce_reference_policies(&references, uri_types, None, None) .expect_err("missing URI must fail before allow_empty policy is evaluated"); assert!(matches!( err, @@ -2654,6 +2739,7 @@ mod tests { std::slice::from_ref(&reference), UriTypeSet::default(), Some(&allowed), + None, ) .expect("terminal binary output must not require implicit C14N"); } @@ -2668,6 +2754,7 @@ mod tests { std::slice::from_ref(&terminal_base64), UriTypeSet::default(), Some(&without_implicit_c14n), + None, ) .expect("terminal Base64 output must not require implicit C14N"); @@ -2676,6 +2763,7 @@ mod tests { std::slice::from_ref(&no_transforms), UriTypeSet::default(), Some(&without_implicit_c14n), + None, ) .expect_err("a node-set result must require allowlisted implicit C14N"); assert!(matches!( @@ -2683,6 +2771,16 @@ mod tests { SignatureVerificationPipelineError::DisallowedTransform { ref algorithm } if algorithm == DEFAULT_IMPLICIT_C14N_URI )); + + let external_resources = HashMap::from([("urn:payload".to_owned(), b"bytes".to_vec())]); + let detached = make_reference("urn:payload", vec![], DigestAlgorithm::Sha256, vec![0; 32]); + enforce_reference_policies( + std::slice::from_ref(&detached), + UriTypeSet::ALL, + Some(&without_implicit_c14n), + Some(&external_resources), + ) + .expect("external octets without transforms must not require implicit C14N"); } #[test] diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index b09f63bf..e57564f3 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -209,16 +209,42 @@ fn verify_certificate_signature( { return true; } - if certificate.signature_algorithm.algorithm.to_id_string() != "1.2.840.10040.4.3" { + verify_dsa_sha1_signature( + &certificate.signature_algorithm.algorithm.to_id_string(), + &certificate.signature_value.data, + certificate.tbs_certificate.as_ref(), + issuer.public_key().raw, + ) +} + +fn verify_crl_signature(crl: &CertificateRevocationList<'_>, issuer: &X509Certificate<'_>) -> bool { + if crl.verify_signature(issuer.public_key()).is_ok() { + return true; + } + verify_dsa_sha1_signature( + &crl.signature_algorithm.algorithm.to_id_string(), + &crl.signature_value.data, + crl.tbs_cert_list.as_ref(), + issuer.public_key().raw, + ) +} + +fn verify_dsa_sha1_signature( + algorithm_oid: &str, + signature_der: &[u8], + signed_data: &[u8], + issuer_spki_der: &[u8], +) -> bool { + if algorithm_oid != "1.2.840.10040.4.3" { return false; } - let Ok(key) = dsa::VerifyingKey::from_public_key_der(issuer.public_key().raw) else { + let Ok(key) = dsa::VerifyingKey::from_public_key_der(issuer_spki_der) else { return false; }; - let Ok(signature) = dsa::Signature::from_der(&certificate.signature_value.data) else { + let Ok(signature) = dsa::Signature::from_der(signature_der) else { return false; }; - let digest = Sha1::digest(certificate.tbs_certificate.as_ref()); + let digest = Sha1::digest(signed_data); key.verify_prehash(&digest, &signature).is_ok() } @@ -349,7 +375,7 @@ fn verify_crls( && crl .next_update() .is_none_or(|next| verification_time <= next); - if !time_valid || crl.verify_signature(issuer.public_key()).is_err() { + if !time_valid || !verify_crl_signature(crl, issuer) { return Err(X509ChainError::InvalidCrl(*crl_index)); } if crl.iter_revoked_certificates().any(|revoked| { @@ -362,3 +388,34 @@ fn verify_crls( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::xmldsig::{KeyInfoSource, parse::XMLDSIG_NS, parse_key_info}; + use roxmltree::Document; + + #[test] + fn dsa_sha1_crl_signature_uses_the_same_fallback_as_certificates() { + let xml = include_str!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt-crl.xml" + ); + let document = Document::parse(xml).expect("the tracked Merlin document is valid XML"); + let key_info_node = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) + .expect("the Merlin document contains KeyInfo"); + let key_info = parse_key_info(key_info_node).expect("the Merlin KeyInfo is valid"); + let KeyInfoSource::X509Data(info) = &key_info.sources[0] else { + panic!("expected X509Data") + }; + let (_, issuer) = X509Certificate::from_der(include_bytes!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/ca.der" + )) + .expect("the tracked Merlin issuer is a DER certificate"); + let (_, crl) = CertificateRevocationList::from_der(&info.crls[0]) + .expect("the tracked Merlin CRL is valid DER"); + + assert!(verify_crl_signature(&crl, &issuer)); + } +} diff --git a/tests/fixtures/xmldsig/external-data/xml-stylesheet-2005 b/tests/fixtures/xmldsig/external-data/xml-stylesheet-2005 new file mode 100644 index 00000000..de8e119b --- /dev/null +++ b/tests/fixtures/xmldsig/external-data/xml-stylesheet-2005 @@ -0,0 +1,341 @@ + + + +Associating Style Sheets with XML documents + + + + +
+W3C +

Associating Style Sheets with XML documents
Version 1.0

+

W3C Recommendation 29 June 1999

+
+
This version:
+
+http://www.w3.org/1999/06/REC-xml-stylesheet-19990629 +
+
+
Latest version:
+
+http://www.w3.org/TR/xml-stylesheet +
+
+
Previous version:
+
+http://www.w3.org/TR/1999/xml-stylesheet-19990428 +
+
+
Editor:
+
+ +James Clark +<jjc@jclark.com> +
+
+
+ +
+
+

+Abstract +

+ +

This document allows a style sheet to be associated with an XML +document by including one or more processing instructions with a +target of xml-stylesheet in the document's prolog.

+ +

+Status of this document +

+ +

This document has been reviewed by W3C Members and other interested +parties and has been endorsed by the Director as a W3C Recommendation. It +is a stable document and may be used as reference material or cited as +a normative reference from other documents. W3C's role in making the +Recommendation is to draw attention to the specification and to +promote its widespread deployment. This enhances the functionality and +interoperability of the Web.

+ +

The list of known errors in this specifications is available at +http://www.w3.org/TR/1999/xml-stylesheet-19990629/errata.

+ +

Comments on this specification may be sent to <www-xml-stylesheet-comments@w3.org>. The archive of public +comments is available at http://w3.org/Archives/Public/www-xml-stylesheet-comments.

+ +

A list of current W3C Recommendations and other technical documents +can be found at http://www.w3.org/TR.

+ +

The Working Group expects additional mechanisms for linking style +sheets to XML document to be defined in a future specification.

+ +

The use of XML processing instructions in this specification should +not be taken as a precedent. The W3C does not anticipate recommending +the use of processing instructions in any future specification. The +Rationale explains why they were used in +this specification.

+ +

This document was produced as part of the W3C XML Activity.

+ + +

+Table of contents +

1 The xml-stylesheet processing instruction +
+

Appendices

A References +
B Rationale +
+
+ +

+1 The xml-stylesheet processing instruction

+ +

Style Sheets can be associated with an XML[XML10] +document by using a processing instruction whose target is +xml-stylesheet. This processing instruction follows the +behaviour of the HTML 4.0 <LINK +REL="stylesheet">[HTML40].

+ +

The xml-stylesheet processing instruction is parsed in +the same way as a start-tag, with the exception that entities other +than predefined entities must not be referenced.

+ +

The following grammar is given using the same notation as the +grammar in the XML Recommendation[XML10]. Symbols in the +grammar that are not defined here are defined in the XML +Recommendation.

+ +
xml-stylesheet processing instruction
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+[1]   StyleSheetPI   ::=   '<?xml-stylesheet' (S PseudoAtt)* S? '?>' +
+[2]   PseudoAtt   ::=    +Name S? '=' S? PseudoAttValue + +
+[3]   PseudoAttValue   ::=   ('"' ([^"<&] | CharRef | PredefEntityRef)* '"' +
+ + +| "'" ([^'<&] | CharRef | PredefEntityRef)* "'") +
+ + +- (Char* '?>' Char*) +
+[4]   PredefEntityRef   ::=   '&amp;' | '&lt;' | '&gt;' | '&quot;' | '&apos;' +
+ +

In PseudoAttValue, a CharRef or a PredefEntityRef is interpreted in the +same manner as in a normal XML attribute value. The actual value of +the pseudo-attribute is the value after each reference is replaced by +the character it references. This replacement is not performed +automatically by an XML processor.

+ +

The xml-stylesheet processing instruction is allowed +only in the prolog of an XML document. The syntax of XML constrains +where processing instructions are allowed in the prolog; the +xml-stylesheet processing instruction is allowed anywhere +in the prolog that meets these constraints.

+ +
+NOTE: If the xml-stylesheet processing instruction +occurs in the external DTD subset or in a parameter entity, it is +possible that it may not be processed by a non-validating XML +processor (see [XML10]).
+ +

The following pseudo attributes are defined

+ +
href CDATA #REQUIRED
+type CDATA #REQUIRED
+title CDATA #IMPLIED
+media CDATA #IMPLIED
+charset CDATA #IMPLIED
+alternate (yes|no) "no"
+ +

The semantics of the pseudo-attributes are exactly as with +<LINK REL="stylesheet"> in HTML 4.0, with the +exception of the alternate pseudo-attribute. If +alternate="yes" is specified, then the processing +instruction has the semantics of <LINK REL="alternate +stylesheet"> instead of <LINK +REL="stylesheet">.

+ +
+NOTE: Since the value of the href attribute is a URI +reference, it may be a relative URI and it may contain a fragment +identifier. In particular the URI reference may contain only a +fragment identifier. Such a URI reference is a reference to a part of +the document containing the xml-stylesheet processing +instruction (see [RFC2396]). The consequence is that the +xml-stylesheet processing instruction allows style sheets +to be embedded in the same document as the xml-stylesheet +processing instruction.
+ +

In some cases, style sheets may be linked with an XML document by +means external to the document. For example, earlier versions of HTTP +[RFC2068] (section 19.6.2.4) allowed style sheets to be +associated with XML documents by means of the Link +header. Any links to style sheets that are specified externally to the +document are considered to occur before the links specified by the +xml-stylesheet processing instructions. This is the same +as in HTML 4.0 (see section +14.6).

+ +

Here are some examples from HTML 4.0 with the corresponding +processing instruction:

+ +
<LINK href="mystyle.css" rel="style sheet" type="text/css">
+<?xml-stylesheet href="mystyle.css" type="text/css"?>
+
+<LINK href="mystyle.css" title="Compact" rel="stylesheet"
+type="text/css">
+<?xml-stylesheet href="mystyle.css" title="Compact" type="text/css"?>
+
+<LINK href="mystyle.css" title="Medium" rel="alternate stylesheet"
+type="text/css">
+<?xml-stylesheet alternate="yes" href="mystyle.css" title="Medium"
+type="text/css"?>
+ +

Multiple xml-stylesheet processing instructions are +also allowed with exactly the same semantics as with LINK +REL="stylesheet". For example,

+ +
<LINK rel="alternate stylesheet" title="compact" href="small-base.css"
+type="text/css">
+<LINK rel="alternate stylesheet" title="compact" href="small-extras.css"
+type="text/css">
+<LINK rel="alternate stylesheet" title="big print" href="bigprint.css"
+type="text/css">
+<LINK rel="stylesheet" href="common.css" type="text/css">
+ +

would be equivalent to:

+ +
<?xml-stylesheet alternate="yes" title="compact" href="small-base.css"
+type="text/css"?>
+<?xml-stylesheet alternate="yes" title="compact" href="small-extras.css"
+type="text/css"?>
+<?xml-stylesheet alternate="yes" title="big print" href="bigprint.css"
+type="text/css"?>
+<?xml-stylesheet href="common.css" type="text/css"?>
+ + + +
+ +

+A References

+ +
+ +
+HTML40 +
+
World Wide Web +Consortium. HTML 4.0 Specification. W3C Recommendation. See +http://www.w3.org/TR/REC-html40 +
+ +
+RFC2068 +
+
R. Fielding, J. Gettys, J. Mogul, +H. Frystyk Nielsen, and T. Berners-Lee. Hypertext Transfer +Protocol -- HTTP/1.1.. IETF RFC 2068. See http://www.ietf.org/rfc/rfc2068.txt.
+ +
+RFC2396 +
+
T. Berners-Lee, R. Fielding, and +L. Masinter. Uniform Resource Identifiers (URI): Generic +Syntax. IETF RFC 2396. See http://www.ietf.org/rfc/rfc2396.txt.
+ +
+XML10 +
+
World Wide Web Consortium. Extensible +Markup Language (XML) 1.0. W3C Recommendation. See http://www.w3.org/TR/1998/REC-xml-19980210 +
+ +
+ + + + +

+B Rationale

+ +

There was an urgent requirement for a specification for style sheet +linking that could be completed in time for the next release from +major browser vendors. Only by choosing a simple mechanism closely +based on a proven existing mechanism could the specification be +completed in time to meet this requirement.

+ +

Use of a processing instruction avoids polluting the main document +structure with application specific processing information.

+ +

The mechanism chosen for this version of the specification is not a +constraint on the additional mechanisms planned for future versions. +There is no expectation that these will use processing instructions; +indeed they may not include the linking information in the source +document.

+ + + + + + diff --git a/tests/fixtures/xmldsig/external-data/xml-stylesheet-2005.b64 b/tests/fixtures/xmldsig/external-data/xml-stylesheet-2005.b64 new file mode 100644 index 00000000..eb9a11ab --- /dev/null +++ b/tests/fixtures/xmldsig/external-data/xml-stylesheet-2005.b64 @@ -0,0 +1,274 @@ +PCFET0NUWVBFIGh0bWwgUFVCTElDICItLy9XM0MvL0RURCBIVE1MIDQuMCBUcmFu +c2l0aW9uYWwvL0VOIj4KPGh0bWw+CjxoZWFkPgo8dGl0bGU+QXNzb2NpYXRpbmcg +U3R5bGUgU2hlZXRzIHdpdGggWE1MIGRvY3VtZW50czwvdGl0bGU+CjxsaW5rIHJl +bD0ic3R5bGVzaGVldCIgdHlwZT0idGV4dC9jc3MiIGhyZWY9Imh0dHA6Ly93d3cu +dzMub3JnL1N0eWxlU2hlZXRzL1RSL1czQy1SRUMiPgo8c3R5bGUgdHlwZT0idGV4 +dC9jc3MiPmNvZGUgeyBmb250LWZhbWlseTogbW9ub3NwYWNlIH08L3N0eWxlPgo8 +L2hlYWQ+Cjxib2R5Pgo8ZGl2IGNsYXNzPSJoZWFkIj4KPGEgaHJlZj0iaHR0cDov +L3d3dy53My5vcmcvIj48aW1nIHNyYz0iaHR0cDovL3d3dy53My5vcmcvSWNvbnMv +V1dXL3czY19ob21lIiBhbHQ9IlczQyIgaGVpZ2h0PSI0OCIgd2lkdGg9IjcyIj48 +L2E+CjxoMT5Bc3NvY2lhdGluZyBTdHlsZSBTaGVldHMgd2l0aCBYTUwgZG9jdW1l +bnRzPGJyPlZlcnNpb24gMS4wPC9oMT4KPGgyPlczQyBSZWNvbW1lbmRhdGlvbiAy +OSBKdW5lIDE5OTk8L2gyPgo8ZGw+CjxkdD5UaGlzIHZlcnNpb246PC9kdD4KPGRk +Pgo8YSBocmVmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzA2L1JFQy14bWwtc3R5 +bGVzaGVldC0xOTk5MDYyOSI+aHR0cDovL3d3dy53My5vcmcvMTk5OS8wNi9SRUMt +eG1sLXN0eWxlc2hlZXQtMTk5OTA2Mjk8L2E+Cjxicj4KPC9kZD4KPGR0PkxhdGVz +dCB2ZXJzaW9uOjwvZHQ+CjxkZD4KPGEgaHJlZj0iaHR0cDovL3d3dy53My5vcmcv +VFIveG1sLXN0eWxlc2hlZXQiPmh0dHA6Ly93d3cudzMub3JnL1RSL3htbC1zdHls +ZXNoZWV0PC9hPgo8YnI+CjwvZGQ+CjxkdD5QcmV2aW91cyB2ZXJzaW9uOjwvZHQ+ +CjxkZD4KPGEgaHJlZj0iaHR0cDovL3d3dy53My5vcmcvVFIvMTk5OS94bWwtc3R5 +bGVzaGVldC0xOTk5MDQyOCI+aHR0cDovL3d3dy53My5vcmcvVFIvMTk5OS94bWwt +c3R5bGVzaGVldC0xOTk5MDQyODwvYT4KPGJyPgo8L2RkPgo8ZHQ+RWRpdG9yOjwv +ZHQ+CjxkZD4KCkphbWVzIENsYXJrCjxhIGhyZWY9Im1haWx0bzpqamNAamNsYXJr +LmNvbSI+Jmx0O2pqY0BqY2xhcmsuY29tJmd0OzwvYT4KPGJyPgo8L2RkPgo8L2Rs +Pgo8cCBjbGFzcz0iY29weXJpZ2h0Ij4KPGEgaHJlZj0iaHR0cDovL3d3dy53My5v +cmcvQ29uc29ydGl1bS9MZWdhbC9pcHItbm90aWNlLmh0bWwjQ29weXJpZ2h0Ij4K +CQlDb3B5cmlnaHQ8L2E+ICZuYnNwOyZjb3B5OyZuYnNwOyAxOTk5IDxhIGhyZWY9 +Imh0dHA6Ly93d3cudzMub3JnIj5XM0M8L2E+CgkJKDxhIGhyZWY9Imh0dHA6Ly93 +d3cubGNzLm1pdC5lZHUiPk1JVDwvYT4sCgkJPGEgaHJlZj0iaHR0cDovL3d3dy5p +bnJpYS5mci8iPklOUklBPC9hPiwKCQk8YSBocmVmPSJodHRwOi8vd3d3LmtlaW8u +YWMuanAvIj5LZWlvPC9hPiApLCBBbGwgUmlnaHRzIFJlc2VydmVkLiBXM0MKCQk8 +YSBocmVmPSJodHRwOi8vd3d3LnczLm9yZy9Db25zb3J0aXVtL0xlZ2FsL2lwci1u +b3RpY2UuaHRtbCNMZWdhbCBEaXNjbGFpbWVyIj5saWFiaWxpdHksPC9hPjxhIGhy +ZWY9Imh0dHA6Ly93d3cudzMub3JnL0NvbnNvcnRpdW0vTGVnYWwvaXByLW5vdGlj +ZS5odG1sI1czQyBUcmFkZW1hcmtzIj50cmFkZW1hcms8L2E+LAoJCTxhIGhyZWY9 +Imh0dHA6Ly93d3cudzMub3JnL0NvbnNvcnRpdW0vTGVnYWwvY29weXJpZ2h0LWRv +Y3VtZW50cy5odG1sIj5kb2N1bWVudCB1c2UgPC9hPmFuZAoJCTxhIGhyZWY9Imh0 +dHA6Ly93d3cudzMub3JnL0NvbnNvcnRpdW0vTGVnYWwvY29weXJpZ2h0LXNvZnR3 +YXJlLmh0bWwiPnNvZnR3YXJlIGxpY2Vuc2luZyA8L2E+cnVsZXMgYXBwbHkuCgk8 +L3A+CjxociB0aXRsZT0iU2VwYXJhdG9yIGZvciBoZWFkZXIiPgo8L2Rpdj4KPGgy +Pgo8YSBuYW1lPSJhYnN0cmFjdCI+QWJzdHJhY3Q8L2E+CjwvaDI+Cgo8cD5UaGlz +IGRvY3VtZW50IGFsbG93cyBhIHN0eWxlIHNoZWV0IHRvIGJlIGFzc29jaWF0ZWQg +d2l0aCBhbiBYTUwKZG9jdW1lbnQgYnkgaW5jbHVkaW5nIG9uZSBvciBtb3JlIHBy +b2Nlc3NpbmcgaW5zdHJ1Y3Rpb25zIHdpdGggYQp0YXJnZXQgb2YgPGNvZGU+eG1s +LXN0eWxlc2hlZXQ8L2NvZGU+IGluIHRoZSBkb2N1bWVudCdzIHByb2xvZy48L3A+ +Cgo8aDI+CjxhIG5hbWU9InN0YXR1cyI+U3RhdHVzIG9mIHRoaXMgZG9jdW1lbnQ8 +L2E+CjwvaDI+Cgo8cD5UaGlzIGRvY3VtZW50IGhhcyBiZWVuIHJldmlld2VkIGJ5 +IFczQyBNZW1iZXJzIGFuZCBvdGhlciBpbnRlcmVzdGVkCnBhcnRpZXMgYW5kIGhh +cyBiZWVuIGVuZG9yc2VkIGJ5IHRoZSBEaXJlY3RvciBhcyBhIFczQyA8YSBocmVm +PSJodHRwOi8vd3d3LnczLm9yZy9Db25zb3J0aXVtL1Byb2Nlc3MvI1JlY3NXM0Mi +PlJlY29tbWVuZGF0aW9uPC9hPi4gSXQKaXMgYSBzdGFibGUgZG9jdW1lbnQgYW5k +IG1heSBiZSB1c2VkIGFzIHJlZmVyZW5jZSBtYXRlcmlhbCBvciBjaXRlZCBhcwph +IG5vcm1hdGl2ZSByZWZlcmVuY2UgZnJvbSBvdGhlciBkb2N1bWVudHMuIFczQydz +IHJvbGUgaW4gbWFraW5nIHRoZQpSZWNvbW1lbmRhdGlvbiBpcyB0byBkcmF3IGF0 +dGVudGlvbiB0byB0aGUgc3BlY2lmaWNhdGlvbiBhbmQgdG8KcHJvbW90ZSBpdHMg +d2lkZXNwcmVhZCBkZXBsb3ltZW50LiBUaGlzIGVuaGFuY2VzIHRoZSBmdW5jdGlv +bmFsaXR5IGFuZAppbnRlcm9wZXJhYmlsaXR5IG9mIHRoZSBXZWIuPC9wPgoKPHA+ +VGhlIGxpc3Qgb2Yga25vd24gZXJyb3JzIGluIHRoaXMgc3BlY2lmaWNhdGlvbnMg +aXMgYXZhaWxhYmxlIGF0CjxhIGhyZWY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkv +MDYvUkVDLXhtbC1zdHlsZXNoZWV0LTE5OTkwNjI5L2VycmF0YSI+aHR0cDovL3d3 +dy53My5vcmcvVFIvMTk5OS94bWwtc3R5bGVzaGVldC0xOTk5MDYyOS9lcnJhdGE8 +L2E+LjwvcD4KCjxwPkNvbW1lbnRzIG9uIHRoaXMgc3BlY2lmaWNhdGlvbiBtYXkg +YmUgc2VudCB0byAmbHQ7PGEgaHJlZj0ibWFpbHRvOnd3dy14bWwtc3R5bGVzaGVl +dC1jb21tZW50c0B3My5vcmciPnd3dy14bWwtc3R5bGVzaGVldC1jb21tZW50c0B3 +My5vcmc8L2E+Jmd0Oy4gVGhlIGFyY2hpdmUgb2YgcHVibGljCmNvbW1lbnRzIGlz +IGF2YWlsYWJsZSBhdCA8YSBocmVmPSJodHRwOi8vd3d3LnczLm9yZy9BcmNoaXZl +cy9QdWJsaWMvd3d3LXhtbC1zdHlsZXNoZWV0LWNvbW1lbnRzIj5odHRwOi8vdzMu +b3JnL0FyY2hpdmVzL1B1YmxpYy93d3cteG1sLXN0eWxlc2hlZXQtY29tbWVudHM8 +L2E+LjwvcD4KCjxwPkEgbGlzdCBvZiBjdXJyZW50IFczQyBSZWNvbW1lbmRhdGlv +bnMgYW5kIG90aGVyIHRlY2huaWNhbCBkb2N1bWVudHMKY2FuIGJlIGZvdW5kIGF0 +IDxhIGhyZWY9Imh0dHA6Ly93d3cudzMub3JnL1RSIj5odHRwOi8vd3d3LnczLm9y +Zy9UUjwvYT4uPC9wPgoKPHA+VGhlIFdvcmtpbmcgR3JvdXAgZXhwZWN0cyBhZGRp +dGlvbmFsIG1lY2hhbmlzbXMgZm9yIGxpbmtpbmcgc3R5bGUKc2hlZXRzIHRvIFhN +TCBkb2N1bWVudCB0byBiZSBkZWZpbmVkIGluIGEgZnV0dXJlIHNwZWNpZmljYXRp +b24uPC9wPgoKPHA+VGhlIHVzZSBvZiBYTUwgcHJvY2Vzc2luZyBpbnN0cnVjdGlv +bnMgaW4gdGhpcyBzcGVjaWZpY2F0aW9uIHNob3VsZApub3QgYmUgdGFrZW4gYXMg +YSBwcmVjZWRlbnQuICBUaGUgVzNDIGRvZXMgbm90IGFudGljaXBhdGUgcmVjb21t +ZW5kaW5nCnRoZSB1c2Ugb2YgcHJvY2Vzc2luZyBpbnN0cnVjdGlvbnMgaW4gYW55 +IGZ1dHVyZSBzcGVjaWZpY2F0aW9uLiAgVGhlCjxhIGhyZWY9IiNyYXRpb25hbGUi +PlJhdGlvbmFsZTwvYT4gZXhwbGFpbnMgd2h5IHRoZXkgd2VyZSB1c2VkIGluCnRo +aXMgc3BlY2lmaWNhdGlvbi48L3A+Cgo8cD5UaGlzIGRvY3VtZW50IHdhcyBwcm9k +dWNlZCBhcyBwYXJ0IG9mIHRoZSA8YSBocmVmPSJodHRwOi8vd3d3LnczLm9yZy9Y +TUwvQWN0aXZpdHkiPlczQyBYTUwgQWN0aXZpdHk8L2E+LjwvcD4KCgo8aDI+Cjxh +IG5hbWU9ImNvbnRlbnRzIj5UYWJsZSBvZiBjb250ZW50czwvYT4KPC9oMj4xIDxh +IGhyZWY9IiNUaGUgeG1sLXN0eWxlc2hlZXQgcHJvY2Vzc2luZyBpbnN0cnVjdGlv +biI+VGhlIHhtbC1zdHlsZXNoZWV0IHByb2Nlc3NpbmcgaW5zdHJ1Y3Rpb248L2E+ +Cjxicj4KPGgzPkFwcGVuZGljZXM8L2gzPkEgPGEgaHJlZj0iI1JlZmVyZW5jZXMi +PlJlZmVyZW5jZXM8L2E+Cjxicj5CIDxhIGhyZWY9IiNyYXRpb25hbGUiPlJhdGlv +bmFsZTwvYT4KPGJyPgo8aHI+Cgo8aDI+CjxhIG5hbWU9IlRoZSB4bWwtc3R5bGVz +aGVldCBwcm9jZXNzaW5nIGluc3RydWN0aW9uIj48L2E+MSBUaGUgPGNvZGU+eG1s +LXN0eWxlc2hlZXQ8L2NvZGU+IHByb2Nlc3NpbmcgaW5zdHJ1Y3Rpb248L2gyPgoK +PHA+U3R5bGUgU2hlZXRzIGNhbiBiZSBhc3NvY2lhdGVkIHdpdGggYW4gWE1MPGEg +aHJlZj0iI1hNTCI+W1hNTDEwXTwvYT4KZG9jdW1lbnQgYnkgdXNpbmcgYSBwcm9j +ZXNzaW5nIGluc3RydWN0aW9uIHdob3NlIHRhcmdldCBpcwo8Y29kZT54bWwtc3R5 +bGVzaGVldDwvY29kZT4uICBUaGlzIHByb2Nlc3NpbmcgaW5zdHJ1Y3Rpb24gZm9s +bG93cyB0aGUKYmVoYXZpb3VyIG9mIHRoZSBIVE1MIDQuMCA8Y29kZT4mbHQ7TElO +SwpSRUw9InN0eWxlc2hlZXQiJmd0OzwvY29kZT48YSBocmVmPSIjSFRNTCI+W0hU +TUw0MF08L2E+LjwvcD4KCjxwPlRoZSA8Y29kZT54bWwtc3R5bGVzaGVldDwvY29k +ZT4gcHJvY2Vzc2luZyBpbnN0cnVjdGlvbiBpcyBwYXJzZWQgaW4KdGhlIHNhbWUg +d2F5IGFzIGEgc3RhcnQtdGFnLCB3aXRoIHRoZSBleGNlcHRpb24gdGhhdCBlbnRp +dGllcyBvdGhlcgp0aGFuIHByZWRlZmluZWQgZW50aXRpZXMgbXVzdCBub3QgYmUg +cmVmZXJlbmNlZC48L3A+Cgo8cD5UaGUgZm9sbG93aW5nIGdyYW1tYXIgaXMgZ2l2 +ZW4gdXNpbmcgdGhlIHNhbWUgbm90YXRpb24gYXMgdGhlCmdyYW1tYXIgaW4gdGhl +IFhNTCBSZWNvbW1lbmRhdGlvbjxhIGhyZWY9IiNYTUwiPltYTUwxMF08L2E+LiAg +U3ltYm9scyBpbiB0aGUKZ3JhbW1hciB0aGF0IGFyZSBub3QgZGVmaW5lZCBoZXJl +IGFyZSBkZWZpbmVkIGluIHRoZSBYTUwKUmVjb21tZW5kYXRpb24uPC9wPgoKPGg1 +PnhtbC1zdHlsZXNoZWV0IHByb2Nlc3NpbmcgaW5zdHJ1Y3Rpb248L2g1Pgo8dGFi +bGUgY2xhc3M9InNjcmFwIj4KPHRib2R5Pgo8dHIgdmFsaWduPSJiYXNlbGluZSI+ +Cjx0ZD4KPGEgbmFtZT0iTlQtU3R5bGVTaGVldFBJIj48L2E+WzFdJm5ic3A7Jm5i +c3A7Jm5ic3A7PC90ZD4KPHRkPlN0eWxlU2hlZXRQSTwvdGQ+Cjx0ZD4mbmJzcDsm +bmJzcDsmbmJzcDs6Oj0mbmJzcDsmbmJzcDsmbmJzcDs8L3RkPgo8dGQ+JyZsdDs/ +eG1sLXN0eWxlc2hlZXQnICg8YSBocmVmPSJodHRwOi8vd3d3LnczLm9yZy9UUi9S +RUMteG1sI05ULVMiPlM8L2E+IDxhIGhyZWY9IiNOVC1Qc2V1ZG9BdHQiPlBzZXVk +b0F0dDwvYT4pKiA8YSBocmVmPSJodHRwOi8vd3d3LnczLm9yZy9UUi9SRUMteG1s +I05ULVMiPlM8L2E+PyAnPyZndDsnPC90ZD4KPHRkPgo8L3RkPgo8L3RyPgo8dHIg +dmFsaWduPSJiYXNlbGluZSI+Cjx0ZD4KPGEgbmFtZT0iTlQtUHNldWRvQXR0Ij48 +L2E+WzJdJm5ic3A7Jm5ic3A7Jm5ic3A7PC90ZD4KPHRkPlBzZXVkb0F0dDwvdGQ+ +Cjx0ZD4mbmJzcDsmbmJzcDsmbmJzcDs6Oj0mbmJzcDsmbmJzcDsmbmJzcDs8L3Rk +Pgo8dGQ+CjxhIGhyZWY9Imh0dHA6Ly93d3cudzMub3JnL1RSL1JFQy14bWwjTlQt +TmFtZSI+TmFtZTwvYT4gPGEgaHJlZj0iaHR0cDovL3d3dy53My5vcmcvVFIvUkVD +LXhtbCNOVC1TIj5TPC9hPj8gJz0nIDxhIGhyZWY9Imh0dHA6Ly93d3cudzMub3Jn +L1RSL1JFQy14bWwjTlQtUyI+UzwvYT4/IDxhIGhyZWY9IiNOVC1Qc2V1ZG9BdHRW +YWx1ZSI+UHNldWRvQXR0VmFsdWU8L2E+CjwvdGQ+Cjx0ZD4KPC90ZD4KPC90cj4K +PHRyIHZhbGlnbj0iYmFzZWxpbmUiPgo8dGQ+CjxhIG5hbWU9Ik5ULVBzZXVkb0F0 +dFZhbHVlIj48L2E+WzNdJm5ic3A7Jm5ic3A7Jm5ic3A7PC90ZD4KPHRkPlBzZXVk +b0F0dFZhbHVlPC90ZD4KPHRkPiZuYnNwOyZuYnNwOyZuYnNwOzo6PSZuYnNwOyZu +YnNwOyZuYnNwOzwvdGQ+Cjx0ZD4oJyInIChbXiImbHQ7JmFtcDtdIHwgPGEgaHJl +Zj0iaHR0cDovL3d3dy53My5vcmcvVFIvUkVDLXhtbCNOVC1DaGFyUmVmIj5DaGFy +UmVmPC9hPiB8IDxhIGhyZWY9IiNOVC1QcmVkZWZFbnRpdHlSZWYiPlByZWRlZkVu +dGl0eVJlZjwvYT4pKiAnIic8L3RkPgo8dGQ+CjwvdGQ+CjwvdHI+Cjx0ciB2YWxp +Z249ImJhc2VsaW5lIj4KPHRkPgo8L3RkPgo8dGQ+CjwvdGQ+Cjx0ZD4KPC90ZD4K +PHRkPnwgIiciIChbXicmbHQ7JmFtcDtdIHwgPGEgaHJlZj0iaHR0cDovL3d3dy53 +My5vcmcvVFIvUkVDLXhtbCNOVC1DaGFyUmVmIj5DaGFyUmVmPC9hPiB8IDxhIGhy +ZWY9IiNOVC1QcmVkZWZFbnRpdHlSZWYiPlByZWRlZkVudGl0eVJlZjwvYT4pKiAi +JyIpPC90ZD4KPHRkPgo8L3RkPgo8L3RyPgo8dHIgdmFsaWduPSJiYXNlbGluZSI+ +Cjx0ZD4KPC90ZD4KPHRkPgo8L3RkPgo8dGQ+CjwvdGQ+Cjx0ZD4tICg8YSBocmVm +PSJodHRwOi8vd3d3LnczLm9yZy9UUi9SRUMteG1sI05ULUNoYXIiPkNoYXI8L2E+ +KiAnPyZndDsnIDxhIGhyZWY9Imh0dHA6Ly93d3cudzMub3JnL1RSL1JFQy14bWwj +TlQtQ2hhciI+Q2hhcjwvYT4qKTwvdGQ+Cjx0ZD4KPC90ZD4KPC90cj4KPHRyIHZh +bGlnbj0iYmFzZWxpbmUiPgo8dGQ+CjxhIG5hbWU9Ik5ULVByZWRlZkVudGl0eVJl +ZiI+PC9hPls0XSZuYnNwOyZuYnNwOyZuYnNwOzwvdGQ+Cjx0ZD5QcmVkZWZFbnRp +dHlSZWY8L3RkPgo8dGQ+Jm5ic3A7Jm5ic3A7Jm5ic3A7Ojo9Jm5ic3A7Jm5ic3A7 +Jm5ic3A7PC90ZD4KPHRkPicmYW1wO2FtcDsnIHwgJyZhbXA7bHQ7JyB8ICcmYW1w +O2d0OycgfCAnJmFtcDtxdW90OycgfCAnJmFtcDthcG9zOyc8L3RkPgo8dGQ+Cjwv +dGQ+CjwvdHI+CjwvdGJvZHk+CjwvdGFibGU+Cgo8cD5JbiA8YSBocmVmPSIjTlQt +UHNldWRvQXR0VmFsdWUiPlBzZXVkb0F0dFZhbHVlPC9hPiwgYSA8YSBocmVmPSJo +dHRwOi8vd3d3LnczLm9yZy9UUi9SRUMteG1sI05ULUNoYXJSZWYiPkNoYXJSZWY8 +L2E+IG9yIGEgPGEgaHJlZj0iI05ULVByZWRlZkVudGl0eVJlZiI+UHJlZGVmRW50 +aXR5UmVmPC9hPiBpcyBpbnRlcnByZXRlZCBpbiB0aGUKc2FtZSBtYW5uZXIgYXMg +aW4gYSBub3JtYWwgWE1MIGF0dHJpYnV0ZSB2YWx1ZS4gIFRoZSBhY3R1YWwgdmFs +dWUgb2YKdGhlIHBzZXVkby1hdHRyaWJ1dGUgaXMgdGhlIHZhbHVlIGFmdGVyIGVh +Y2ggcmVmZXJlbmNlIGlzIHJlcGxhY2VkIGJ5CnRoZSBjaGFyYWN0ZXIgaXQgcmVm +ZXJlbmNlcy4gIFRoaXMgcmVwbGFjZW1lbnQgaXMgbm90IHBlcmZvcm1lZAphdXRv +bWF0aWNhbGx5IGJ5IGFuIFhNTCBwcm9jZXNzb3IuPC9wPgoKPHA+VGhlIDxjb2Rl +PnhtbC1zdHlsZXNoZWV0PC9jb2RlPiBwcm9jZXNzaW5nIGluc3RydWN0aW9uIGlz +IGFsbG93ZWQKb25seSBpbiB0aGUgcHJvbG9nIG9mIGFuIFhNTCBkb2N1bWVudC4g +VGhlIHN5bnRheCBvZiBYTUwgY29uc3RyYWlucwp3aGVyZSBwcm9jZXNzaW5nIGlu +c3RydWN0aW9ucyBhcmUgYWxsb3dlZCBpbiB0aGUgcHJvbG9nOyB0aGUKPGNvZGU+ +eG1sLXN0eWxlc2hlZXQ8L2NvZGU+IHByb2Nlc3NpbmcgaW5zdHJ1Y3Rpb24gaXMg +YWxsb3dlZCBhbnl3aGVyZQppbiB0aGUgcHJvbG9nIHRoYXQgbWVldHMgdGhlc2Ug +Y29uc3RyYWludHMuPC9wPgoKPGJsb2NrcXVvdGU+CjxiPk5PVEU6IDwvYj5JZiB0 +aGUgPGNvZGU+eG1sLXN0eWxlc2hlZXQ8L2NvZGU+IHByb2Nlc3NpbmcgaW5zdHJ1 +Y3Rpb24Kb2NjdXJzIGluIHRoZSBleHRlcm5hbCBEVEQgc3Vic2V0IG9yIGluIGEg +cGFyYW1ldGVyIGVudGl0eSwgaXQgaXMKcG9zc2libGUgdGhhdCBpdCBtYXkgbm90 +IGJlIHByb2Nlc3NlZCBieSBhIG5vbi12YWxpZGF0aW5nIFhNTApwcm9jZXNzb3Ig +KHNlZSA8YSBocmVmPSIjWE1MIj5bWE1MMTBdPC9hPikuPC9ibG9ja3F1b3RlPgoK +PHA+VGhlIGZvbGxvd2luZyBwc2V1ZG8gYXR0cmlidXRlcyBhcmUgZGVmaW5lZDwv +cD4KCjxwcmU+aHJlZiBDREFUQSAjUkVRVUlSRUQKdHlwZSBDREFUQSAjUkVRVUlS +RUQKdGl0bGUgQ0RBVEEgI0lNUExJRUQKbWVkaWEgQ0RBVEEgI0lNUExJRUQKY2hh +cnNldCBDREFUQSAjSU1QTElFRAphbHRlcm5hdGUgKHllc3xubykgIm5vIjwvcHJl +PgoKPHA+VGhlIHNlbWFudGljcyBvZiB0aGUgcHNldWRvLWF0dHJpYnV0ZXMgYXJl +IGV4YWN0bHkgYXMgd2l0aAo8Y29kZT4mbHQ7TElOSyBSRUw9InN0eWxlc2hlZXQi +Jmd0OzwvY29kZT4gaW4gSFRNTCA0LjAsIHdpdGggdGhlCmV4Y2VwdGlvbiBvZiB0 +aGUgPGNvZGU+YWx0ZXJuYXRlPC9jb2RlPiBwc2V1ZG8tYXR0cmlidXRlLiAgSWYK +PGNvZGU+YWx0ZXJuYXRlPSJ5ZXMiPC9jb2RlPiBpcyBzcGVjaWZpZWQsIHRoZW4g +dGhlIHByb2Nlc3NpbmcKaW5zdHJ1Y3Rpb24gaGFzIHRoZSBzZW1hbnRpY3Mgb2Yg +PGNvZGU+Jmx0O0xJTksgUkVMPSJhbHRlcm5hdGUKc3R5bGVzaGVldCImZ3Q7PC9j +b2RlPiBpbnN0ZWFkIG9mIDxjb2RlPiZsdDtMSU5LClJFTD0ic3R5bGVzaGVldCIm +Z3Q7PC9jb2RlPi48L3A+Cgo8YmxvY2txdW90ZT4KPGI+Tk9URTogPC9iPlNpbmNl +IHRoZSB2YWx1ZSBvZiB0aGUgPGNvZGU+aHJlZjwvY29kZT4gYXR0cmlidXRlIGlz +IGEgVVJJCnJlZmVyZW5jZSwgaXQgbWF5IGJlIGEgcmVsYXRpdmUgVVJJIGFuZCBp +dCBtYXkgY29udGFpbiBhIGZyYWdtZW50CmlkZW50aWZpZXIuIEluIHBhcnRpY3Vs +YXIgdGhlIFVSSSByZWZlcmVuY2UgbWF5IGNvbnRhaW4gb25seSBhCmZyYWdtZW50 +IGlkZW50aWZpZXIuICBTdWNoIGEgVVJJIHJlZmVyZW5jZSBpcyBhIHJlZmVyZW5j +ZSB0byBhIHBhcnQgb2YKdGhlIGRvY3VtZW50IGNvbnRhaW5pbmcgdGhlIDxjb2Rl +PnhtbC1zdHlsZXNoZWV0PC9jb2RlPiBwcm9jZXNzaW5nCmluc3RydWN0aW9uIChz +ZWUgPGEgaHJlZj0iI1JGQzIzOTYiPltSRkMyMzk2XTwvYT4pLiBUaGUgY29uc2Vx +dWVuY2UgaXMgdGhhdCB0aGUKPGNvZGU+eG1sLXN0eWxlc2hlZXQ8L2NvZGU+IHBy +b2Nlc3NpbmcgaW5zdHJ1Y3Rpb24gYWxsb3dzIHN0eWxlIHNoZWV0cwp0byBiZSBl +bWJlZGRlZCBpbiB0aGUgc2FtZSBkb2N1bWVudCBhcyB0aGUgPGNvZGU+eG1sLXN0 +eWxlc2hlZXQ8L2NvZGU+CnByb2Nlc3NpbmcgaW5zdHJ1Y3Rpb24uPC9ibG9ja3F1 +b3RlPgoKPHA+SW4gc29tZSBjYXNlcywgc3R5bGUgc2hlZXRzIG1heSBiZSBsaW5r +ZWQgd2l0aCBhbiBYTUwgZG9jdW1lbnQgYnkKbWVhbnMgZXh0ZXJuYWwgdG8gdGhl +IGRvY3VtZW50LiBGb3IgZXhhbXBsZSwgZWFybGllciB2ZXJzaW9ucyBvZiBIVFRQ +CjxhIGhyZWY9IiNSRkMyMDY4Ij5bUkZDMjA2OF08L2E+IChzZWN0aW9uIDE5LjYu +Mi40KSBhbGxvd2VkIHN0eWxlIHNoZWV0cyB0byBiZQphc3NvY2lhdGVkIHdpdGgg +WE1MIGRvY3VtZW50cyBieSBtZWFucyBvZiB0aGUgPGNvZGU+TGluazwvY29kZT4K +aGVhZGVyLiAgQW55IGxpbmtzIHRvIHN0eWxlIHNoZWV0cyB0aGF0IGFyZSBzcGVj +aWZpZWQgZXh0ZXJuYWxseSB0byB0aGUKZG9jdW1lbnQgYXJlIGNvbnNpZGVyZWQg +dG8gb2NjdXIgYmVmb3JlIHRoZSBsaW5rcyBzcGVjaWZpZWQgYnkgdGhlCjxjb2Rl +PnhtbC1zdHlsZXNoZWV0PC9jb2RlPiBwcm9jZXNzaW5nIGluc3RydWN0aW9ucy4g +IFRoaXMgaXMgdGhlIHNhbWUKYXMgaW4gSFRNTCA0LjAgKHNlZSA8YSBocmVmPSJo +dHRwOi8vd3d3LnczLm9yZy9UUi9SRUMtaHRtbDQwL3ByZXNlbnQvc3R5bGVzLmh0 +bWwjaC0xNC42Ij5zZWN0aW9uCjE0LjY8L2E+KS48L3A+Cgo8cD5IZXJlIGFyZSBz +b21lIGV4YW1wbGVzIGZyb20gSFRNTCA0LjAgd2l0aCB0aGUgY29ycmVzcG9uZGlu +Zwpwcm9jZXNzaW5nIGluc3RydWN0aW9uOjwvcD4KCjxwcmU+Jmx0O0xJTksgaHJl +Zj0ibXlzdHlsZS5jc3MiIHJlbD0ic3R5bGUgc2hlZXQiIHR5cGU9InRleHQvY3Nz +IiZndDsKJmx0Oz94bWwtc3R5bGVzaGVldCBocmVmPSJteXN0eWxlLmNzcyIgdHlw +ZT0idGV4dC9jc3MiPyZndDsKCiZsdDtMSU5LIGhyZWY9Im15c3R5bGUuY3NzIiB0 +aXRsZT0iQ29tcGFjdCIgcmVsPSJzdHlsZXNoZWV0Igp0eXBlPSJ0ZXh0L2NzcyIm +Z3Q7CiZsdDs/eG1sLXN0eWxlc2hlZXQgaHJlZj0ibXlzdHlsZS5jc3MiIHRpdGxl +PSJDb21wYWN0IiB0eXBlPSJ0ZXh0L2NzcyI/Jmd0OwoKJmx0O0xJTksgaHJlZj0i +bXlzdHlsZS5jc3MiIHRpdGxlPSJNZWRpdW0iIHJlbD0iYWx0ZXJuYXRlIHN0eWxl +c2hlZXQiCnR5cGU9InRleHQvY3NzIiZndDsKJmx0Oz94bWwtc3R5bGVzaGVldCBh +bHRlcm5hdGU9InllcyIgaHJlZj0ibXlzdHlsZS5jc3MiIHRpdGxlPSJNZWRpdW0i +CnR5cGU9InRleHQvY3NzIj8mZ3Q7PC9wcmU+Cgo8cD5NdWx0aXBsZSA8Y29kZT54 +bWwtc3R5bGVzaGVldDwvY29kZT4gcHJvY2Vzc2luZyBpbnN0cnVjdGlvbnMgYXJl +CmFsc28gYWxsb3dlZCB3aXRoIGV4YWN0bHkgdGhlIHNhbWUgc2VtYW50aWNzIGFz +IHdpdGggPGNvZGU+TElOSwpSRUw9InN0eWxlc2hlZXQiPC9jb2RlPi4gRm9yIGV4 +YW1wbGUsPC9wPgoKPHByZT4mbHQ7TElOSyByZWw9ImFsdGVybmF0ZSBzdHlsZXNo +ZWV0IiB0aXRsZT0iY29tcGFjdCIgaHJlZj0ic21hbGwtYmFzZS5jc3MiCnR5cGU9 +InRleHQvY3NzIiZndDsKJmx0O0xJTksgcmVsPSJhbHRlcm5hdGUgc3R5bGVzaGVl +dCIgdGl0bGU9ImNvbXBhY3QiIGhyZWY9InNtYWxsLWV4dHJhcy5jc3MiCnR5cGU9 +InRleHQvY3NzIiZndDsKJmx0O0xJTksgcmVsPSJhbHRlcm5hdGUgc3R5bGVzaGVl +dCIgdGl0bGU9ImJpZyBwcmludCIgaHJlZj0iYmlncHJpbnQuY3NzIgp0eXBlPSJ0 +ZXh0L2NzcyImZ3Q7CiZsdDtMSU5LIHJlbD0ic3R5bGVzaGVldCIgaHJlZj0iY29t +bW9uLmNzcyIgdHlwZT0idGV4dC9jc3MiJmd0OzwvcHJlPgoKPHA+d291bGQgYmUg +ZXF1aXZhbGVudCB0bzo8L3A+Cgo8cHJlPiZsdDs/eG1sLXN0eWxlc2hlZXQgYWx0 +ZXJuYXRlPSJ5ZXMiIHRpdGxlPSJjb21wYWN0IiBocmVmPSJzbWFsbC1iYXNlLmNz +cyIKdHlwZT0idGV4dC9jc3MiPyZndDsKJmx0Oz94bWwtc3R5bGVzaGVldCBhbHRl +cm5hdGU9InllcyIgdGl0bGU9ImNvbXBhY3QiIGhyZWY9InNtYWxsLWV4dHJhcy5j +c3MiCnR5cGU9InRleHQvY3NzIj8mZ3Q7CiZsdDs/eG1sLXN0eWxlc2hlZXQgYWx0 +ZXJuYXRlPSJ5ZXMiIHRpdGxlPSJiaWcgcHJpbnQiIGhyZWY9ImJpZ3ByaW50LmNz +cyIKdHlwZT0idGV4dC9jc3MiPyZndDsKJmx0Oz94bWwtc3R5bGVzaGVldCBocmVm +PSJjb21tb24uY3NzIiB0eXBlPSJ0ZXh0L2NzcyI/Jmd0OzwvcHJlPgoKCgo8aHIg +dGl0bGU9IlNlcGFyYXRvciBmcm9tIGZvb3RlciI+Cgo8aDI+CjxhIG5hbWU9IlJl +ZmVyZW5jZXMiPjwvYT5BIFJlZmVyZW5jZXM8L2gyPgoKPGRsPgoKPGR0Pgo8YSBu +YW1lPSJIVE1MIj5IVE1MNDA8L2E+CjwvZHQ+CjxkZD5Xb3JsZCBXaWRlIFdlYgpD +b25zb3J0aXVtLiA8aT5IVE1MIDQuMCBTcGVjaWZpY2F0aW9uLjwvaT4gVzNDIFJl +Y29tbWVuZGF0aW9uLiBTZWUKPGEgaHJlZj0iaHR0cDovL3d3dy53My5vcmcvVFIv +UkVDLWh0bWw0MCI+aHR0cDovL3d3dy53My5vcmcvVFIvUkVDLWh0bWw0MDwvYT4K +PC9kZD4KCjxkdD4KPGEgbmFtZT0iUkZDMjA2OCI+UkZDMjA2ODwvYT4KPC9kdD4K +PGRkPlIuIEZpZWxkaW5nLCBKLiBHZXR0eXMsIEouIE1vZ3VsLApILiBGcnlzdHlr +IE5pZWxzZW4sIGFuZCBULiBCZXJuZXJzLUxlZS4gIDxpPkh5cGVydGV4dCBUcmFu +c2ZlcgpQcm90b2NvbCAtLSBIVFRQLzEuMS48L2k+LiBJRVRGIFJGQyAyMDY4LiBT +ZWUgPGEgaHJlZj0iaHR0cDovL3d3dy5pZXRmLm9yZy9yZmMvcmZjMjA2OC50eHQi +Pmh0dHA6Ly93d3cuaWV0Zi5vcmcvcmZjL3JmYzIwNjgudHh0PC9hPi48L2RkPgoK +PGR0Pgo8YSBuYW1lPSJSRkMyMzk2Ij5SRkMyMzk2PC9hPgo8L2R0Pgo8ZGQ+VC4g +QmVybmVycy1MZWUsIFIuIEZpZWxkaW5nLCBhbmQKTC4gTWFzaW50ZXIuICA8aT5V +bmlmb3JtIFJlc291cmNlIElkZW50aWZpZXJzIChVUkkpOiBHZW5lcmljClN5bnRh +eDwvaT4uIElFVEYgUkZDIDIzOTYuIFNlZSA8YSBocmVmPSJodHRwOi8vd3d3Lmll +dGYub3JnL3JmYy9yZmMyMzk2LnR4dCI+aHR0cDovL3d3dy5pZXRmLm9yZy9yZmMv +cmZjMjM5Ni50eHQ8L2E+LjwvZGQ+Cgo8ZHQ+CjxhIG5hbWU9IlhNTCI+WE1MMTA8 +L2E+CjwvZHQ+CjxkZD5Xb3JsZCBXaWRlIFdlYiBDb25zb3J0aXVtLiA8aT5FeHRl +bnNpYmxlCk1hcmt1cCBMYW5ndWFnZSAoWE1MKSAxLjAuPC9pPiBXM0MgUmVjb21t +ZW5kYXRpb24uIFNlZSA8YSBocmVmPSJodHRwOi8vd3d3LnczLm9yZy9UUi8xOTk4 +L1JFQy14bWwtMTk5ODAyMTAiPmh0dHA6Ly93d3cudzMub3JnL1RSLzE5OTgvUkVD +LXhtbC0xOTk4MDIxMDwvYT4KPC9kZD4KCjwvZGw+CgoKCgo8aDI+CjxhIG5hbWU9 +InJhdGlvbmFsZSI+PC9hPkIgUmF0aW9uYWxlPC9oMj4KCjxwPlRoZXJlIHdhcyBh +biB1cmdlbnQgcmVxdWlyZW1lbnQgZm9yIGEgc3BlY2lmaWNhdGlvbiBmb3Igc3R5 +bGUgc2hlZXQKbGlua2luZyB0aGF0IGNvdWxkIGJlIGNvbXBsZXRlZCBpbiB0aW1l +IGZvciB0aGUgbmV4dCByZWxlYXNlIGZyb20KbWFqb3IgYnJvd3NlciB2ZW5kb3Jz +LiAgT25seSBieSBjaG9vc2luZyBhIHNpbXBsZSBtZWNoYW5pc20gY2xvc2VseQpi +YXNlZCBvbiBhIHByb3ZlbiBleGlzdGluZyBtZWNoYW5pc20gY291bGQgdGhlIHNw +ZWNpZmljYXRpb24gYmUKY29tcGxldGVkIGluIHRpbWUgdG8gbWVldCB0aGlzIHJl +cXVpcmVtZW50LjwvcD4KCjxwPlVzZSBvZiBhIHByb2Nlc3NpbmcgaW5zdHJ1Y3Rp +b24gYXZvaWRzIHBvbGx1dGluZyB0aGUgbWFpbiBkb2N1bWVudApzdHJ1Y3R1cmUg +d2l0aCBhcHBsaWNhdGlvbiBzcGVjaWZpYyBwcm9jZXNzaW5nIGluZm9ybWF0aW9u +LjwvcD4KCjxwPlRoZSBtZWNoYW5pc20gY2hvc2VuIGZvciB0aGlzIHZlcnNpb24g +b2YgdGhlIHNwZWNpZmljYXRpb24gaXMgbm90IGEKY29uc3RyYWludCBvbiB0aGUg +YWRkaXRpb25hbCBtZWNoYW5pc21zIHBsYW5uZWQgZm9yIGZ1dHVyZSB2ZXJzaW9u +cy4KVGhlcmUgaXMgbm8gZXhwZWN0YXRpb24gdGhhdCB0aGVzZSB3aWxsIHVzZSBw +cm9jZXNzaW5nIGluc3RydWN0aW9uczsKaW5kZWVkIHRoZXkgbWF5IG5vdCBpbmNs +dWRlIHRoZSBsaW5raW5nIGluZm9ybWF0aW9uIGluIHRoZSBzb3VyY2UKZG9jdW1l +bnQuPC9wPgoKCgoKPC9ib2R5Pgo8L2h0bWw+Cg== diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/Readme.txt b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/Readme.txt new file mode 100644 index 00000000..37e9d88f --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/Readme.txt @@ -0,0 +1,63 @@ +Sample XML Signatures[1][2] + +[1] http://www.w3.org/TR/2002/REC-xmldsig-core-20020212/ +[2] http://www.w3.org/TR/2001/REC-xml-c14n-20010315 + +1. A large and complex signature: + +This includes internal and external base 64, references of the forms +"", "#xpointer(/)", "#foo" and "#xpointer(id('foo'))" (with and +without comments), manifests, signature properties, simple xpath +with here(), xslt, retrieval method and odd interreferential +dependencies. + + signature.xml - A signature + signature.tmpl - The template from which the signature was created + signature-c14n-*.txt - All intermediate c14n output + +2. Some basic signatures: + +The key for the HMAC-SHA1 signatures is "secret".getBytes("ASCII") +which is, in hex, (73 65 63 72 65 74). No key info is provided for +these signatures. + + signature-enveloped-dsa.xml + signature-enveloping-b64-dsa.xml + signature-enveloping-dsa.xml + signature-enveloping-hmac-sha1-40.xml + signature-enveloping-hmac-sha1.xml + signature-enveloping-rsa.xml + signature-external-b64-dsa.xml + signature-external-dsa.xml - The signatures + signature-*-c14n-*.txt - The intermediate c14n output + +3. Varying key information: + +To resolve the key associated with the KeyName in `signature-keyname.xml' +you must perform a cunning transformation from the name `Xxx' to the +certificate that resides in the directory `certs/' that has a subject name +containing the common name `Xxx', which happens to be in the file +`certs/xxx.crt'. + +To resolve the key associated with the X509Data in `signature-x509-is.xml', +`signature-x509-ski.xml' and `signature-x509-sn.xml' you need to resolve +the identified certificate from those in the `certs' directory. + +In `signature-x509-crt-crl.xml' an X.509 CRL is present which has revoked +the X.509 certificate used for signing. So verification should be +qualified. + + signature-keyname.xml + signature-retrievalmethod-rawx509crt.xml + signature-x509-crt-crl.xml + signature-x509-crt.xml + signature-x509-is.xml + signature-x509-ski.xml + signature-x509-sn.xml - The signatures + certs/*.crt - The certificates + +Merlin Hughes +Baltimore Technologies, Ltd. +http://www.baltimore.com/ + +Thursday, April 4, 2002 diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/badb.der b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/badb.der new file mode 100644 index 0000000000000000000000000000000000000000..2d0dec6893841ae0e84f64a73d4cf916d39636f6 GIT binary patch literal 850 zcmXqLV)ip=V&+@G%*4pV#K!REr((L70Vf-~R-4B;3l?UBGDB`4mpPP$O_<5k)sWwS z7sTNZW^*Y`%E`<#R54Hj32+HZJ0<3nWaj1nCYKha8p;|-gM^rcMI7_;OEOZ66hev;^NKT5^GXz)9S!8fc@2yVObm>S zj7=>~&7;J54Ix}3Q%e)GD1$Uwxr^6;8{#e&r^J*bgC@ppK&%Z55Jn(Jj;XPcp`~ks zmwL~|1#;)#wZD(h;{1?)Aos4_yORu_(bo%?94smLDt^A*HCHj>AUqf+Ln zmgJoKD_1S_^TGU&JGQNOyhvB)c*U!?XWsL(yUA4=U0`l(VE{Tq*y(kio@=oV>$0PL zp8{@uy(Rl1Uax{}tyA5r)6>Kstz9%nZ22w+x#qnerQ6@h{Yjso@X7r2bN~OA`fIl* zo3E^Xl(4QcG0>tn^HX8)C~*!kRp3)D0pYb}2N>$FFS zA+NCb$CqxOKS{lHd%jNO@9q1Gtqd#-_<&I<%g@O8pM`~)iM_!<5X9$W;bP%v*us)^ z>Z0ii17VPmG7FCZR|7|vOqfBGkx(sC24L1_FwkKV37Wjlo_}eOTC!DbcH8TnB|l#`ifsA8Z565tY+c1p}C$;{0!N>vC+P0q;6&&f~EOf6RMDM`^Y6g1!m zsp1yqh|mvCO)f1;HIy}w1_?0>i#X=xmt>?CDTEXy<`rkA=9MToI~vG|^BNc#m>3uv z0D+-tlsK;;h-+kIWNK+*7G;o5D|hi3@Ic(f3iMEZkwFvVHXzo9g$N^%BgfR($k5WY z!Arg8;sUwz@7mu-XmNf>KahLZ?%hcS&*Q{gLYn#o)b8l(fNAHeV z^>+G4FP@qDM?v$Kk>;;ms=mbwgYC0~ERQA}W)fwX;r*+kDVx2i_u}V^48kApT)a^f z$<)}O+V)KH56}6n>;C2VIXSLY^NhLk>9KwFqqA=qQUz-S-25igPKb#=a=YTQ%g2f7 z&8_m=zZf$-HDuT*{LZPN%8#>JJM-*W@dP!a9Z3#;d8}WjnJ!_fJjpp_HuvGgn^7tA zR7-Ns{gta0`uSk~#~s^NJYJ-$bG+i!+cWR^+1=zSjV>@ZwlDylk@vhO`cA4hH@msA zd4EuA;$yRuUg}zxL@f@+JdErW@vWG8;mFiWT>sBn-zqSxQOb|}5$vPqY{Bt)uV(wF zGiF5$-Mjj}Y1gN5PisD2f5m%FT{K7Y%8sdyyB@9gPm{g*SgIt@Hs$dQJ_h6BgBHq3 zA7hqS7%{HDtx#Ob5dQ2>U;JV#0}BH_U|h=bGcx{XVPR%sZ!i!9@%dP|SU8&BvE*() zBp_@c3=&di;W6N9;OLSGGl()0szu5H%=!!lI!q$Y$C~{XEMa=JDn7qa@nP4EBb6WK cGl`@uo}c)j>ekYf3#Vcq#($Q%+H`*{01x*!Q~&?~ literal 0 HcmV?d00001 diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/balor.pem b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/balor.pem new file mode 100644 index 00000000..edc1748a --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/balor.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDTzCCAw+gAwIBAgIGAOz5IaxHMAkGByqGSM44BAMwdjELMAkGA1UEBhMCSUUx +DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll +cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEdMBsGA1UEAxMUQW5vdGhlciBUcmFu +c2llbnQgQ0EwHhcNMDIwNDAzMDAwMDE1WhcNMTIwNDAyMjI1OTQ2WjBnMQswCQYD +VQQGEwJJRTEPMA0GA1UECBMGRHVibGluMSQwIgYDVQQKExtCYWx0aW1vcmUgVGVj +aG5vbG9naWVzIEx0ZC4xETAPBgNVBAsTCFgvU2VjdXJlMQ4wDAYDVQQDEwVCYWxv +cjCCAbYwggErBgcqhkjOOAQBMIIBHgKBgQCEirBKJ4zRoB7P7ofvWCoJ8GfAbd0+ +7skASVvXcaTBdHD1F8+HRW0hWOEMlvIoAi7MKTmvnhxxGLFrxNDa9ZXCh1D16u7u +NSScBzatUQBXmYlOsGvtRS979f09awIM3qVe8UuImn8+L8XRzJX8ICn6Min6uiVN +c6FTP2oSOcVgwwIVAJhL+niCaweCjdHz0QAT8dzR2HJZAoGAJYbmGfwMz7Wu/mxO +QkGrJklc3PLjP3vizewAZRF8EEZOkH2QXF/E23jzRPGRZ4OFH7f0MwDlMQCxE+5C +gHpOCXsrac3NF2AmMrhiQE5uBfWWNaQCeckJlJsLw2HZWmSeJXRszv0eexL54J/x +uLao46ItLMd46u3M7w8HRh55MtADgYQAAoGAbueMW9xlSwsHNyM3j1KFYeM2yUon +KtIVOMFc4VmNFE14ldDEldIK/8072nA2fCJvWfhTTC5DOAjzvSmH8sw2cgCLuo72 +K39mC5aDx3/US5x+WwiDqYiVQbrir09mHdnjGnRRPWTjmA4AM3PBOCNi8VykODIB +r9sgc3UAV+b8jl+jOjA4MA4GA1UdDwEB/wQEAwIHgDARBgNVHQ4ECgQIg+4EbbfC +EBMwEwYDVR0jBAwwCoAIihxWMFoyEn0wCQYHKoZIzjgEAwMvADAsAhRDxoNOoKQC +6qpfb4Eh4YrYxHnwnwIUZKOfYeB62qVk0Mpd4V/zHNWC360= +-----END CERTIFICATE----- diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/bres.pem b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/bres.pem new file mode 100644 index 00000000..18a0966c --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/bres.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDTjCCAw6gAwIBAgIGAOz5Id5/MAkGByqGSM44BAMwdjELMAkGA1UEBhMCSUUx +DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll +cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEdMBsGA1UEAxMUQW5vdGhlciBUcmFu +c2llbnQgQ0EwHhcNMDIwNDAzMDAwMDI4WhcNMTIwNDAyMjI1OTQ2WjBmMQswCQYD +VQQGEwJJRTEPMA0GA1UECBMGRHVibGluMSQwIgYDVQQKExtCYWx0aW1vcmUgVGVj +aG5vbG9naWVzIEx0ZC4xETAPBgNVBAsTCFgvU2VjdXJlMQ0wCwYDVQQDEwRCcmVz +MIIBtjCCASsGByqGSM44BAEwggEeAoGBAISKsEonjNGgHs/uh+9YKgnwZ8Bt3T7u +yQBJW9dxpMF0cPUXz4dFbSFY4QyW8igCLswpOa+eHHEYsWvE0Nr1lcKHUPXq7u41 +JJwHNq1RAFeZiU6wa+1FL3v1/T1rAgzepV7xS4iafz4vxdHMlfwgKfoyKfq6JU1z +oVM/ahI5xWDDAhUAmEv6eIJrB4KN0fPRABPx3NHYclkCgYAlhuYZ/AzPta7+bE5C +QasmSVzc8uM/e+LN7ABlEXwQRk6QfZBcX8TbePNE8ZFng4Uft/QzAOUxALET7kKA +ek4Jeytpzc0XYCYyuGJATm4F9ZY1pAJ5yQmUmwvDYdlaZJ4ldGzO/R57Evngn/G4 +tqjjoi0sx3jq7czvDwdGHnky0AOBhAACgYBgvDFxw1U6Ou2G6P/+347Jfk2wPB1/ +atr4p3JUVLuT0ExZG6np+rKiXmcBbYKbAhMY37zVkroR9bwo+NgaJGubQ4ex5Y1X +N2Q5gIHNhNfKr8G4LPVqWGxf/lFPDYxX3ezqBJPpJCJTREX7s6Hp/VTV2SpQlySv ++GRcFKJFPlhD9aM6MDgwDgYDVR0PAQH/BAQDAgeAMBEGA1UdDgQKBAiC+5gx0MHL +hTATBgNVHSMEDDAKgAiKHFYwWjISfTAJBgcqhkjOOAQDAy8AMCwCFDTcM5i61uqq +/aveERhOJ6NG/LubAhREVDtAeNbTEywXr4O7KvEEvFLUjg== +-----END CERTIFICATE----- diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/ca.der b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/ca.der new file mode 100644 index 0000000000000000000000000000000000000000..00861d03871082cc2085eddd1c33b175f07e2a97 GIT binary patch literal 862 zcmXqLVvaIsVwPIK%*4pV#K!REr=pdO0Vf-~R-4B;3l?UBGDB`4mpPP$O_<5k)sWwS z7sTNZW^*Y`%E`<#R54Hj32+HZJ0<3nWaj1nCYKha8p;|-gM^rcMI7_;OEOZ66hev;^NKT5^GXz)9S!8fc@2yVObm>S zj7=>~%%a434Ix}3Fn7S*)x@~Xpovi%7C?+ZjvP~CBSTBq1~2uViwoq=ziWRVp~d+j z{Xp(ryLTrUJfp7{E;(3I@KyYLyKAmu#6zBGpEQ{C&S+Y$pC?l&u`&C|gKx z`s&>~Qs$c!}uWdFH&%LE_AH6$f)!XSGy?AEo9|g@{Mw-8N zsrnW#47Se_vOJn_m`RjjhWD?Erfl}6-ix0vG6;XXbMZz|BvWI9YTGl(KRoBRuKSnc z=j6Cr%`@iCr^oiykIuefNENIRaPyl`J0T|i$nA>HE*~eRH@C`f|6rTeZwVV&kSjul`&dGaf65mi!qP1@J74wH*IArC#C0eG0B!$1JTiD9` zZ*Km-v{SMB*ss5w{I=xLll&)N)>u{_l{RBIq-6hX=Nlozdqux2#R6T$r?W|~x|eA= zEpgWM#b&_N#0N}rviyvU|5;d=nV49>sZv&$g~NaiD8|IdU?2!mz{kSH!qFuYW)Nj0 zREv}unDrS9beKepY$G;?E(_wRpB?qfp=|R9cXrFYOd`U?hR(kAzc;SYoN~*%-g;;E H54#lr0O>c_ literal 0 HcmV?d00001 diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/ca.pem b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/ca.pem new file mode 100644 index 00000000..4e6d5766 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/ca.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDWjCCAxqgAwIBAgIGAOz5ITo8MAkGByqGSM44BAMwdjELMAkGA1UEBhMCSUUx +DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll +cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEdMBsGA1UEAxMUQW5vdGhlciBUcmFu +c2llbnQgQ0EwHhcNMDIwNDAyMjM1OTQ2WhcNMTIwNDAyMjI1OTQ2WjB2MQswCQYD +VQQGEwJJRTEPMA0GA1UECBMGRHVibGluMSQwIgYDVQQKExtCYWx0aW1vcmUgVGVj +aG5vbG9naWVzIEx0ZC4xETAPBgNVBAsTCFgvU2VjdXJlMR0wGwYDVQQDExRBbm90 +aGVyIFRyYW5zaWVudCBDQTCCAbYwggErBgcqhkjOOAQBMIIBHgKBgQCEirBKJ4zR +oB7P7ofvWCoJ8GfAbd0+7skASVvXcaTBdHD1F8+HRW0hWOEMlvIoAi7MKTmvnhxx +GLFrxNDa9ZXCh1D16u7uNSScBzatUQBXmYlOsGvtRS979f09awIM3qVe8UuImn8+ +L8XRzJX8ICn6Min6uiVNc6FTP2oSOcVgwwIVAJhL+niCaweCjdHz0QAT8dzR2HJZ +AoGAJYbmGfwMz7Wu/mxOQkGrJklc3PLjP3vizewAZRF8EEZOkH2QXF/E23jzRPGR +Z4OFH7f0MwDlMQCxE+5CgHpOCXsrac3NF2AmMrhiQE5uBfWWNaQCeckJlJsLw2HZ +WmSeJXRszv0eexL54J/xuLao46ItLMd46u3M7w8HRh55MtADgYQAAoGADpGA7hzl +zqaxtr6U+w86qQmoDJhIPMGAUG65aFhGDLm410IzA30J4DYEd9gpnG7lNF+AeHQq +rpvUN+H0CB0eSxiElFRiV+x+oYUN/p1v/mbKXb4H1+mT7XTi5G/k9Kw5e8UbNgDC +Ij/2uewSMd5y+jkWUUUXlwYbqt5pOZZhmtejNjA0MA4GA1UdDwEB/wQEAwICBDAP +BgNVHRMECDAGAQH/AgEAMBEGA1UdDgQKBAiKHFYwWjISfTAJBgcqhkjOOAQDAy8A +MCwCFDI9WLFVplIMf5ta+kB2s/BHBzm9AhQTczFDTX/7sawplNpLfzu5i/g+qA== +-----END CERTIFICATE----- diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh-cert.der b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh-cert.der new file mode 100644 index 0000000000000000000000000000000000000000..2109edfa2ddb013c4514d992bfe04675de7eb047 GIT binary patch literal 851 zcmXqLV)i#^V&+@G%*4pV#K!REr{a-i2Api{T5TTZELfNg$_%-IT;@;~Hen`DS3`aS zUJ!>vn9Zd$DJL_}P{lwAB)}ys?Ua~Pl9`)dl&TPtnw*iBpOc@SnOdyiQ<9=*C}_YB zQpGLI5uqQPnp|3xYA9_VckS;Zv^YPcAIQCH_wFQvXY}>LB?n6izKWl3cg;TL+t@yU%h*0sxpV&Y;7Pz_{>hf4cTv9^{c=BwasSYxwka#qj$%w zdOQ827tc)nqoDcANb}b&Ro~);!S-1~mPZo~Gl??H@cvcNl+E7Md-3x{2H}r)F5W1L zWNK_sZF?s9hv)p(b^mhwoE%rHdB)uN^w_@o(b+c)se&~EZhjMLC&a`bxn1$u<>SQk z=2rRbUyK=^8ZvAYe&^Is<;PjAoq6`Gc!HYIjwA=aJl3z%OqVcKp5&Y|oBMF$&8U=l zswFw+{>oJg{d_S0IbQMV?V0!d>~3a2;%dxaItXoMDp!Z z`u8@#Ko}&X%)(>9)xgmu6J`))Bvgx(0hkRK40M5k-SR%C<<9K5OVR$zYo;xI{K;sp dpc<1%iTrHm3B}19x8Kj~=TQ7za&2p}3jmm{IDG&B literal 0 HcmV?d00001 diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh-cert.pem b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh-cert.pem new file mode 100644 index 00000000..049721f1 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh-cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDTzCCAw6gAwIBAgIGAOz5IcSmMAkGByqGSM44BAMwdjELMAkGA1UEBhMCSUUx +DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll +cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEdMBsGA1UEAxMUQW5vdGhlciBUcmFu +c2llbnQgQ0EwHhcNMDIwNDAzMDAwMDIxWhcNMTIwNDAyMjI1OTQ2WjBmMQswCQYD +VQQGEwJJRTEPMA0GA1UECBMGRHVibGluMSQwIgYDVQQKExtCYWx0aW1vcmUgVGVj +aG5vbG9naWVzIEx0ZC4xETAPBgNVBAsTCFgvU2VjdXJlMQ0wCwYDVQQDEwRMdWdo +MIIBtjCCASsGByqGSM44BAEwggEeAoGBAISKsEonjNGgHs/uh+9YKgnwZ8Bt3T7u +yQBJW9dxpMF0cPUXz4dFbSFY4QyW8igCLswpOa+eHHEYsWvE0Nr1lcKHUPXq7u41 +JJwHNq1RAFeZiU6wa+1FL3v1/T1rAgzepV7xS4iafz4vxdHMlfwgKfoyKfq6JU1z +oVM/ahI5xWDDAhUAmEv6eIJrB4KN0fPRABPx3NHYclkCgYAlhuYZ/AzPta7+bE5C +QasmSVzc8uM/e+LN7ABlEXwQRk6QfZBcX8TbePNE8ZFng4Uft/QzAOUxALET7kKA +ek4Jeytpzc0XYCYyuGJATm4F9ZY1pAJ5yQmUmwvDYdlaZJ4ldGzO/R57Evngn/G4 +tqjjoi0sx3jq7czvDwdGHnky0AOBhAACgYBIdlgw5JS5w1C4a5zQVul03YLFTkaX +6RxbTYsDcnb0SyegrcKQ5y7MgaeDTUVIzCe6Q1WNjvT1fLwWmygpNVUUOZKEJT3p +kSB+8/7IrGM+IWUTxkyIwasgsmrQnV/a+CSRFVDzZQKJFzcdCfZmK0yxh2NrPMiQ +ogOgroVjgLrlE6M6MDgwDgYDVR0PAQH/BAQDAgeAMBEGA1UdDgQKBAiMWQ6+Iv7t +UDATBgNVHSMEDDAKgAiKHFYwWjISfTAJBgcqhkjOOAQDAzAAMC0CFQCE72yE3Jte +0ltPp3yWpePyMp0RJgIUdB+bQ5BzY7G332mPCCH7dNa1Y0Q= +-----END CERTIFICATE----- diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh.der b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh.der new file mode 100644 index 0000000000000000000000000000000000000000..3b1193ab0c1d67c5adbd714dbbbf8676d6edb9fc GIT binary patch literal 442 zcmV;r0Y&~Wf&sQLf&nWA2P%e0&Nu`CFoFRd0)c@5go?0ACyddc9?$NF?^r4c@Mpkn z-9GNg07+Zdaiqa?aP=3@hed56Sm6wo@+bl>%qcmqo*Z!)v1`Q8+Vz#fhfwwE?(Q`t zoCh|oQ29*m79{fq*53<{A79&$X`pY)(Q!t0qZY-16f; zd*aRP0A&$;5JpareUMyV#M^lDMDdYlgM}Zr^fLhEF#xd>?m~ciP6>M}Y0b?SU?wuS zVn9xA1@)FSqyl-#36z@)!(rK4WS%8-Y|i~2dlLEJpYgc1sNwa zO+`q|C%QvbjgIv7e7qK$C@D2n6giTFB|YhpAb#`y$gE>NA!QTBOo+j&AhK%EonPAc kB#{+R^JM~w7dIUV_GT+gv4>-8Jjjru1E8*jV}QEl6KH(VRR910 literal 0 HcmV?d00001 diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh.pem b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh.pem new file mode 100644 index 00000000..e0d1e959 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh.pem @@ -0,0 +1,12 @@ +-----BEGIN PUBLIC KEY----- +MIIBtjCCASsGByqGSM44BAEwggEeAoGBAISKsEonjNGgHs/uh+9YKgnwZ8Bt3T7u +yQBJW9dxpMF0cPUXz4dFbSFY4QyW8igCLswpOa+eHHEYsWvE0Nr1lcKHUPXq7u41 +JJwHNq1RAFeZiU6wa+1FL3v1/T1rAgzepV7xS4iafz4vxdHMlfwgKfoyKfq6JU1z +oVM/ahI5xWDDAhUAmEv6eIJrB4KN0fPRABPx3NHYclkCgYAlhuYZ/AzPta7+bE5C +QasmSVzc8uM/e+LN7ABlEXwQRk6QfZBcX8TbePNE8ZFng4Uft/QzAOUxALET7kKA +ek4Jeytpzc0XYCYyuGJATm4F9ZY1pAJ5yQmUmwvDYdlaZJ4ldGzO/R57Evngn/G4 +tqjjoi0sx3jq7czvDwdGHnky0AOBhAACgYBIdlgw5JS5w1C4a5zQVul03YLFTkaX +6RxbTYsDcnb0SyegrcKQ5y7MgaeDTUVIzCe6Q1WNjvT1fLwWmygpNVUUOZKEJT3p +kSB+8/7IrGM+IWUTxkyIwasgsmrQnV/a+CSRFVDzZQKJFzcdCfZmK0yxh2NrPMiQ +ogOgroVjgLrlEw== +-----END PUBLIC KEY----- diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/macha.der b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/macha.der new file mode 100644 index 0000000000000000000000000000000000000000..484ddc266a575cce00515034ab8aa6674100b2b7 GIT binary patch literal 852 zcmXqLVh%89V&-4K%*4pV#K!REr(*Rl15P$}tu~Ky7A(vLWro~9E^{agn=q57t0BJu zFNnh-%;r*>l#`ifsA8Z565tY+c1p}C$;{0!N>vC+P0q;6&&f~EOf6RMDM`^Y6g1!m zsp1yqh|mvCO)f1;HIy}w1_?0>i#X=xmt>?CDTEXy<`rkA=9MToI~vG|^BNc#m>3uv zfPit7IIkgyYh+|(YH4B?WspuQckvnUK-|UZo0y!DXwbyC4T!a2A;Jjc$T2lGGPHDU z@KW!&xIpgwyY}}HTAUx!59Hppdv}t-Gx~brl7l4$U&YV2yXGoJJmi`7NrOr6jHc!K zc`}6(8?%pGxb=1Fq4t2Uuim{gRhh$Xwlk(s%4Tosz4-YegYd^Y7jG0r zGBq}+wmp;l!*hP?x_>!-PL8Y9JY(*BdTd|)=rc0PAPjXI~&3!oWW>m^N z)smcZf90x$emJb~m|7qYKQ9Eet?s#J)U|@lQypar-Wp z$J>_t@p&}2+-7#^eYqvC-1v&`&Cpvo>%^1X&(3QW%H|#W%djH)(yTI>r5jEDoM%v~ zda_wz-Iepn|K^+QalHP^PCeYyedoQFg|$&O%sot=FaL)XH*Zk+ckqp6L9|7b!C}91 z2||pn_Dt>Fk=8wR2Iki97S3L=*vi1dfDag#viyvU|5;d=nb;c)1VMa07A_W!ZsRwO zm6K2WG7ts{DYNhxa5Zpr$%Gk1841-Q_1_NDSV0YRX3m;&7^}X+GxY#kD8RCq0 d=0r1zSZPlv+H9kFZBkYqZ|`5J@23PC8~|)vn9Zd$DJL_}P{lwAB)}ys?Ua~Pl9`)dl&TPtnw*iBpOc@SnOdyiQ<9=*C}_YB zQpGLI5uqQPnp|3xYA9+T3=(1%<_RfE%qz}J%_~uGb~KO^=QS`gFflMPGBUL^GK~`F zHH2^t!Cbn#r-^a9K@+16EI=569C@b3MuxkO=P~_SF07at{d%$&*WLbY+`UVlELXf& zu+U?{?mdo^QzT6|uAb=d5&SZ3`H6`a+P(DUI~}(3T5VUqn4|jqM`EODkC!dE z)~?=`aC}kI$DEY&t1fU0uYMEDu;iK2=R2p}+IJW^9IIZv@$%EPC!Snb7{q33a5y0J zbs%%J-bN--h8CkcB5T>KKYqTt>Z8T$Hx1{n>Z^jBHm|0-b$i%i!`a&k6XtGAUo`#V z^sN^&Po>yQajT#FP0>Si(?k(ZFWy(W@jT)On|nWN-Mc%*ZG!%`pFgJD++M7ha{0w( zX@#TFliym~H_x;ds)|keEA>pqlAU*No!K$jA3PuLu5P_MrQ63n&zJe}-1L?`p;0gM zJD&8n8+Glix_wfCxv__D?%~oO{kFchS6jT_^4i5up?zQP&Ifi=`k7xQcwKjOTWkhQ zMts0DCd<#r_@9M^nTd%7oD5}!SvU;XfMQIH3@tKik#wtHpIa0ELt^ A;Q#;t literal 0 HcmV?d00001 diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/merlin.pem b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/merlin.pem new file mode 100644 index 00000000..7efe8e08 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/merlin.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDSzCCAwugAwIBAgIGAOz46fwJMAkGByqGSM44BAMwbjELMAkGA1UEBhMCSUUx +DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll +cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEVMBMGA1UEAxMMVHJhbnNpZW50IENB +MB4XDTAyMDQwMjIyNTkyNVoXDTEyMDQwMjIxNTkyNVowbjELMAkGA1UEBhMCSUUx +DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll +cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEVMBMGA1UEAxMMVHJhbnNpZW50IENB +MIIBtzCCASwGByqGSM44BAEwggEfAoGBAN3jngL6pxMhaVvrk0oK3Y+2C42k5Kch +3nChSKC7vEGTZBk0CNXIiEwR9JanyJHQh0ovH4lAtw06tyfRbCXn+GFbQxeyaVLx +0zkKrau2YMeigvFsZM+q0AsTq+xdAKTmIvPcy0aHuDJAxnursdPlrcjk0KFSBjUw +w1BV61EDWy6xAhUAhDLcFK0GO/Hz1arxOOvsgM/VLyUCgYEAnnx7hbdWozGbtnFg +nbFnopfRl7XRacpkPJRGf5P2IUgVspEUSUoN6i1fDBfBg43zKt7dlEaQL7b5+JTZ +t3MhZNPosxsgxVuT7Ts/g5k7EnpdYv0a5hw5Bw29fjbGHfgM8d2rhd2Ui0xHbk0D +451nhLxVWulviOSPhzKKvXrbySADgYQAAoGAfag+HCABIJadDD9Aarhgc2QR3Lp7 +PpMOh0lAwLiIsvkO4UlbeOS0IJC8bcqLjM1fVw6FGSaxmq+4y1ag2m9k6IdE0Qh5 +NxB/xFkmdwqXFRIJVp44OeUygB47YK76NmUIYG3DdfiPPU3bqzjvtOtETiCHvo25 +4D6UjwPpYErXRUajNjA0MA4GA1UdDwEB/wQEAwICBDAPBgNVHRMECDAGAQH/AgEA +MBEGA1UdDgQKBAiDhj5AdjLikzAJBgcqhkjOOAQDAy8AMCwCFELu0nuweqW7Wf0s +gk/CAGGL0BGKAhRNdgQGr5iyZKoH4oqPm0VJ9TjXLg== +-----END CERTIFICATE----- + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/morigu.pem b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/morigu.pem new file mode 100644 index 00000000..c1fd6eb5 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/morigu.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDUDCCAxCgAwIBAgIGAOz5IVHTMAkGByqGSM44BAMwdjELMAkGA1UEBhMCSUUx +DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll +cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEdMBsGA1UEAxMUQW5vdGhlciBUcmFu +c2llbnQgQ0EwHhcNMDIwNDAyMjM1OTUyWhcNMTIwNDAyMjI1OTQ2WjBoMQswCQYD +VQQGEwJJRTEPMA0GA1UECBMGRHVibGluMSQwIgYDVQQKExtCYWx0aW1vcmUgVGVj +aG5vbG9naWVzIEx0ZC4xETAPBgNVBAsTCFgvU2VjdXJlMQ8wDQYDVQQDEwZNb3Jp +Z3UwggG2MIIBKwYHKoZIzjgEATCCAR4CgYEAhIqwSieM0aAez+6H71gqCfBnwG3d +Pu7JAElb13GkwXRw9RfPh0VtIVjhDJbyKAIuzCk5r54ccRixa8TQ2vWVwodQ9eru +7jUknAc2rVEAV5mJTrBr7UUve/X9PWsCDN6lXvFLiJp/Pi/F0cyV/CAp+jIp+rol +TXOhUz9qEjnFYMMCFQCYS/p4gmsHgo3R89EAE/Hc0dhyWQKBgCWG5hn8DM+1rv5s +TkJBqyZJXNzy4z974s3sAGURfBBGTpB9kFxfxNt480TxkWeDhR+39DMA5TEAsRPu +QoB6Tgl7K2nNzRdgJjK4YkBObgX1ljWkAnnJCZSbC8Nh2VpkniV0bM79HnsS+eCf +8bi2qOOiLSzHeOrtzO8PB0YeeTLQA4GEAAKBgH1NBJ9Az5TwY4tDE0dPYVHHABt+ +yLspnT3k9G6YWUMFhZ/+3RuqEPjnKrPfUoXTTJGIACgPU3/PkqwrPVD0JMdpOcnZ +LHiJ/P7QRQeMwDRoBrs7genB1bDd4pSJrEUcjrkA5uRrIj2Z5fL+UuLiLGPO2rM7 +BNQRIq3QFPdX++NuozowODAOBgNVHQ8BAf8EBAMCB4AwEQYDVR0OBAoECIK7Ljjh ++EsfMBMGA1UdIwQMMAqACIocVjBaMhJ9MAkGByqGSM44BAMDLwAwLAIUEJJCOHw8 +ppxoRyz3s+Vmb4NKIfMCFDgJoZn9zh/3WoYNBURODwLvyBOy +-----END CERTIFICATE----- diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/nemain.der b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/nemain.der new file mode 100644 index 0000000000000000000000000000000000000000..f4b62ae6ff1f1b334f5dde43ba6f5fc59b79de0e GIT binary patch literal 852 zcmXqLVh%89Vis7y%*4pV#K!REr{aX;2Api{T5TTZELfNg$_%-IT;@;~Hen`DS3`aS zUJ!>vn9Zd$DJL_}P{lwAB)}ys?Ua~Pl9`)dl&TPtnw*iBpOc@SnOdyiQ<9=*C}_YB zQpGLI5uqQPnp|3xYA9SQ?t=P|S(wc)H8(Lc&!CBM8xU*5f`k#skz;CXWN7Ky z;HBPkae>_VckS;Zv^YPcAIQCH_wFQvXY}>LB?n6izKWl3cg;TL+t@yU%h*0sxpV&Y;7Pz_{>hf4cTv9^{c=BwasSYxwka#qj$%w zdOQ827tc)nqoDcANb}b&Ro~);!S-1~mPZo~Gl??H@cvcNl+E7Md-3x{2H}r)F5W1L zWNK_sZF?s9hv)p(b^mhwoE%rHdB)uN^w_@o(b+c)se&~EZhjMLC&a`bxn1$u<>SQk z=2rRbUyK=^8ZvAYe&^Is<;PjAoq6`Gc!HYIjwA=aJl3z%OqVcKp5&Y|oBMF$&8U=l zswFw+{>oJg{d_S0IbQMV?V0!d>~34|M z<(KAk7zl%elv#KTxEeURWWo%hjD%{DvH-I_gMki{h^Ux~{;FovscSWT&3~pbb|kWS cH8P2K2TJtTMpW2sw2x=~*j3lhJ7?+-0EYWEegFUf literal 0 HcmV?d00001 diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/nemain.pem b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/nemain.pem new file mode 100644 index 00000000..b681a5c2 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/nemain.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDUDCCAxCgAwIBAgIGAOz5IZDHMAkGByqGSM44BAMwdjELMAkGA1UEBhMCSUUx +DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll +cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEdMBsGA1UEAxMUQW5vdGhlciBUcmFu +c2llbnQgQ0EwHhcNMDIwNDAzMDAwMDA4WhcNMTIwNDAyMjI1OTQ2WjBoMQswCQYD +VQQGEwJJRTEPMA0GA1UECBMGRHVibGluMSQwIgYDVQQKExtCYWx0aW1vcmUgVGVj +aG5vbG9naWVzIEx0ZC4xETAPBgNVBAsTCFgvU2VjdXJlMQ8wDQYDVQQDEwZOZW1h +aW4wggG2MIIBKwYHKoZIzjgEATCCAR4CgYEAhIqwSieM0aAez+6H71gqCfBnwG3d +Pu7JAElb13GkwXRw9RfPh0VtIVjhDJbyKAIuzCk5r54ccRixa8TQ2vWVwodQ9eru +7jUknAc2rVEAV5mJTrBr7UUve/X9PWsCDN6lXvFLiJp/Pi/F0cyV/CAp+jIp+rol +TXOhUz9qEjnFYMMCFQCYS/p4gmsHgo3R89EAE/Hc0dhyWQKBgCWG5hn8DM+1rv5s +TkJBqyZJXNzy4z974s3sAGURfBBGTpB9kFxfxNt480TxkWeDhR+39DMA5TEAsRPu +QoB6Tgl7K2nNzRdgJjK4YkBObgX1ljWkAnnJCZSbC8Nh2VpkniV0bM79HnsS+eCf +8bi2qOOiLSzHeOrtzO8PB0YeeTLQA4GEAAKBgHzbc/0aTzXwKKeT85kjCq2HD4WY +nZC9DOck02gNhNbEgN+wGeUPDSQM/vhmxVeoK3ptVA/sU8arBW8V+AdrU/9hJr0v +nEiqgt9WQLHUhnMJiXTMLcS7XHeIVcwh/iRjD61HUp1cby9UMHZRsW6Ys8rUi0Zn +/1KrtpTwZJuNwsYIozowODAOBgNVHQ8BAf8EBAMCB4AwEQYDVR0OBAoECIX9dMSn +0pyIMBMGA1UdIwQMMAqACIocVjBaMhJ9MAkGByqGSM44BAMDLwAwLAIUFRYkL6qD +NZWtKU03+WYBiGEGSoECFEtRGI19WHg+sT9fBfGKfo8NnJX4 +-----END CERTIFICATE----- diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloped-dsa.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloped-dsa.tmpl new file mode 100644 index 00000000..ba499417 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloped-dsa.tmpl @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + TestKeyName-dsa-1024 + + + + + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-b64-dsa.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-b64-dsa.tmpl new file mode 100644 index 00000000..fc9d34c1 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-b64-dsa.tmpl @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + TestKeyName-dsa-1024 + + + + c29tZSB0ZXh0 + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-b64-dsa.xml b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-b64-dsa.xml new file mode 100644 index 00000000..4e924b0e --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-b64-dsa.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + N6pjx3OY2VRHMmLhoAV8HmMu2nc= + + + + KgAeq8e0yUNfFz+mFlZ3QgyQNMciV+Z3BoDQDvQNker7pazEnJmOIA== + + + + +

+ 3eOeAvqnEyFpW+uTSgrdj7YLjaTkpyHecKFIoLu8QZNkGTQI1ciITBH0lqfIkdCH + Si8fiUC3DTq3J9FsJef4YVtDF7JpUvHTOQqtq7Zgx6KC8Wxkz6rQCxOr7F0ApOYi + 89zLRoe4MkDGe6ux0+WtyOTQoVIGNTDDUFXrUQNbLrE= +

+ + hDLcFK0GO/Hz1arxOOvsgM/VLyU= + + + nnx7hbdWozGbtnFgnbFnopfRl7XRacpkPJRGf5P2IUgVspEUSUoN6i1fDBfBg43z + Kt7dlEaQL7b5+JTZt3MhZNPosxsgxVuT7Ts/g5k7EnpdYv0a5hw5Bw29fjbGHfgM + 8d2rhd2Ui0xHbk0D451nhLxVWulviOSPhzKKvXrbySA= + + + cfYpihpAQeepbNFS4MAbQRhdXpDi5wLrwxE5hIvoYqo1L8BQVu8fY1TFAPtoae1i + Bg/GIJyP3iLfyuBJaDvJJLP30wBH9i/s5J3656PevpOVdTfi777Fi9Gj6y/ib2Vv + +OZfJkkp4L50+p5TUhPmQLJtREsgtl+tnIOyJT++G9U= + +
+
+
+ c29tZSB0ZXh0 +
diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-dsa.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-dsa.tmpl new file mode 100644 index 00000000..3870393f --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-dsa.tmpl @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + TestKeyName-dsa-1024 + + + + some text + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-dsa.xml b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-dsa.xml new file mode 100644 index 00000000..488ac261 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-dsa.xml @@ -0,0 +1,39 @@ + + + + + + + + 7/XTsHaBSOnJ/jXD5v0zL6VKYsk= + + + + PfD92lkxKgc2OKvF4p0ba6cJj6d1eqIDx5Q1hvVYTviotje23Snunw== + + + + +

+ 3eOeAvqnEyFpW+uTSgrdj7YLjaTkpyHecKFIoLu8QZNkGTQI1ciITBH0lqfIkdCH + Si8fiUC3DTq3J9FsJef4YVtDF7JpUvHTOQqtq7Zgx6KC8Wxkz6rQCxOr7F0ApOYi + 89zLRoe4MkDGe6ux0+WtyOTQoVIGNTDDUFXrUQNbLrE= +

+ + hDLcFK0GO/Hz1arxOOvsgM/VLyU= + + + nnx7hbdWozGbtnFgnbFnopfRl7XRacpkPJRGf5P2IUgVspEUSUoN6i1fDBfBg43z + Kt7dlEaQL7b5+JTZt3MhZNPosxsgxVuT7Ts/g5k7EnpdYv0a5hw5Bw29fjbGHfgM + 8d2rhd2Ui0xHbk0D451nhLxVWulviOSPhzKKvXrbySA= + + + cfYpihpAQeepbNFS4MAbQRhdXpDi5wLrwxE5hIvoYqo1L8BQVu8fY1TFAPtoae1i + Bg/GIJyP3iLfyuBJaDvJJLP30wBH9i/s5J3656PevpOVdTfi777Fi9Gj6y/ib2Vv + +OZfJkkp4L50+p5TUhPmQLJtREsgtl+tnIOyJT++G9U= + +
+
+
+ some text +
diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1-40.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1-40.tmpl new file mode 100644 index 00000000..a8497338 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1-40.tmpl @@ -0,0 +1,19 @@ + + + + + + 80 + + + + + + + + + + TeskKeyName-Hmac + + some text + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1-40.xml b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1-40.xml new file mode 100644 index 00000000..d654c536 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1-40.xml @@ -0,0 +1,17 @@ + + + + + + 80 + + + + 7/XTsHaBSOnJ/jXD5v0zL6VKYsk= + + + + xjqFz/yYQRTOrw== + + some text + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1.tmpl new file mode 100644 index 00000000..caa50b50 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1.tmpl @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + TeskKeyName-Hmac + + some text + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1.xml b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1.xml new file mode 100644 index 00000000..c0c8343a --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1.xml @@ -0,0 +1,15 @@ + + + + + + + + 7/XTsHaBSOnJ/jXD5v0zL6VKYsk= + + + + JElPttIT4Am7Q+MNoMyv+WDfAZw= + + some text + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-rsa.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-rsa.tmpl new file mode 100644 index 00000000..90e7a993 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-rsa.tmpl @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + TestKeyName-rsa-2048 + + + + some text + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-external-b64-dsa.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-external-b64-dsa.tmpl new file mode 100644 index 00000000..f5f48cd5 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-external-b64-dsa.tmpl @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + TestKeyName-dsa-1024 + + + + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-external-b64-dsa.xml b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-external-b64-dsa.xml new file mode 100644 index 00000000..1fb56630 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-external-b64-dsa.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + 60NvZvtdTB+7UnlLp/H24p7h4bs= + + + + IhOlAjMFaZtkEju5R5bi528h1HpDa4A21sudZynhJRRLjZuQIHZ3eQ== + + + + +

+ 3eOeAvqnEyFpW+uTSgrdj7YLjaTkpyHecKFIoLu8QZNkGTQI1ciITBH0lqfIkdCH + Si8fiUC3DTq3J9FsJef4YVtDF7JpUvHTOQqtq7Zgx6KC8Wxkz6rQCxOr7F0ApOYi + 89zLRoe4MkDGe6ux0+WtyOTQoVIGNTDDUFXrUQNbLrE= +

+ + hDLcFK0GO/Hz1arxOOvsgM/VLyU= + + + nnx7hbdWozGbtnFgnbFnopfRl7XRacpkPJRGf5P2IUgVspEUSUoN6i1fDBfBg43z + Kt7dlEaQL7b5+JTZt3MhZNPosxsgxVuT7Ts/g5k7EnpdYv0a5hw5Bw29fjbGHfgM + 8d2rhd2Ui0xHbk0D451nhLxVWulviOSPhzKKvXrbySA= + + + cfYpihpAQeepbNFS4MAbQRhdXpDi5wLrwxE5hIvoYqo1L8BQVu8fY1TFAPtoae1i + Bg/GIJyP3iLfyuBJaDvJJLP30wBH9i/s5J3656PevpOVdTfi777Fi9Gj6y/ib2Vv + +OZfJkkp4L50+p5TUhPmQLJtREsgtl+tnIOyJT++G9U= + +
+
+
+
diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-external-dsa.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-external-dsa.tmpl new file mode 100644 index 00000000..2b5c73e2 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-external-dsa.tmpl @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + TestKeyName-dsa-1024 + + + + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-external-dsa.xml b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-external-dsa.xml new file mode 100644 index 00000000..34d3e6a8 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-external-dsa.xml @@ -0,0 +1,38 @@ + + + + + + + + 60NvZvtdTB+7UnlLp/H24p7h4bs= + + + + LaL1/t/XodYvDJDgSEbq47GX8ltnlx3FFURdi7o+UFVi+zLf0WyWaQ== + + + + +

+ 3eOeAvqnEyFpW+uTSgrdj7YLjaTkpyHecKFIoLu8QZNkGTQI1ciITBH0lqfIkdCH + Si8fiUC3DTq3J9FsJef4YVtDF7JpUvHTOQqtq7Zgx6KC8Wxkz6rQCxOr7F0ApOYi + 89zLRoe4MkDGe6ux0+WtyOTQoVIGNTDDUFXrUQNbLrE= +

+ + hDLcFK0GO/Hz1arxOOvsgM/VLyU= + + + nnx7hbdWozGbtnFgnbFnopfRl7XRacpkPJRGf5P2IUgVspEUSUoN6i1fDBfBg43z + Kt7dlEaQL7b5+JTZt3MhZNPosxsgxVuT7Ts/g5k7EnpdYv0a5hw5Bw29fjbGHfgM + 8d2rhd2Ui0xHbk0D451nhLxVWulviOSPhzKKvXrbySA= + + + cfYpihpAQeepbNFS4MAbQRhdXpDi5wLrwxE5hIvoYqo1L8BQVu8fY1TFAPtoae1i + Bg/GIJyP3iLfyuBJaDvJJLP30wBH9i/s5J3656PevpOVdTfi777Fi9Gj6y/ib2Vv + +OZfJkkp4L50+p5TUhPmQLJtREsgtl+tnIOyJT++G9U= + +
+
+
+
diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-keyname.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-keyname.tmpl new file mode 100644 index 00000000..add078f2 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-keyname.tmpl @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + TestKeyName-dsa-1024 + + + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-keyname.xml b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-keyname.xml new file mode 100644 index 00000000..a7c60a3d --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-keyname.xml @@ -0,0 +1,17 @@ + + + + + + + + 60NvZvtdTB+7UnlLp/H24p7h4bs= + + + + JkJ3GplEU0iDbqSv7ZOXhvv3zeM1KmP+CLphhoc+NPYqpGYQiW6O6w== + + + Lugh + + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-retrievalmethod-rawx509crt.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-retrievalmethod-rawx509crt.tmpl new file mode 100644 index 00000000..064a953e --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-retrievalmethod-rawx509crt.tmpl @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-retrievalmethod-rawx509crt.xml b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-retrievalmethod-rawx509crt.xml new file mode 100644 index 00000000..30620184 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-retrievalmethod-rawx509crt.xml @@ -0,0 +1,17 @@ + + + + + + + + 60NvZvtdTB+7UnlLp/H24p7h4bs= + + + + SNB5FI193RFXoG2j8Z9bXWgW7BMPICqNob4Hjh08oou4tkhGxz4+pg== + + + + + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt-crl.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt-crl.tmpl new file mode 100644 index 00000000..7b8d170d --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt-crl.tmpl @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + TestKeyName-dsa-1024 + + + + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt.tmpl new file mode 100644 index 00000000..7b8d170d --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt.tmpl @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + TestKeyName-dsa-1024 + + + + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-is.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-is.tmpl new file mode 100644 index 00000000..7b8d170d --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-is.tmpl @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + TestKeyName-dsa-1024 + + + + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-ski.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-ski.tmpl new file mode 100644 index 00000000..7b8d170d --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-ski.tmpl @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + TestKeyName-dsa-1024 + + + + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-sn.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-sn.tmpl new file mode 100644 index 00000000..7b8d170d --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-sn.tmpl @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + TestKeyName-dsa-1024 + + + + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature.tmpl new file mode 100644 index 00000000..0e2d0781 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature.tmpl @@ -0,0 +1,252 @@ + + + + + + +]> + + + foo + bar + + + + + + + + + + + + + + + + + + + + + self::text() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ancestor-or-self::dsig:SignedInfo + and + count(ancestor-or-self::dsig:Reference | + here()/ancestor::dsig:Reference[1]) > + count(ancestor-or-self::dsig:Reference) + or + count(ancestor-or-self::node() | + id('notaries')) = + count(ancestor-or-self::node()) + + + + + + + + + + + + + + + ancestor-or-self::dsig:X509Data + + + + + + I am the text. + SSBhbSB0aGUgdGV4dC4= + + + + + + + + + + + + + + + + + + + + + + Notaries + + + + + + + + +
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + + + 192.168.21.138 + + + + + + +MIIFqjCCBJKgAwIBAgIUdzXuSH9oYtrxs5VtlhzLD6bzT1AwDQYJKoZIhvcNAQEL +BQAwgbYxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMT0wOwYDVQQK +EzRYTUwgU2VjdXJpdHkgTGlicmFyeSAoaHR0cDovL3d3dy5hbGVrc2V5LmNvbS94 +bWxzZWMpMRgwFgYDVQQLEw9TZWNvbmQgbGV2ZWwgQ0ExFjAUBgNVBAMTDUFsZWtz +ZXkgU2FuaW4xITAfBgkqhkiG9w0BCQEWEnhtbHNlY0BhbGVrc2V5LmNvbTAgFw0y +NjAzMDgyMjEzMTBaGA8yMTI2MDIxMjIyMTMxMFowfTELMAkGA1UEBhMCVVMxEzAR +BgNVBAgTCkNhbGlmb3JuaWExPTA7BgNVBAoTNFhNTCBTZWN1cml0eSBMaWJyYXJ5 +IChodHRwOi8vd3d3LmFsZWtzZXkuY29tL3htbHNlYykxGjAYBgNVBAMTEVRlc3Qg +S2V5IGRzYS0xMDI0MIIBtjCCASsGByqGSM44BAEwggEeAoGBAIXYS5F9OLq7vXyX +vPx4EY5UKcDS+nXaVDFwppOgO5DxHw8ZDronBwAYUMMJrNsakb17IMyQvuJDR0FP +HLxyAQXrWjXXiR7tbwG5oC2/N/H33iU6qcHcxk9Xp6DKaiNZXVgOmwuiD4xDQm0n +lAMeFRP1TIlvouaQB6s6+RGwPD81AhUApYJ8h4jXgfdyWtN+hFTj4bub068CgYBq +/BjSSH5vUQaZZshI2BdEu8N7R4Ecy5OJYcPytvfj6zSTR/N+4PRnDCAHXXyGsYi0 +FB3SDcdgIn+MfJUOx1KRNXhp2AK/F6QVfgp8J6TgFonsHAJNlsjZJ06QLwAVs0Tv +yVuEZcePakDLsGwfFsRIWLT0oeZ5wmm59tQ1AY881wOBhAACgYBgSc1I6UqJiCj4 +MDiNQ1s+rVJHG0emMr7sELrqaxmQrgzEs5NBfFE6e4doXfVfz1A+OXDW4vmx0YFD +vXOy9KHgFQpBViUf7P4c9/BERiIvL7rWuTNkW/g2O9ssCHhwq3Ifs51ScfGjjdpb +gsEtdrCzxiQondH71HAXvLcy9g2XgqOCAVAwggFMMAwGA1UdEwQFMAMBAf8wLAYJ +YIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRlMB0GA1Ud +DgQWBBSja1vEKoR/s6T/Ybtp6LvtXuA2SDCB7gYDVR0jBIHmMIHjgBTRfResRUKK +jvmwFyXVPHKYnYg6JaGBtKSBsTCBrjELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNh +bGlmb3JuaWExPTA7BgNVBAoTNFhNTCBTZWN1cml0eSBMaWJyYXJ5IChodHRwOi8v +d3d3LmFsZWtzZXkuY29tL3htbHNlYykxEDAOBgNVBAsTB1Jvb3QgQ0ExFjAUBgNV +BAMTDUFsZWtzZXkgU2FuaW4xITAfBgkqhkiG9w0BCQEWEnhtbHNlY0BhbGVrc2V5 +LmNvbYIUdzXuSH9oYtrxs5VtlhzLD6bzT08wDQYJKoZIhvcNAQELBQADggEBAJA2 +6Gg+tjwHN2LOFLGf0H/L9EGOsVd766W9WlSMd9o4Scu7CpPxjlxIiZ1Me4PqNA9B +yOpn0+etG4C2ZYx8uC05NaqqwsONDyCbDIQY65DoHgmN1UykWtFo7+7107C6d2Dt +Sx9NK/s8+khLHCKk+zcCSlITHqo9jGqkeHJ/N7D1YY7J4tigDnQLK0JDYP86GwVm +Lntj2aOW8tlTuT/e2SFfcjaeAbY8nw1j6Xe3cI/IsMQIKkZPDSpi1vpbQh2Wp2VJ +qfD/c9NgPET/AhJ8M6+2dAQ2odRwBRknPhbyFKHuRbUS5M29h0AaRM7JzgajieZH +YGsyfg9XjjhqrWyi+OM= + + + +
+
+ bar + + + + + +
+ diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature.xml b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature.xml new file mode 100644 index 00000000..504fbe11 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature.xml @@ -0,0 +1,269 @@ + + + + + + +]> + + + foo + bar + + + + + + + + 60NvZvtdTB+7UnlLp/H24p7h4bs= + + + + + + + 60NvZvtdTB+7UnlLp/H24p7h4bs= + + + + + + self::text() + + + + + zyjp8GJOX69990Kkqw8ioPXGExk= + + + + + + ancestor-or-self::dsig:SignedInfo + and + count(ancestor-or-self::dsig:Reference | + here()/ancestor::dsig:Reference[1]) > + count(ancestor-or-self::dsig:Reference) + or + count(ancestor-or-self::node() | + id('notaries')) = + count(ancestor-or-self::node()) + + + + + tQiE3GUKiBenPyp3J0Ei6rJMFv4= + + + + + + + zyjp8GJOX69990Kkqw8ioPXGExk= + + + + qg4HFwsN+/WX32uH85WlJU9l45k= + + + + ETlEI3y7hvvAtMe9wQSz7LhbHEE= + + + + + + + J/O0HhdaPXxx49fgGWMESL09GpA= + + + + + + + + J/O0HhdaPXxx49fgGWMESL09GpA= + + + + + + + J/O0HhdaPXxx49fgGWMESL09GpA= + + + + + + + + MkL9CX8yeABBth1RChyPx58Ls8w= + + + + yamSIokKmjA3hB/s3Fu07wDO3vM= + + + + + + + yamSIokKmjA3hB/s3Fu07wDO3vM= + + + + yamSIokKmjA3hB/s3Fu07wDO3vM= + + + + + + + 419CYgyTWOTGYGBhzieWklNf7Bk= + + + + VzK45P9Ksjqq5oXlKQpkGgB2CNY= + + + + 7/9fR+NIDz9owc1Lfsxu1JBr8uo= + + + + qURlo3LSq4TWQtygBZJ0iXQ9E14= + + + + WvZUJAJ/3QNqzQvwne2vvy7U5Pck8ZZ5UTa6pIwR7GE+PoGi6A1kyw== + + + + + + + ancestor-or-self::dsig:X509Data + + + + + + I am the text. + SSBhbSB0aGUgdGV4dC4= + + + + + + + + 60NvZvtdTB+7UnlLp/H24p7h4bs= + + + + qURlo3LSq4TWQtygBZJ0iXQ9E14= + + + + + + + + + + Notaries + + + + + + + + +
+ +
+ + +
+
+
+ +
+ + c7wq5XKos6RqNVJyFy7/fl6+sAs= +
+
+
+ + + + 192.168.21.138 + + + + + + + CN=Merlin Hughes,OU=X/Secure,O=Baltimore Technologies Ltd.,ST=Dublin,C=IE + + + + CN=Transient CA,OU=X/Secure,O=Baltimore Technologies Ltd.,ST=Dublin,C=IE + + 1017788370348 + + + MIIDUDCCAxCgAwIBAgIGAOz46g2sMAkGByqGSM44BAMwbjELMAkGA1UEBhMCSUUx + DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll + cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEVMBMGA1UEAxMMVHJhbnNpZW50IENB + MB4XDTAyMDQwMjIyNTkzMFoXDTEyMDQwMjIxNTkyNVowbzELMAkGA1UEBhMCSUUx + DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll + cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEWMBQGA1UEAxMNTWVybGluIEh1Z2hl + czCCAbcwggEsBgcqhkjOOAQBMIIBHwKBgQDd454C+qcTIWlb65NKCt2PtguNpOSn + Id5woUigu7xBk2QZNAjVyIhMEfSWp8iR0IdKLx+JQLcNOrcn0Wwl5/hhW0MXsmlS + 8dM5Cq2rtmDHooLxbGTPqtALE6vsXQCk5iLz3MtGh7gyQMZ7q7HT5a3I5NChUgY1 + MMNQVetRA1susQIVAIQy3BStBjvx89Wq8Tjr7IDP1S8lAoGBAJ58e4W3VqMxm7Zx + YJ2xZ6KX0Ze10WnKZDyURn+T9iFIFbKRFElKDeotXwwXwYON8yre3ZRGkC+2+fiU + 2bdzIWTT6LMbIMVbk+07P4OZOxJ6XWL9GuYcOQcNvX42xh34DPHdq4XdlItMR25N + A+OdZ4S8VVrpb4jkj4cyir1628kgA4GEAAKBgHH2KYoaQEHnqWzRUuDAG0EYXV6Q + 4ucC68MROYSL6GKqNS/AUFbvH2NUxQD7aGntYgYPxiCcj94i38rgSWg7ySSz99MA + R/Yv7OSd+uej3r6TlXU34u++xYvRo+sv4m9lb/jmXyZJKeC+dPqeU1IT5kCybURL + ILZfrZyDsiU/vhvVozowODAOBgNVHQ8BAf8EBAMCB4AwEQYDVR0OBAoECIatY7SE + lXEOMBMGA1UdIwQMMAqACIOGPkB2MuKTMAkGByqGSM44BAMDLwAwLAIUSvT02iQj + Q5da4Wpe0Bvs7GuCcVsCFCEcQpbjUfnxXFXNWiFyQ49ZrWqn + + + MIIDSzCCAwugAwIBAgIGAOz46fwJMAkGByqGSM44BAMwbjELMAkGA1UEBhMCSUUx + DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll + cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEVMBMGA1UEAxMMVHJhbnNpZW50IENB + MB4XDTAyMDQwMjIyNTkyNVoXDTEyMDQwMjIxNTkyNVowbjELMAkGA1UEBhMCSUUx + DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll + cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEVMBMGA1UEAxMMVHJhbnNpZW50IENB + MIIBtzCCASwGByqGSM44BAEwggEfAoGBAN3jngL6pxMhaVvrk0oK3Y+2C42k5Kch + 3nChSKC7vEGTZBk0CNXIiEwR9JanyJHQh0ovH4lAtw06tyfRbCXn+GFbQxeyaVLx + 0zkKrau2YMeigvFsZM+q0AsTq+xdAKTmIvPcy0aHuDJAxnursdPlrcjk0KFSBjUw + w1BV61EDWy6xAhUAhDLcFK0GO/Hz1arxOOvsgM/VLyUCgYEAnnx7hbdWozGbtnFg + nbFnopfRl7XRacpkPJRGf5P2IUgVspEUSUoN6i1fDBfBg43zKt7dlEaQL7b5+JTZ + t3MhZNPosxsgxVuT7Ts/g5k7EnpdYv0a5hw5Bw29fjbGHfgM8d2rhd2Ui0xHbk0D + 451nhLxVWulviOSPhzKKvXrbySADgYQAAoGAfag+HCABIJadDD9Aarhgc2QR3Lp7 + PpMOh0lAwLiIsvkO4UlbeOS0IJC8bcqLjM1fVw6FGSaxmq+4y1ag2m9k6IdE0Qh5 + NxB/xFkmdwqXFRIJVp44OeUygB47YK76NmUIYG3DdfiPPU3bqzjvtOtETiCHvo25 + 4D6UjwPpYErXRUajNjA0MA4GA1UdDwEB/wQEAwICBDAPBgNVHRMECDAGAQH/AgEA + MBEGA1UdDgQKBAiDhj5AdjLikzAJBgcqhkjOOAQDAy8AMCwCFELu0nuweqW7Wf0s + gk/CAGGL0BGKAhRNdgQGr5iyZKoH4oqPm0VJ9TjXLg== + + + +
+
+ bar + + + + + +
+ diff --git a/tests/fixtures_smoke.rs b/tests/fixtures_smoke.rs index a95a2d00..59cc8c93 100644 --- a/tests/fixtures_smoke.rs +++ b/tests/fixtures_smoke.rs @@ -178,7 +178,7 @@ fn fixture_file_count_matches_expected() { let expected = [ ("keys", 24), ("c14n", 41), - ("xmldsig", 81), + ("xmldsig", 127), ("saml", 2), ("xmlenc", 482), ]; @@ -193,6 +193,27 @@ fn fixture_file_count_matches_expected() { } } +#[test] +fn merlin_xmldsig_snapshot_contains_complete_interop_inputs() { + // These files cover the distinct detached, HMAC, key-resolution, and CRL paths. + let required = [ + "xmldsig/merlin-xmldsig-twenty-three/signature.xml", + "xmldsig/merlin-xmldsig-twenty-three/signature-external-dsa.xml", + "xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1-40.xml", + "xmldsig/merlin-xmldsig-twenty-three/signature-retrievalmethod-rawx509crt.xml", + "xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt-crl.xml", + "xmldsig/merlin-xmldsig-twenty-three/certs/ca.pem", + "xmldsig/merlin-xmldsig-twenty-three/certs/balor.der", + "xmldsig/external-data/xml-stylesheet-2005", + "xmldsig/external-data/xml-stylesheet-2005.b64", + ]; + + for relative_path in required { + let path = fixtures_dir().join(relative_path); + assert!(path.is_file(), "missing Merlin fixture: {}", path.display()); + } +} + // ─── Helpers ──────────────────────────────────────────────────────────────── /// Assert that a file exists and contains the expected PEM header marker. diff --git a/tests/merlin_interop.rs b/tests/merlin_interop.rs index 084a7c27..d341cb28 100644 --- a/tests/merlin_interop.rs +++ b/tests/merlin_interop.rs @@ -13,8 +13,8 @@ use xml_sec::xmldsig::{ UriTypeSet, VerificationKey, VerifyContext, X509ChainError, XPathHereSemantics, }; -const MERLIN: &str = "donors/xmlsec/tests/merlin-xmldsig-twenty-three"; -const DONOR_EXTERNAL: &str = "donors/xmlsec/tests/external-data"; +const MERLIN: &str = "tests/fixtures/xmldsig/merlin-xmldsig-twenty-three"; +const DONOR_EXTERNAL: &str = "tests/fixtures/xmldsig/external-data"; const VERIFY_2005: u64 = 1_104_580_800; fn root() -> PathBuf { @@ -112,12 +112,22 @@ fn verifies_all_merlin_documents_with_upstream_expectations() { ); let hmac = HmacSha1VerificationKey::new(b"secret".to_vec()).expect("valid HMAC key"); - for name in [ + assert_valid( "signature-enveloping-hmac-sha1", + VerifyContext::new() + .key(&hmac) + .verify(&xml("signature-enveloping-hmac-sha1")), + ); + let truncated_hmac = HmacSha1VerificationKey::new(b"secret".to_vec()) + .expect("valid HMAC key") + .with_output_length_bits(80) + .expect("valid XMLDSig truncation"); + assert_valid( "signature-enveloping-hmac-sha1-40", - ] { - assert_valid(name, VerifyContext::new().key(&hmac).verify(&xml(name))); - } + VerifyContext::new() + .key(&truncated_hmac) + .verify(&xml("signature-enveloping-hmac-sha1-40")), + ); let resources = external_resources(); for name in ["signature-external-dsa", "signature-external-b64-dsa"] { @@ -320,26 +330,30 @@ fn bounds_external_resources_before_dereference() { let default = DefaultKeyResolver::default(); let mut oversized = external_resources(); oversized.insert("urn:oversized".into(), vec![0; 8 * 1024 * 1024 + 1]); - assert!( + assert!(matches!( VerifyContext::new() .key_resolver(&default) .allowed_uri_types(UriTypeSet::ALL) .external_resources(&oversized) - .verify(&xml("signature-external-dsa")) - .is_err() - ); + .verify(&xml("signature-external-dsa")), + Err(DsigError::InvalidStructure { + reason: "external resource exceeds maximum allowed length" + }) + )); - let aggregate = (0..5) - .map(|index| (format!("urn:aggregate:{index}"), vec![0; 7 * 1024 * 1024])) - .collect(); - assert!( + let mut aggregate = external_resources(); + aggregate + .extend((0..5).map(|index| (format!("urn:aggregate:{index}"), vec![0; 7 * 1024 * 1024]))); + assert!(matches!( VerifyContext::new() .key_resolver(&default) .allowed_uri_types(UriTypeSet::ALL) .external_resources(&aggregate) - .verify(&xml("signature-external-dsa")) - .is_err() - ); + .verify(&xml("signature-external-dsa")), + Err(DsigError::InvalidStructure { + reason: "external resources exceed maximum aggregate length" + }) + )); } #[test] @@ -405,12 +419,21 @@ fn rejects_missing_ambiguous_and_weak_key_resolution() { "", 1, ); + let ambiguous_error = VerifyContext::new() + .key_resolver(&DefaultKeyResolver::default()) + .allow_internal_dtd(true) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&ambiguous) + .expect_err("duplicate ID must fail before key resolution"); assert!( - VerifyContext::new() - .key_resolver(&DefaultKeyResolver::default()) - .allow_internal_dtd(true) - .verify(&ambiguous) - .is_err() + matches!( + ambiguous_error, + DsigError::InvalidStructure { + reason: "X509Data RetrievalMethod target is missing or ambiguous" + } + ), + "unexpected duplicate-ID error: {ambiguous_error:?}" ); let weak = VerifyContext::new() @@ -425,27 +448,29 @@ fn rejects_missing_ambiguous_and_weak_key_resolution() { #[test] fn rejects_dtd_and_unsupported_retrieval_defaults() { // Internal DTD parsing and RetrievalMethod transform compatibility require exact opt-ins. - assert!( + assert!(matches!( VerifyContext::new() .key_resolver(&DefaultKeyResolver::default()) - .verify(&xml("signature")) - .is_err() - ); + .verify(&xml("signature")), + Err(DsigError::XmlParse(_)) + )); let unsupported = xml("signature").replacen( "ancestor-or-self::dsig:X509Data", "descendant-or-self::dsig:X509Data", 1, ); - assert!( + let resources = external_resources(); + assert!(matches!( VerifyContext::new() .key_resolver(&DefaultKeyResolver::default()) .allow_internal_dtd(true) - .verify(&unsupported) - .is_err() - ); + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&unsupported), + Err(DsigError::ParseKeyInfo(_)) + )); - let resources = external_resources(); let retrieval = DefaultKeyResolver::new(KeyResolverConfig { trusted_certs: vec![cert("ca.pem"), cert("balor.pem")], verify_chains: true, From e0ac4b2ccfc1375d698eca16ea1161dea3e6093b Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 5 Aug 2026 12:13:57 +0300 Subject: [PATCH 03/26] fix(xmldsig): harden retrieval methods - Preserve KeyInfo source order and bound X.509 materialization - Parse complete simple-content text across XML node splits - Separate reference and key-retrieval URI policies - Normalize misleading Merlin donor artifacts reproducibly --- docs/xmldsig.md | 8 +- scripts/import-donor-fixtures.sh | 22 ++ src/xmldsig/parse.rs | 127 +++++-- src/xmldsig/verify.rs | 353 +++++++++++++++--- .../merlin-xmldsig-twenty-three/Readme.txt | 63 ---- ...=> signature-enveloping-hmac-sha1-80.tmpl} | 0 ... => signature-enveloping-hmac-sha1-80.xml} | 0 tests/fixtures_smoke.rs | 18 +- tests/merlin_interop.rs | 56 ++- 9 files changed, 485 insertions(+), 162 deletions(-) delete mode 100644 tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/Readme.txt rename tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/{signature-enveloping-hmac-sha1-40.tmpl => signature-enveloping-hmac-sha1-80.tmpl} (100%) rename tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/{signature-enveloping-hmac-sha1-40.xml => signature-enveloping-hmac-sha1-80.xml} (100%) diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 3f24d1c9..53943670 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -50,9 +50,11 @@ after either outcome. External references are disabled by default. Callers must both allow their URI class with `UriTypeSet` and provide every payload through `VerifyContext::external_resources`; verification never performs network or filesystem I/O. Individual resources are limited to 8 MiB and the -complete map to 32 MiB. `RetrievalMethod` currently accepts untransformed external -`rawX509Certificate` data and the Merlin same-document `X509Data` XPath selection. Other retrieval -transform chains fail closed instead of being ignored. +complete map to 32 MiB. External key retrieval has an independent policy boundary: callers must +also opt in with `VerifyContext::allowed_retrieval_method_uri_types`. Allowing external signed +payloads never implicitly allows external key material. `RetrievalMethod` currently accepts +untransformed external `rawX509Certificate` data and the Merlin same-document `X509Data` XPath +selection. Other retrieval transform chains fail closed instead of being ignored. Internal DTD declarations are disabled by default and require `VerifyContext::allow_internal_dtd(true)`. External entity resolution remains disabled. XSLT is diff --git a/scripts/import-donor-fixtures.sh b/scripts/import-donor-fixtures.sh index 9359ca14..a8492d36 100755 --- a/scripts/import-donor-fixtures.sh +++ b/scripts/import-donor-fixtures.sh @@ -35,6 +35,27 @@ replace_target() { return 1 } +normalize_imported_snapshot() { + local relative_path="$1" + local staging="$2" + + if [[ "$relative_path" == "xmldsig/merlin-xmldsig-twenty-three" ]]; then + # The donor README contains unresolved placeholders and is not executable + # fixture data. Keep the imported corpus curated rather than publishing + # upstream prose as project documentation. + rm -f "$staging/Readme.txt" + + # xmlsec 1.3.12's historical "-40" filenames contain an 80-bit HMAC, + # matching XMLDSig 1.1's security floor. Normalize only the local names; + # file contents remain byte-for-byte donor data. + for extension in tmpl xml; do + mv \ + "$staging/signature-enveloping-hmac-sha1-40.$extension" \ + "$staging/signature-enveloping-hmac-sha1-80.$extension" + done + fi +} + fixture_paths=("$@") if (( ${#fixture_paths[@]} == 0 )); then fixture_paths=( @@ -100,6 +121,7 @@ for relative_path in "${fixture_paths[@]}"; do rm -rf "$staging" exit 1 fi + normalize_imported_snapshot "$relative_path" "$staging" replace_target "$staging" "$target" else target_parent="$(dirname "$target")" diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index 3882c460..63c1525c 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -37,6 +37,9 @@ const MAX_DER_ENCODED_KEY_VALUE_LEN: usize = 8192; const MAX_DER_ENCODED_KEY_VALUE_TEXT_LEN: usize = 65_536; const MAX_DER_ENCODED_KEY_VALUE_BASE64_LEN: usize = MAX_DER_ENCODED_KEY_VALUE_LEN.div_ceil(3) * 4; const MAX_KEY_NAME_TEXT_LEN: usize = 4096; +const MAX_KEY_INFO_CHILD_COUNT: usize = 64; +const MAX_HMAC_OUTPUT_LENGTH_TEXT_LEN: usize = 32; +const MAX_RETRIEVAL_XPATH_TEXT_LEN: usize = 256; const MAX_RSA_MODULUS_LEN: usize = 1024; const MAX_RSA_EXPONENT_LEN: usize = 8; pub(crate) const EC_P256_OID: &str = "1.2.840.10045.3.1.7"; @@ -44,12 +47,13 @@ pub(crate) const EC_P384_OID: &str = "1.3.132.0.34"; const MAX_EC_PUBLIC_KEY_LEN: usize = 97; const MAX_X509_BASE64_TEXT_LEN: usize = 262_144; const MAX_X509_BASE64_NORMALIZED_LEN: usize = MAX_X509_BASE64_TEXT_LEN; -const MAX_X509_DECODED_BINARY_LEN: usize = MAX_X509_BASE64_NORMALIZED_LEN.div_ceil(4) * 3; +pub(crate) const MAX_X509_DECODED_BINARY_LEN: usize = + MAX_X509_BASE64_NORMALIZED_LEN.div_ceil(4) * 3; const MAX_X509_SUBJECT_NAME_TEXT_LEN: usize = 16_384; const MAX_X509_ISSUER_NAME_TEXT_LEN: usize = 16_384; const MAX_X509_SERIAL_NUMBER_TEXT_LEN: usize = 4096; const MAX_X509_DATA_ENTRY_COUNT: usize = 64; -const MAX_X509_DATA_TOTAL_BINARY_LEN: usize = 1_048_576; +pub(crate) const MAX_X509_DATA_TOTAL_BINARY_LEN: usize = 1_048_576; const MAX_X509_CHAIN_DEPTH: usize = 9; pub(crate) const MAX_REFERENCES_PER_SIGNATURE: usize = 64; @@ -180,13 +184,13 @@ pub enum KeyInfoSource { } /// Transform forms accepted on ``. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum RetrievalMethodTransforms { /// No transform chain is present. None, - /// Select the `ds:X509Data` ancestor-or-self node from a same-document object. - X509DataAncestor, + /// Filter a same-document node-set to one `ds:X509Data`-rooted subtree. + X509DataNodeSetFilter, } /// Parsed `` dispatch result. @@ -454,9 +458,9 @@ fn parse_hmac_output_length( )); } ensure_no_element_children(child, "HMACOutputLength")?; - let bits = child - .text() - .unwrap_or_default() + let text = + collect_text_content_bounded(child, MAX_HMAC_OUTPUT_LENGTH_TEXT_LEN, "HMACOutputLength")?; + let bits = text .trim() .parse::() .map_err(|_| ParseError::InvalidStructure("invalid HMACOutputLength".into()))?; @@ -589,7 +593,13 @@ pub fn parse_key_info(key_info_node: Node) -> Result { ensure_no_non_whitespace_text(key_info_node, "KeyInfo")?; let mut sources = Vec::new(); - for child in element_children(key_info_node) { + let mut x509_total_binary_len = 0usize; + for (index, child) in element_children(key_info_node).enumerate() { + if index >= MAX_KEY_INFO_CHILD_COUNT { + return Err(ParseError::InvalidStructure( + "KeyInfo contains too many child elements".into(), + )); + } match (child.tag_name().namespace(), child.tag_name().name()) { (Some(XMLDSIG_NS), "KeyName") => { ensure_no_element_children(child, "KeyName")?; @@ -602,7 +612,7 @@ pub fn parse_key_info(key_info_node: Node) -> Result { sources.push(KeyInfoSource::KeyValue(key_value)); } (Some(XMLDSIG_NS), "X509Data") => { - let x509 = parse_x509_data_dispatch(child)?; + let x509 = parse_x509_data_dispatch_with_budget(child, &mut x509_total_binary_len)?; sources.push(KeyInfoSource::X509Data(x509)); } (Some(XMLDSIG_NS), "RetrievalMethod") => { @@ -676,7 +686,10 @@ fn parse_retrieval_method_transforms( "unsupported RetrievalMethod transform chain".into(), )); } - let expression = xpath.text().unwrap_or_default().trim(); + ensure_no_element_children(xpath, "XPath")?; + let expression = + collect_text_content_bounded(xpath, MAX_RETRIEVAL_XPATH_TEXT_LEN, "RetrievalMethod XPath")?; + let expression = expression.trim(); let selects_x509_data = expression .strip_prefix("ancestor-or-self::") .and_then(|step| step.split_once(':')) @@ -688,8 +701,7 @@ fn parse_retrieval_method_transforms( "unsupported RetrievalMethod XPath selection".into(), )); } - ensure_no_element_children(xpath, "XPath")?; - Ok(RetrievalMethodTransforms::X509DataAncestor) + Ok(RetrievalMethodTransforms::X509DataNodeSetFilter) } // ── Helpers ────────────────────────────────────────────────────────────────── @@ -1029,19 +1041,21 @@ fn decode_crypto_binary( Ok(value) } -pub(crate) fn parse_x509_data_dispatch(node: Node) -> Result { +pub(crate) fn parse_x509_data_dispatch_with_budget( + node: Node, + total_binary_len: &mut usize, +) -> Result { verify_ds_element(node, "X509Data")?; ensure_no_non_whitespace_text(node, "X509Data")?; let mut info = X509DataInfo::default(); - let mut total_binary_len = 0usize; for child in element_children(node) { match (child.tag_name().namespace(), child.tag_name().name()) { (Some(XMLDSIG_NS), "X509Certificate") => { ensure_no_element_children(child, "X509Certificate")?; ensure_x509_data_entry_budget(&info)?; let cert = decode_x509_base64(child, "X509Certificate")?; - add_x509_data_usage(&mut total_binary_len, cert.len())?; + add_x509_data_usage(total_binary_len, cert.len())?; let parsed_cert = parse_x509_certificate(cert.as_slice())?; info.parsed_certificates.push(parsed_cert); info.certificates.push(cert); @@ -1065,14 +1079,14 @@ pub(crate) fn parse_x509_data_dispatch(node: Node) -> Result { ensure_no_element_children(child, "X509CRL")?; ensure_x509_data_entry_budget(&info)?; let crl = decode_x509_base64(child, "X509CRL")?; - add_x509_data_usage(&mut total_binary_len, crl.len())?; + add_x509_data_usage(total_binary_len, crl.len())?; info.crls.push(crl); } (Some(XMLDSIG11_NS), "X509Digest") => { @@ -1080,7 +1094,7 @@ pub(crate) fn parse_x509_data_dispatch(node: Node) -> Result { @@ -2952,7 +2966,7 @@ BA== [KeyInfoSource::RetrievalMethod { uri, resource_type: Some(resource_type), - transforms: RetrievalMethodTransforms::X509DataAncestor, + transforms: RetrievalMethodTransforms::X509DataNodeSetFilter, }] if uri == "#keys" && resource_type == "http://www.w3.org/2000/09/xmldsig#X509Data" )); @@ -2975,12 +2989,36 @@ BA== .sources .as_slice(), [KeyInfoSource::RetrievalMethod { - transforms: RetrievalMethodTransforms::X509DataAncestor, + transforms: RetrievalMethodTransforms::X509DataNodeSetFilter, .. }] )); } + #[test] + fn parse_key_info_reads_complete_retrieval_xpath_text() { + // XML comments split character data into multiple text nodes; all chunks + // still belong to the XPath parameter's string-value. + let valid = r##" + + + ancestor-or-self::ds:X509Data + + + "##; + let document = Document::parse(valid).unwrap(); + assert!(parse_key_info(document.root_element()).is_ok()); + + let unsupported = + valid.replace("X509Data", "X509Data[false()]"); + let document = Document::parse(&unsupported).unwrap(); + assert!(matches!( + parse_key_info(document.root_element()), + Err(ParseError::InvalidStructure(reason)) + if reason == "unsupported RetrievalMethod XPath selection" + )); + } + #[test] fn parse_dsa_key_value_accepts_schema_optional_parameters_and_rejects_half_pair() { let key_info = |optional: &str| { @@ -3030,6 +3068,23 @@ BA== )); } + #[test] + fn parse_key_info_rejects_excessive_child_sources() { + // KeyInfo extensions are lax, but their parse work remains bounded. + let children = (0..=64) + .map(|index| format!(r#""#)) + .collect::(); + let xml = + format!(r#"{children}"#); + let document = Document::parse(&xml).unwrap(); + + assert!(matches!( + parse_key_info(document.root_element()), + Err(ParseError::InvalidStructure(reason)) + if reason == "KeyInfo contains too many child elements" + )); + } + #[test] fn parse_key_info_rejects_keyname_with_child_elements() { let xml = r#" @@ -3121,6 +3176,36 @@ BA== // ── parse_signed_info: happy path ──────────────────────────────── + #[test] + fn parse_hmac_output_length_reads_all_text_nodes() { + // A comment may split valid simple content without changing its value. + let xml = r#" + 80 + "#; + let document = Document::parse(xml).unwrap(); + + assert_eq!( + parse_hmac_output_length(document.root_element(), SignatureAlgorithm::HmacSha1) + .unwrap(), + Some(80) + ); + } + + #[test] + fn parse_hmac_output_length_rejects_hidden_suffix_text() { + // Reading only the first text node would misinterpret 800 bits as 80. + let xml = r#" + 800 + "#; + let document = Document::parse(xml).unwrap(); + + assert!(matches!( + parse_hmac_output_length(document.root_element(), SignatureAlgorithm::HmacSha1), + Err(ParseError::InvalidStructure(reason)) + if reason == "HMACOutputLength must be a byte-aligned value from 80 through 160" + )); + } + #[test] fn parse_signed_info_rsa_sha256_with_reference() { let xml = r#" diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index 5c1f0a09..f4c0bc4e 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -18,12 +18,13 @@ use crate::c14n::canonicalize; use super::digest::{DigestAlgorithm, compute_digest, constant_time_eq}; use super::parse::{ - KeyInfo, MAX_REFERENCES_PER_SIGNATURE, ParseError, Reference, RetrievalMethodTransforms, + KeyInfo, MAX_REFERENCES_PER_SIGNATURE, MAX_X509_DATA_TOTAL_BINARY_LEN, + MAX_X509_DECODED_BINARY_LEN, ParseError, Reference, RetrievalMethodTransforms, SignatureAlgorithm, XMLDSIG_NS, }; use super::parse::{ parse_key_info, parse_reference_with_xpath_budget, parse_signed_info_with_xpath_budget, - parse_x509_certificate, parse_x509_data_dispatch, reference_digest_method, + parse_x509_certificate, parse_x509_data_dispatch_with_budget, reference_digest_method, }; use super::signature::{ SignatureVerificationError, verify_dsa_signature_spki, verify_ecdsa_signature_pem, @@ -41,6 +42,7 @@ const MAX_SIGNATURE_VALUE_LEN: usize = 8192; const MAX_SIGNATURE_VALUE_TEXT_LEN: usize = 65_536; const MAX_EXTERNAL_RESOURCE_LEN: usize = 8 * 1024 * 1024; const MAX_EXTERNAL_RESOURCE_TOTAL_LEN: usize = 32 * 1024 * 1024; +const MAX_RETRIEVAL_METHOD_COUNT: usize = 64; /// Cryptographic verifier used by [`VerifyContext`]. /// /// This trait intentionally has no `Send + Sync` supertraits so lightweight @@ -145,6 +147,7 @@ pub struct VerifyContext<'a> { key_resolver: Option<&'a dyn KeyResolver>, process_manifests: bool, allowed_uri_types: UriTypeSet, + allowed_retrieval_method_uri_types: UriTypeSet, allowed_transforms: Option>, store_pre_digest: bool, transform_options: TransformOptions, @@ -167,6 +170,7 @@ impl<'a> VerifyContext<'a> { key_resolver: None, process_manifests: false, allowed_uri_types: UriTypeSet::default(), + allowed_retrieval_method_uri_types: UriTypeSet::default(), allowed_transforms: None, store_pre_digest: false, transform_options: TransformOptions::default(), @@ -228,6 +232,17 @@ impl<'a> VerifyContext<'a> { self } + /// Restrict URI classes used to retrieve key material from ``. + /// + /// This policy is independent from [`Self::allowed_uri_types`]: allowing an + /// external signed payload does not implicitly allow external key retrieval. + /// Same-document retrieval is enabled by default; external retrieval requires + /// an explicit opt-in and still uses only caller-supplied resources. + pub fn allowed_retrieval_method_uri_types(mut self, types: UriTypeSet) -> Self { + self.allowed_retrieval_method_uri_types = types; + self + } + /// Provide external URI payloads explicitly. /// /// The map is the complete external I/O boundary: verification never @@ -817,7 +832,7 @@ fn verify_signature_with_context( info, &resolver, ctx.external_resources, - ctx.allowed_uri_types, + ctx.allowed_retrieval_method_uri_types, )?; } let execution_budget = TransformExecutionBudget::default(); @@ -931,19 +946,36 @@ fn materialize_retrieval_methods( external_resources: Option<&HashMap>>, allowed_uri_types: UriTypeSet, ) -> Result<(), SignatureVerificationPipelineError> { - let retrievals = key_info + let retrieval_count = key_info .sources .iter() - .filter_map(|source| match source { - super::parse::KeyInfoSource::RetrievalMethod { - uri, - resource_type, - transforms, - } => Some((uri.clone(), resource_type.clone(), *transforms)), - _ => None, - }) - .collect::>(); - for (uri, resource_type, transforms) in retrievals { + .filter(|source| matches!(source, super::parse::KeyInfoSource::RetrievalMethod { .. })) + .count(); + if retrieval_count > MAX_RETRIEVAL_METHOD_COUNT { + return Err(SignatureVerificationPipelineError::InvalidStructure { + reason: "KeyInfo contains too many RetrievalMethod elements", + }); + } + + let mut total_binary_len = existing_x509_binary_len(key_info)?; + let mut seen = HashSet::new(); + let mut materialized = Vec::with_capacity(key_info.sources.len()); + for source in std::mem::take(&mut key_info.sources) { + let super::parse::KeyInfoSource::RetrievalMethod { + uri, + resource_type, + transforms, + } = source + else { + materialized.push(source); + continue; + }; + + let identity = (uri.clone(), resource_type.clone(), transforms); + if !seen.insert(identity) { + continue; + } + if resource_type.as_deref() == Some("http://www.w3.org/2000/09/xmldsig#rawX509Certificate") { if !allowed_uri_types.allows(&uri) { @@ -963,9 +995,15 @@ fn materialize_retrieval_methods( )), ) })?; + if certificate.len() > MAX_X509_DECODED_BINARY_LEN { + return Err(SignatureVerificationPipelineError::InvalidStructure { + reason: "raw X509 RetrievalMethod certificate exceeds maximum allowed length", + }); + } + add_retrieval_binary_usage(&mut total_binary_len, certificate.len())?; let parsed = parse_x509_certificate(certificate) .map_err(SignatureVerificationPipelineError::ParseKeyInfo)?; - key_info.sources.push(super::parse::KeyInfoSource::X509Data( + materialized.push(super::parse::KeyInfoSource::X509Data( super::parse::X509DataInfo { certificates: vec![certificate.clone()], parsed_certificates: vec![parsed], @@ -977,7 +1015,7 @@ fn materialize_retrieval_methods( if !allowed_uri_types.allows(&uri) { return Err(SignatureVerificationPipelineError::DisallowedUri { uri }); } - if transforms != RetrievalMethodTransforms::X509DataAncestor { + if transforms != RetrievalMethodTransforms::X509DataNodeSetFilter { return Err(SignatureVerificationPipelineError::InvalidStructure { reason: "X509Data RetrievalMethod requires the supported XPath selection", }); @@ -992,42 +1030,83 @@ fn materialize_retrieval_methods( reason: "X509Data RetrievalMethod target is missing or ambiguous", }, )?; - let containing = target.ancestors().find(|candidate| { - candidate.is_element() - && candidate.tag_name().namespace() == Some(XMLDSIG_NS) - && candidate.tag_name().name() == "X509Data" - }); - let mut selected = target.descendants().filter(|candidate| { - candidate.is_element() - && candidate.tag_name().namespace() == Some(XMLDSIG_NS) - && candidate.tag_name().name() == "X509Data" - && Some(*candidate) != containing + let node = select_retrieved_x509_data_root(target)?; + let data = parse_x509_data_dispatch_with_budget(node, &mut total_binary_len) + .map_err(SignatureVerificationPipelineError::ParseKeyInfo)?; + materialized.push(super::parse::KeyInfoSource::X509Data(data)); + } else { + materialized.push(super::parse::KeyInfoSource::RetrievalMethod { + uri, + resource_type, + transforms, }); - let node = match (containing, selected.next()) { - (Some(node), None) | (None, Some(node)) => node, - (None, None) => { - return Err(SignatureVerificationPipelineError::InvalidStructure { - reason: "X509Data RetrievalMethod selected no X509Data element", - }); - } - (Some(_), Some(_)) => { - return Err(SignatureVerificationPipelineError::InvalidStructure { - reason: "X509Data RetrievalMethod selected multiple X509Data elements", - }); - } - }; - if selected.next().is_some() { - return Err(SignatureVerificationPipelineError::InvalidStructure { - reason: "X509Data RetrievalMethod selected multiple X509Data elements", - }); + } + } + key_info.sources = materialized; + Ok(()) +} + +fn select_retrieved_x509_data_root<'a, 'input>( + target: Node<'a, 'input>, +) -> Result, SignatureVerificationPipelineError> { + // XMLDSig XPath filtering evaluates the predicate for every node in the + // dereferenced node-set. `ancestor-or-self::ds:X509Data` therefore retains + // one X509Data descendant and its subtree; it cannot import an ancestor + // that was outside the URI target's node-set. + let mut roots = target.descendants().filter(|candidate| { + candidate.is_element() + && candidate.tag_name().namespace() == Some(XMLDSIG_NS) + && candidate.tag_name().name() == "X509Data" + }); + let root = roots + .next() + .ok_or(SignatureVerificationPipelineError::InvalidStructure { + reason: "X509Data RetrievalMethod selected no X509Data element", + })?; + if roots.next().is_some() { + return Err(SignatureVerificationPipelineError::InvalidStructure { + reason: "X509Data RetrievalMethod selected multiple X509Data elements", + }); + } + Ok(root) +} + +fn existing_x509_binary_len( + key_info: &KeyInfo, +) -> Result { + let mut total = 0usize; + for source in &key_info.sources { + if let super::parse::KeyInfoSource::X509Data(info) = source { + for len in info + .certificates + .iter() + .chain(&info.skis) + .chain(&info.crls) + .map(Vec::len) + .chain(info.digests.iter().map(|(_, digest)| digest.len())) + { + add_retrieval_binary_usage(&mut total, len)?; } - let data = parse_x509_data_dispatch(node) - .map_err(SignatureVerificationPipelineError::ParseKeyInfo)?; - key_info - .sources - .push(super::parse::KeyInfoSource::X509Data(data)); } } + Ok(total) +} + +fn add_retrieval_binary_usage( + total: &mut usize, + delta: usize, +) -> Result<(), SignatureVerificationPipelineError> { + *total = + total + .checked_add(delta) + .ok_or(SignatureVerificationPipelineError::InvalidStructure { + reason: "RetrievalMethod X509Data binary length overflow", + })?; + if *total > MAX_X509_DATA_TOTAL_BINARY_LEN { + return Err(SignatureVerificationPipelineError::InvalidStructure { + reason: "RetrievalMethod X509Data exceeds maximum aggregate binary length", + }); + } Ok(()) } @@ -2427,9 +2506,9 @@ mod tests { } #[test] - fn retrieval_method_materializes_containing_or_descendant_x509_data() { + fn retrieval_method_materializes_single_x509_data_subtree() { for target_xml in [ - r#"CN=leaf"#, + r#"CN=leaf"#, r#"CN=leaf"#, ] { let xml = format!( @@ -2449,15 +2528,179 @@ mod tests { None, UriTypeSet::SAME_DOCUMENT, ) - .expect("ancestor-or-self selection must accept either relation"); - assert!(key_info.sources.iter().any(|source| matches!( - source, - super::super::parse::KeyInfoSource::X509Data(info) + .expect("XPath filter must produce one X509Data-rooted node-set"); + assert!(matches!( + key_info.sources.as_slice(), + [super::super::parse::KeyInfoSource::X509Data(info)] if info.subject_names == ["CN=leaf"] - ))); + )); } } + #[test] + fn retrieval_method_rejects_target_inside_external_x509_data_ancestor() { + // XPath filtering cannot add an ancestor that was outside the URI's + // dereferenced node-set, so this result is not rooted at X509Data. + let xml = r##" + ancestor-or-self::ds:X509Data + CN=leaf + "##; + let document = Document::parse(xml).unwrap(); + let key_info_node = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) + .unwrap(); + let mut key_info = parse_key_info(key_info_node).unwrap(); + + let error = materialize_retrieval_methods( + &mut key_info, + &UriReferenceResolver::new(&document), + None, + UriTypeSet::SAME_DOCUMENT, + ) + .expect_err("filter output without an X509Data root must be rejected"); + assert!(matches!( + error, + SignatureVerificationPipelineError::InvalidStructure { + reason: "X509Data RetrievalMethod selected no X509Data element" + } + )); + } + + #[test] + fn retrieval_method_rejects_ambiguous_x509_data_relation() { + // A transformed result with multiple X509Data roots is not one KeyInfo child. + let xml = r##" + ancestor-or-self::ds:X509Data + + "##; + let document = Document::parse(xml).unwrap(); + let key_info_node = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) + .unwrap(); + let mut key_info = parse_key_info(key_info_node).unwrap(); + + let error = materialize_retrieval_methods( + &mut key_info, + &UriReferenceResolver::new(&document), + None, + UriTypeSet::SAME_DOCUMENT, + ) + .expect_err("multiple transformed X509Data roots must be rejected"); + assert!(matches!( + error, + SignatureVerificationPipelineError::InvalidStructure { + reason: "X509Data RetrievalMethod selected multiple X509Data elements" + } + )); + } + + #[test] + fn retrieval_method_materialization_preserves_key_info_order() { + // Replacing the source in place keeps a later fallback behind the + // retrieved key material for first-match resolvers. + let xml = r##" + + ancestor-or-self::ds:X509Data + fallback + + CN=leaf + "##; + let document = Document::parse(xml).unwrap(); + let key_info_node = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) + .unwrap(); + let mut key_info = parse_key_info(key_info_node).unwrap(); + + materialize_retrieval_methods( + &mut key_info, + &UriReferenceResolver::new(&document), + None, + UriTypeSet::SAME_DOCUMENT, + ) + .unwrap(); + assert!(matches!( + key_info.sources.as_slice(), + [ + super::super::parse::KeyInfoSource::X509Data(_), + super::super::parse::KeyInfoSource::KeyName(name) + ] if name == "fallback" + )); + } + + #[test] + fn retrieval_method_materialization_bounds_repeated_sources() { + // Repeating one allowed certificate must not multiply parsing and clones + // before SignatureValue validation. + const RAW_X509_TYPE: &str = "http://www.w3.org/2000/09/xmldsig#rawX509Certificate"; + let certificate = include_bytes!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/balor.der" + ) + .to_vec(); + let resources = HashMap::from([("urn:certificate".to_string(), certificate)]); + let mut key_info = KeyInfo { + sources: (0..=64) + .map(|_| super::super::parse::KeyInfoSource::RetrievalMethod { + uri: "urn:certificate".into(), + resource_type: Some(RAW_X509_TYPE.into()), + transforms: RetrievalMethodTransforms::None, + }) + .collect(), + }; + let document = Document::parse("").unwrap(); + + let error = materialize_retrieval_methods( + &mut key_info, + &UriReferenceResolver::new(&document), + Some(&resources), + UriTypeSet::ALL, + ) + .expect_err("retrieval count must be bounded before materialization"); + assert!(matches!( + error, + SignatureVerificationPipelineError::InvalidStructure { + reason: "KeyInfo contains too many RetrievalMethod elements" + } + )); + } + + #[test] + fn retrieval_method_materialization_deduplicates_within_count_limit() { + // Repeated references to the same raw certificate produce one parsed + // key source rather than one certificate clone per XML element. + const RAW_X509_TYPE: &str = "http://www.w3.org/2000/09/xmldsig#rawX509Certificate"; + let certificate = include_bytes!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/balor.der" + ) + .to_vec(); + let resources = HashMap::from([("urn:certificate".to_string(), certificate)]); + let mut key_info = KeyInfo { + sources: (0..MAX_RETRIEVAL_METHOD_COUNT) + .map(|_| super::super::parse::KeyInfoSource::RetrievalMethod { + uri: "urn:certificate".into(), + resource_type: Some(RAW_X509_TYPE.into()), + transforms: RetrievalMethodTransforms::None, + }) + .collect(), + }; + let document = Document::parse("").unwrap(); + + materialize_retrieval_methods( + &mut key_info, + &UriReferenceResolver::new(&document), + Some(&resources), + UriTypeSet::ALL, + ) + .unwrap(); + assert!(matches!( + key_info.sources.as_slice(), + [super::super::parse::KeyInfoSource::X509Data(info)] + if info.certificates.len() == 1 + )); + } + #[test] fn verify_context_does_not_hide_malformed_digest_behind_unsupported_transform() { // A bad DigestValue remains a parse error even when its transform URI is unsupported. diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/Readme.txt b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/Readme.txt deleted file mode 100644 index 37e9d88f..00000000 --- a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/Readme.txt +++ /dev/null @@ -1,63 +0,0 @@ -Sample XML Signatures[1][2] - -[1] http://www.w3.org/TR/2002/REC-xmldsig-core-20020212/ -[2] http://www.w3.org/TR/2001/REC-xml-c14n-20010315 - -1. A large and complex signature: - -This includes internal and external base 64, references of the forms -"", "#xpointer(/)", "#foo" and "#xpointer(id('foo'))" (with and -without comments), manifests, signature properties, simple xpath -with here(), xslt, retrieval method and odd interreferential -dependencies. - - signature.xml - A signature - signature.tmpl - The template from which the signature was created - signature-c14n-*.txt - All intermediate c14n output - -2. Some basic signatures: - -The key for the HMAC-SHA1 signatures is "secret".getBytes("ASCII") -which is, in hex, (73 65 63 72 65 74). No key info is provided for -these signatures. - - signature-enveloped-dsa.xml - signature-enveloping-b64-dsa.xml - signature-enveloping-dsa.xml - signature-enveloping-hmac-sha1-40.xml - signature-enveloping-hmac-sha1.xml - signature-enveloping-rsa.xml - signature-external-b64-dsa.xml - signature-external-dsa.xml - The signatures - signature-*-c14n-*.txt - The intermediate c14n output - -3. Varying key information: - -To resolve the key associated with the KeyName in `signature-keyname.xml' -you must perform a cunning transformation from the name `Xxx' to the -certificate that resides in the directory `certs/' that has a subject name -containing the common name `Xxx', which happens to be in the file -`certs/xxx.crt'. - -To resolve the key associated with the X509Data in `signature-x509-is.xml', -`signature-x509-ski.xml' and `signature-x509-sn.xml' you need to resolve -the identified certificate from those in the `certs' directory. - -In `signature-x509-crt-crl.xml' an X.509 CRL is present which has revoked -the X.509 certificate used for signing. So verification should be -qualified. - - signature-keyname.xml - signature-retrievalmethod-rawx509crt.xml - signature-x509-crt-crl.xml - signature-x509-crt.xml - signature-x509-is.xml - signature-x509-ski.xml - signature-x509-sn.xml - The signatures - certs/*.crt - The certificates - -Merlin Hughes -Baltimore Technologies, Ltd. -http://www.baltimore.com/ - -Thursday, April 4, 2002 diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1-40.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1-80.tmpl similarity index 100% rename from tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1-40.tmpl rename to tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1-80.tmpl diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1-40.xml b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1-80.xml similarity index 100% rename from tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1-40.xml rename to tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1-80.xml diff --git a/tests/fixtures_smoke.rs b/tests/fixtures_smoke.rs index 59cc8c93..3a4b852b 100644 --- a/tests/fixtures_smoke.rs +++ b/tests/fixtures_smoke.rs @@ -178,7 +178,7 @@ fn fixture_file_count_matches_expected() { let expected = [ ("keys", 24), ("c14n", 41), - ("xmldsig", 127), + ("xmldsig", 126), ("saml", 2), ("xmlenc", 482), ]; @@ -199,7 +199,7 @@ fn merlin_xmldsig_snapshot_contains_complete_interop_inputs() { let required = [ "xmldsig/merlin-xmldsig-twenty-three/signature.xml", "xmldsig/merlin-xmldsig-twenty-three/signature-external-dsa.xml", - "xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1-40.xml", + "xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1-80.xml", "xmldsig/merlin-xmldsig-twenty-three/signature-retrievalmethod-rawx509crt.xml", "xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt-crl.xml", "xmldsig/merlin-xmldsig-twenty-three/certs/ca.pem", @@ -214,6 +214,20 @@ fn merlin_xmldsig_snapshot_contains_complete_interop_inputs() { } } +#[test] +fn merlin_xmldsig_snapshot_normalizes_non_fixture_donor_artifacts() { + // The importer removes stale donor prose and gives the historical `-40` + // vector a local name matching its actual XMLDSig-compliant 80-bit output. + let dir = fixtures_dir().join("xmldsig/merlin-xmldsig-twenty-three"); + assert!(!dir.join("Readme.txt").exists()); + assert!(!dir.join("signature-enveloping-hmac-sha1-40.xml").exists()); + assert!(!dir.join("signature-enveloping-hmac-sha1-40.tmpl").exists()); + + let fixture = fs::read_to_string(dir.join("signature-enveloping-hmac-sha1-80.xml")) + .expect("normalized Merlin HMAC fixture must be readable"); + assert!(fixture.contains("80")); +} + // ─── Helpers ──────────────────────────────────────────────────────────────── /// Assert that a file exists and contains the expected PEM header marker. diff --git a/tests/merlin_interop.rs b/tests/merlin_interop.rs index d341cb28..6bb3c7a9 100644 --- a/tests/merlin_interop.rs +++ b/tests/merlin_interop.rs @@ -9,8 +9,9 @@ use std::{ use x509_parser::prelude::{FromDer, X509Certificate}; use xml_sec::xmldsig::{ DefaultKeyResolver, DsigError, DsigStatus, FailureReason, HmacSha1VerificationKey, - KeyResolutionError, KeyResolverConfig, SignatureAlgorithm, SignatureVerificationError, - UriTypeSet, VerificationKey, VerifyContext, X509ChainError, XPathHereSemantics, + KeyResolutionError, KeyResolverConfig, ParseError, SignatureAlgorithm, + SignatureVerificationError, UriTypeSet, VerificationKey, VerifyContext, X509ChainError, + XPathHereSemantics, }; const MERLIN: &str = "tests/fixtures/xmldsig/merlin-xmldsig-twenty-three"; @@ -123,10 +124,10 @@ fn verifies_all_merlin_documents_with_upstream_expectations() { .with_output_length_bits(80) .expect("valid XMLDSig truncation"); assert_valid( - "signature-enveloping-hmac-sha1-40", + "signature-enveloping-hmac-sha1-80", VerifyContext::new() .key(&truncated_hmac) - .verify(&xml("signature-enveloping-hmac-sha1-40")), + .verify(&xml("signature-enveloping-hmac-sha1-80")), ); let resources = external_resources(); @@ -193,6 +194,7 @@ fn verifies_all_merlin_documents_with_upstream_expectations() { VerifyContext::new() .key_resolver(&retrieval) .allowed_uri_types(UriTypeSet::ALL) + .allowed_retrieval_method_uri_types(UriTypeSet::ALL) .external_resources(&resources) .verify(&xml("signature-retrievalmethod-rawx509crt")), ); @@ -366,7 +368,7 @@ fn rejects_wrong_hmac_key_and_invalid_output_length() { .expect("wrong MAC is a validation result"); assert_ne!(result.status, DsigStatus::Valid); - let malformed = xml("signature-enveloping-hmac-sha1-40").replacen( + let malformed = xml("signature-enveloping-hmac-sha1-80").replacen( "80", "72", 1, @@ -374,7 +376,7 @@ fn rejects_wrong_hmac_key_and_invalid_output_length() { assert!(malformed.contains("72")); assert!(VerifyContext::new().key(&wrong).verify(&malformed).is_err()); - let implicit_full_length = xml("signature-enveloping-hmac-sha1-40").replacen( + let implicit_full_length = xml("signature-enveloping-hmac-sha1-80").replacen( "80", "", 1, @@ -461,14 +463,17 @@ fn rejects_dtd_and_unsupported_retrieval_defaults() { 1, ); let resources = external_resources(); + let unsupported_error = VerifyContext::new() + .key_resolver(&DefaultKeyResolver::default()) + .allow_internal_dtd(true) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&unsupported) + .expect_err("unsupported RetrievalMethod XPath must fail closed"); assert!(matches!( - VerifyContext::new() - .key_resolver(&DefaultKeyResolver::default()) - .allow_internal_dtd(true) - .allowed_uri_types(UriTypeSet::ALL) - .external_resources(&resources) - .verify(&unsupported), - Err(DsigError::ParseKeyInfo(_)) + unsupported_error, + DsigError::ParseKeyInfo(ParseError::InvalidStructure(reason)) + if reason == "unsupported RetrievalMethod XPath selection" )); let retrieval = DefaultKeyResolver::new(KeyResolverConfig { @@ -477,11 +482,26 @@ fn rejects_dtd_and_unsupported_retrieval_defaults() { verification_time: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(VERIFY_2005)), ..KeyResolverConfig::default() }); + let reference_error = VerifyContext::new() + .key_resolver(&retrieval) + .external_resources(&resources) + .verify(&xml("signature-retrievalmethod-rawx509crt")) + .expect_err("external SignedInfo reference must require an explicit opt-in"); assert!(matches!( - VerifyContext::new() - .key_resolver(&retrieval) - .external_resources(&resources) - .verify(&xml("signature-retrievalmethod-rawx509crt")), - Err(DsigError::DisallowedUri { .. }) + reference_error, + DsigError::DisallowedUri { uri } + if uri == "http://www.w3.org/TR/xml-stylesheet" + )); + + let retrieval_error = VerifyContext::new() + .key_resolver(&retrieval) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&xml("signature-retrievalmethod-rawx509crt")) + .expect_err("external key retrieval must require its own explicit opt-in"); + assert!(matches!( + retrieval_error, + DsigError::DisallowedUri { uri } + if uri == "tests/merlin-xmldsig-twenty-three/certs/balor.der" )); } From 8fd4a488d9115a0a742ec07c2cad71b19d8a951f Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 5 Aug 2026 12:46:47 +0300 Subject: [PATCH 04/26] fix(xmldsig): harden key retrieval - Require external URIs for raw X509 retrieval - Preserve DSA fallback during rollover validation - Fail donor fixture normalization without partial installs --- scripts/import-donor-fixtures.sh | 19 +++++++-- src/xmldsig/verify.rs | 71 +++++++++++++++++++++++++++----- src/xmldsig/x509.rs | 40 +++++++++++++++++- 3 files changed, 115 insertions(+), 15 deletions(-) diff --git a/scripts/import-donor-fixtures.sh b/scripts/import-donor-fixtures.sh index a8492d36..675a9383 100755 --- a/scripts/import-donor-fixtures.sh +++ b/scripts/import-donor-fixtures.sh @@ -38,6 +38,7 @@ replace_target() { normalize_imported_snapshot() { local relative_path="$1" local staging="$2" + local donor if [[ "$relative_path" == "xmldsig/merlin-xmldsig-twenty-three" ]]; then # The donor README contains unresolved placeholders and is not executable @@ -49,9 +50,16 @@ normalize_imported_snapshot() { # matching XMLDSig 1.1's security floor. Normalize only the local names; # file contents remain byte-for-byte donor data. for extension in tmpl xml; do - mv \ - "$staging/signature-enveloping-hmac-sha1-40.$extension" \ - "$staging/signature-enveloping-hmac-sha1-80.$extension" + donor="$staging/signature-enveloping-hmac-sha1-40.$extension" + if [[ ! -f "$donor" ]]; then + printf 'donor snapshot no longer provides %s; update normalize_imported_snapshot\n' \ + "${donor##*/}" >&2 + return 1 + fi + if ! mv "$donor" "$staging/signature-enveloping-hmac-sha1-80.$extension"; then + printf 'failed to normalize donor fixture: %s\n' "${donor##*/}" >&2 + return 1 + fi done fi } @@ -121,7 +129,10 @@ for relative_path in "${fixture_paths[@]}"; do rm -rf "$staging" exit 1 fi - normalize_imported_snapshot "$relative_path" "$staging" + if ! normalize_imported_snapshot "$relative_path" "$staging"; then + rm -rf "$staging" + exit 1 + fi replace_target "$staging" "$target" else target_parent="$(dirname "$target")" diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index f4c0bc4e..03cd7d24 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -97,6 +97,23 @@ pub struct UriTypeSet { allow_external: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UriClass { + Empty, + SameDocument, + External, +} + +fn classify_uri(uri: &str) -> UriClass { + if uri.is_empty() { + UriClass::Empty + } else if uri.starts_with('#') { + UriClass::SameDocument + } else { + UriClass::External + } +} + impl UriTypeSet { /// Create a custom URI policy. pub const fn new(allow_empty: bool, allow_same_document: bool, allow_external: bool) -> Self { @@ -124,13 +141,11 @@ impl UriTypeSet { }; fn allows(self, uri: &str) -> bool { - if uri.is_empty() { - return self.allow_empty; - } - if uri.starts_with('#') { - return self.allow_same_document; + match classify_uri(uri) { + UriClass::Empty => self.allow_empty, + UriClass::SameDocument => self.allow_same_document, + UriClass::External => self.allow_external, } - self.allow_external } } @@ -978,14 +993,16 @@ fn materialize_retrieval_methods( if resource_type.as_deref() == Some("http://www.w3.org/2000/09/xmldsig#rawX509Certificate") { - if !allowed_uri_types.allows(&uri) { - return Err(SignatureVerificationPipelineError::DisallowedUri { uri }); - } - if transforms != RetrievalMethodTransforms::None || uri.starts_with('#') { + if transforms != RetrievalMethodTransforms::None + || classify_uri(&uri) != UriClass::External + { return Err(SignatureVerificationPipelineError::InvalidStructure { reason: "raw X509 RetrievalMethod requires an untransformed external URI", }); } + if !allowed_uri_types.allows(&uri) { + return Err(SignatureVerificationPipelineError::DisallowedUri { uri }); + } let certificate = external_resources .and_then(|resources| resources.get(&uri)) .ok_or_else(|| { @@ -2701,6 +2718,40 @@ mod tests { )); } + #[test] + fn raw_x509_retrieval_rejects_empty_same_document_uri() { + // rawX509Certificate consumes external DER octets; an empty URI denotes + // the XML document and must never become a key into the external map. + const RAW_X509_TYPE: &str = "http://www.w3.org/2000/09/xmldsig#rawX509Certificate"; + let certificate = include_bytes!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/balor.der" + ) + .to_vec(); + let resources = HashMap::from([(String::new(), certificate)]); + let mut key_info = KeyInfo { + sources: vec![super::super::parse::KeyInfoSource::RetrievalMethod { + uri: String::new(), + resource_type: Some(RAW_X509_TYPE.into()), + transforms: RetrievalMethodTransforms::None, + }], + }; + let document = Document::parse("").unwrap(); + + let error = materialize_retrieval_methods( + &mut key_info, + &UriReferenceResolver::new(&document), + Some(&resources), + UriTypeSet::ALL, + ) + .expect_err("empty URI must retain same-document semantics"); + assert!(matches!( + error, + SignatureVerificationPipelineError::InvalidStructure { + reason: "raw X509 RetrievalMethod requires an untransformed external URI" + } + )); + } + #[test] fn verify_context_does_not_hide_malformed_digest_behind_unsupported_transform() { // A bad DigestValue remains a parse error even when its transform URI is unsupported. diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index e57564f3..f6c0b7ba 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -121,9 +121,11 @@ pub fn verify_x509_certificate_chain( return validate_path(&path_der, info, options, verification_time); } + // Use the path-edge verifier here too: x509-parser does not verify legacy + // DSA-SHA1 roots, while our fallback must recognize them for rollover. let replace_untrusted_root = if path_der.len() > 1 && last.subject() == last.issuer() - && last.verify_signature(None).is_ok() + && verify_certificate_signature(&last, &last) { let child = parse_certificate(path_der[path_der.len() - 2])?; child.issuer() == last.subject() && verify_certificate_signature(&child, &last) @@ -394,6 +396,42 @@ mod tests { use super::*; use crate::xmldsig::{KeyInfoSource, parse::XMLDSIG_NS, parse_key_info}; use roxmltree::Document; + use std::time::Duration; + + #[test] + fn dsa_rollover_replaces_embedded_root_before_depth_validation() { + let leaf = include_bytes!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/balor.der" + ) + .to_vec(); + let embedded_root = + include_bytes!("../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/ca.der") + .to_vec(); + + // Trust-anchor self-signatures are not part of path validation. Changing + // only that signature gives this test a distinct rollover certificate + // with the same subject and DSA public key as the embedded stale root. + let mut rollover_anchor = embedded_root.clone(); + *rollover_anchor + .last_mut() + .expect("certificate is non-empty") ^= 1; + parse_certificate(&rollover_anchor).expect("modified trust anchor remains valid DER"); + let anchors = vec![rollover_anchor]; + let info = X509DataInfo { + certificates: vec![leaf, embedded_root], + certificate_chain: vec![0, 1], + ..X509DataInfo::default() + }; + let options = X509ChainOptions { + trusted_certs: &anchors, + verification_time: UNIX_EPOCH + Duration::from_secs(1_104_580_800), + max_chain_depth: 2, + check_crls: false, + }; + + verify_x509_certificate_chain(&info, &options) + .expect("the stale DSA root must be replaced by the configured anchor"); + } #[test] fn dsa_sha1_crl_signature_uses_the_same_fallback_as_certificates() { From 155520bf3f9ba570cf90d32c09b3e6da4a1ccf40 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 5 Aug 2026 13:44:45 +0300 Subject: [PATCH 05/26] fix(xmldsig): harden key source handling - accept schema-valid partial DSAKeyValue sources without aborting ordered fallback - share same-document ID parsing across retrieval and manifest paths - redact HMAC secret material from Debug output --- src/xmldsig/keys.rs | 44 ++++++++++++--- src/xmldsig/parse.rs | 121 ++++++++++++++++++++++------------------ src/xmldsig/uri.rs | 38 +++++++++++++ src/xmldsig/verify.rs | 75 ++++++++++++------------- tests/merlin_interop.rs | 19 +++++++ 5 files changed, 194 insertions(+), 103 deletions(-) diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index e70ab311..312c0eef 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -1,6 +1,6 @@ //! Configuration and key material for XMLDSig key resolution. -use std::{collections::HashMap, time::SystemTime}; +use std::{collections::HashMap, fmt, time::SystemTime}; use crypto_bigint::BoxedUint; use dsa::pkcs8::{DecodePublicKey as DsaDecodePublicKey, EncodePublicKey as DsaEncodePublicKey}; @@ -25,12 +25,21 @@ use super::{ }; /// Caller-owned HMAC-SHA1 verification key. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct HmacSha1VerificationKey { secret: Vec, output_len: usize, } +impl fmt::Debug for HmacSha1VerificationKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("HmacSha1VerificationKey") + .field("output_length_bits", &(self.output_len * 8)) + .finish_non_exhaustive() + } +} + impl HmacSha1VerificationKey { /// Construct a key from non-empty secret bytes. pub fn new(secret: impl Into>) -> Result { @@ -401,6 +410,9 @@ impl DefaultKeyResolver { if algorithm != SignatureAlgorithm::DsaSha1 { return Err(KeyResolutionError::AlgorithmMismatch); } + let (Some(p), Some(q), Some(g)) = (p.as_deref(), q.as_deref(), g.as_deref()) else { + return Err(KeyResolutionError::InvalidPublicKey); + }; dsa_key_value_to_spki_der(p, q, g, y)? } KeyValueInfo::Rsa { modulus, exponent } => { @@ -478,7 +490,7 @@ impl KeyResolver for DefaultKeyResolver { KeyInfoSource::KeyValue(key_value) => { match Self::resolve_key_value(key_value, algorithm) { Ok(resolved) => resolved, - Err(error) if ec_key_value_error_allows_fallback(key_value, &error) => { + Err(error) if key_value_error_allows_fallback(key_value, &error) => { deferred_key_value_error.get_or_insert(error); None } @@ -559,13 +571,10 @@ fn ec_key_value_to_spki_der( } } -fn ec_key_value_error_allows_fallback( - key_value: &KeyValueInfo, - error: &KeyResolutionError, -) -> bool { +fn key_value_error_allows_fallback(key_value: &KeyValueInfo, error: &KeyResolutionError) -> bool { matches!( key_value, - KeyValueInfo::Ec { .. } | KeyValueInfo::InvalidEcKeyValue + KeyValueInfo::Dsa { .. } | KeyValueInfo::Ec { .. } | KeyValueInfo::InvalidEcKeyValue ) && matches!( error, KeyResolutionError::InvalidPublicKey | KeyResolutionError::AlgorithmMismatch @@ -770,6 +779,25 @@ mod tests { )); } + #[test] + fn hmac_key_debug_redacts_secret_material() { + // Debug output may expose public verification parameters, never caller secrets. + let secret = b"unique-debug-secret-marker"; + let key = HmacSha1VerificationKey::new(secret.to_vec()) + .expect("the fixture HMAC secret is non-empty") + .with_output_length_bits(80) + .expect("80 bits is a valid HMAC-SHA1 output length"); + + let debug = format!("{key:?}"); + assert!( + !debug + .contains(std::str::from_utf8(secret).expect("the debug marker is literal ASCII")) + ); + assert!(!debug.contains(&format!("{secret:?}"))); + assert!(debug.contains("output_length_bits")); + assert!(debug.contains("80")); + } + #[test] fn stores_named_verification_key_metadata() { // Named resolution must retain every field needed by the later resolver wiring. diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index 63c1525c..fe914d0b 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -199,12 +199,12 @@ pub enum RetrievalMethodTransforms { pub enum KeyValueInfo { /// `` public parameters. Dsa { - /// Prime modulus P. - p: Vec, - /// Prime divisor Q. - q: Vec, - /// Generator G. - g: Vec, + /// Optional prime modulus P, present only together with Q. + p: Option>, + /// Optional prime divisor Q, present only together with P. + q: Option>, + /// Optional generator G. + g: Option>, /// Public value Y. y: Vec, }, @@ -823,49 +823,50 @@ fn parse_key_value_dispatch(node: Node) -> Result { fn parse_dsa_key_value(node: Node<'_, '_>) -> Result { verify_ds_element(node, "DSAKeyValue")?; ensure_no_non_whitespace_text(node, "DSAKeyValue")?; - let mut children = element_children(node); - let mut next = |name| -> Result, ParseError> { - let child = children - .next() - .ok_or_else(|| ParseError::InvalidStructure(format!("DSAKeyValue requires {name}")))?; - verify_ds_element(child, name)?; - ensure_no_element_children(child, name)?; - decode_crypto_binary(child, name, MAX_RSA_MODULUS_LEN) - }; - let p = next("P")?; - let q = next("Q")?; - let g = next("G")?; - let y = next("Y")?; - let optional = children.collect::>(); - let valid_optional = match optional.as_slice() { - [] => true, - [j] => is_ds_element(*j, "J"), - [seed, counter] => is_ds_element(*seed, "Seed") && is_ds_element(*counter, "PgenCounter"), - [j, seed, counter] => { - is_ds_element(*j, "J") - && is_ds_element(*seed, "Seed") - && is_ds_element(*counter, "PgenCounter") - } - _ => false, - }; - if !valid_optional { + let children = element_children(node).collect::>(); + let mut index = 0; + let p = take_dsa_crypto_binary(&children, &mut index, "P")?; + let q = take_dsa_crypto_binary(&children, &mut index, "Q")?; + if p.is_some() != q.is_some() { return Err(ParseError::InvalidStructure( - "DSAKeyValue optional children must be J and/or a Seed/PgenCounter pair".into(), + "DSAKeyValue P and Q must be present together".into(), )); } - for child in optional { - let name = match child.tag_name().name() { - "J" => "J", - "Seed" => "Seed", - "PgenCounter" => "PgenCounter", - _ => unreachable!("optional DSA child shape was validated above"), - }; - ensure_no_element_children(child, name)?; - decode_crypto_binary(child, name, MAX_RSA_MODULUS_LEN)?; + let g = take_dsa_crypto_binary(&children, &mut index, "G")?; + let y = take_dsa_crypto_binary(&children, &mut index, "Y")? + .ok_or_else(|| ParseError::InvalidStructure("DSAKeyValue requires Y".into()))?; + let _j = take_dsa_crypto_binary(&children, &mut index, "J")?; + let seed = take_dsa_crypto_binary(&children, &mut index, "Seed")?; + let counter = take_dsa_crypto_binary(&children, &mut index, "PgenCounter")?; + if seed.is_some() != counter.is_some() { + return Err(ParseError::InvalidStructure( + "DSAKeyValue Seed and PgenCounter must be present together".into(), + )); + } + if index != children.len() { + return Err(ParseError::InvalidStructure( + "DSAKeyValue children do not match the XMLDSig schema order".into(), + )); } Ok(KeyValueInfo::Dsa { p, q, g, y }) } +fn take_dsa_crypto_binary( + children: &[Node<'_, '_>], + index: &mut usize, + name: &'static str, +) -> Result>, ParseError> { + let Some(&child) = children.get(*index) else { + return Ok(None); + }; + if !is_ds_element(child, name) { + return Ok(None); + } + *index += 1; + ensure_no_element_children(child, name)?; + decode_crypto_binary(child, name, MAX_RSA_MODULUS_LEN).map(Some) +} + fn is_ds_element(node: Node<'_, '_>, name: &str) -> bool { node.tag_name().namespace() == Some(XMLDSIG_NS) && node.tag_name().name() == name } @@ -3021,19 +3022,22 @@ BA== #[test] fn parse_dsa_key_value_accepts_schema_optional_parameters_and_rejects_half_pair() { - let key_info = |optional: &str| { + let key_info = |parameters: &str| { format!( r#" -

AQ==

AQ==AQ==AQ=={optional} + {parameters}
"# ) }; - for optional in [ - "AQ==", - "AQ==AQ==", - "AQ==AQ==AQ==", + for parameters in [ + "AQ==", + "AQ==AQ==", + "

AQ==

AQ==AQ==", + "

AQ==

AQ==AQ==AQ==AQ==", + "AQ==AQ==AQ==", + "AQ==AQ==AQ==AQ==", ] { - let xml = key_info(optional); + let xml = key_info(parameters); let doc = Document::parse(&xml).unwrap(); assert!(matches!( parse_key_info(doc.root_element()) @@ -3044,12 +3048,19 @@ BA== )); } - let xml = key_info("AQ=="); - let doc = Document::parse(&xml).unwrap(); - assert!(matches!( - parse_key_info(doc.root_element()), - Err(ParseError::InvalidStructure(_)) - )); + for invalid_parameters in [ + "

AQ==

AQ==", + "AQ==AQ==", + "AQ==AQ==", + "AQ==AQ==", + ] { + let xml = key_info(invalid_parameters); + let doc = Document::parse(&xml).unwrap(); + assert!(matches!( + parse_key_info(doc.root_element()), + Err(ParseError::InvalidStructure(_)) + )); + } } #[test] diff --git a/src/xmldsig/uri.rs b/src/xmldsig/uri.rs index 7426eb67..795b66d3 100644 --- a/src/xmldsig/uri.rs +++ b/src/xmldsig/uri.rs @@ -293,6 +293,21 @@ pub(crate) fn parse_xpointer_id_fragment(fragment: &str) -> Option<&str> { } } +/// Extract the ID selected by a supported same-document URI. +/// +/// This keeps secondary consumers such as KeyInfo and Manifest processing in +/// lockstep with the resolver's bare-fragment and XPointer ID semantics. +pub(crate) fn same_document_reference_id(uri: &str) -> Option<&str> { + let fragment = uri.strip_prefix('#')?; + if fragment.is_empty() || fragment == "xpointer(/)" { + return None; + } + if let Some(id) = parse_xpointer_id_fragment(fragment) { + return (!id.is_empty()).then_some(id); + } + (!fragment.starts_with("xpointer(")).then_some(fragment) +} + #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { @@ -723,6 +738,29 @@ mod tests { ); } + #[test] + fn same_document_reference_id_rejects_non_id_fragments() { + assert_eq!(super::same_document_reference_id("#target"), Some("target")); + assert_eq!( + super::same_document_reference_id("#xpointer(id('target'))"), + Some("target") + ); + assert_eq!( + super::same_document_reference_id(r#"#xpointer(id("target"))"#), + Some("target") + ); + for uri in [ + "", + "target", + "#", + "#xpointer(/)", + "#xpointer(id(''))", + "#xpointer(id(target))", + ] { + assert_eq!(super::same_document_reference_id(uri), None, "{uri}"); + } + } + #[test] fn same_element_multiple_id_attrs_not_duplicate() { // An element with both ID="x" and Id="x" should NOT be treated as diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index 03cd7d24..13ce7099 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -35,7 +35,7 @@ use super::transforms::{ TransformOptions, XPATH_TRANSFORM_URI, XPathHereSemantics, XPathSignatureParseBudget, execute_transforms_with_options_and_budget, }; -use super::uri::{UriReferenceResolver, parse_xpointer_id_fragment}; +use super::uri::{UriReferenceResolver, same_document_reference_id}; use super::whitespace::{is_xml_whitespace_only, normalize_xml_base64_bytes}; const MAX_SIGNATURE_VALUE_LEN: usize = 8192; @@ -1037,7 +1037,7 @@ fn materialize_retrieval_methods( reason: "X509Data RetrievalMethod requires the supported XPath selection", }); } - let id = uri.strip_prefix('#').ok_or( + let id = same_document_reference_id(&uri).ok_or( SignatureVerificationPipelineError::InvalidStructure { reason: "X509Data RetrievalMethod requires a same-document URI", }, @@ -1337,7 +1337,7 @@ fn collect_authenticated_signed_info_reference_nodes( .all(transform_preserves_manifest_structure) }) .filter_map(|reference| reference.uri.as_deref()) - .filter_map(signed_info_reference_id_from_uri) + .filter_map(same_document_reference_id) .filter_map(|id| resolver.node_id_for_id(id)) .collect() } @@ -1357,17 +1357,6 @@ fn transform_preserves_manifest_structure(transform: &Transform) -> bool { } } -fn signed_info_reference_id_from_uri(uri: &str) -> Option<&str> { - let fragment = uri.strip_prefix('#')?; - if fragment.is_empty() || fragment == "xpointer(/)" { - return None; - } - if let Some(id) = parse_xpointer_id_fragment(fragment) { - return (!id.is_empty()).then_some(id); - } - (!fragment.starts_with("xpointer(")).then_some(fragment) -} - enum ResolvedVerifyingKey<'a> { Borrowed(&'a dyn VerifyingKey), Owned(Box), @@ -2524,33 +2513,39 @@ mod tests { #[test] fn retrieval_method_materializes_single_x509_data_subtree() { - for target_xml in [ - r#"CN=leaf"#, - r#"CN=leaf"#, + for uri in [ + "#target", + "#xpointer(id('target'))", + "#xpointer(id("target"))", ] { - let xml = format!( - r##"ancestor-or-self::ds:X509Data{target_xml}"## - ); - let document = Document::parse(&xml).unwrap(); - let key_info_node = document - .descendants() - .find(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) - .unwrap(); - let mut key_info = parse_key_info(key_info_node).unwrap(); - let resolver = UriReferenceResolver::new(&document); - - materialize_retrieval_methods( - &mut key_info, - &resolver, - None, - UriTypeSet::SAME_DOCUMENT, - ) - .expect("XPath filter must produce one X509Data-rooted node-set"); - assert!(matches!( - key_info.sources.as_slice(), - [super::super::parse::KeyInfoSource::X509Data(info)] - if info.subject_names == ["CN=leaf"] - )); + for target_xml in [ + r#"CN=leaf"#, + r#"CN=leaf"#, + ] { + let xml = format!( + r#"ancestor-or-self::ds:X509Data{target_xml}"# + ); + let document = Document::parse(&xml).unwrap(); + let key_info_node = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) + .unwrap(); + let mut key_info = parse_key_info(key_info_node).unwrap(); + let resolver = UriReferenceResolver::new(&document); + + materialize_retrieval_methods( + &mut key_info, + &resolver, + None, + UriTypeSet::SAME_DOCUMENT, + ) + .expect("XPath filter must produce one X509Data-rooted node-set"); + assert!(matches!( + key_info.sources.as_slice(), + [super::super::parse::KeyInfoSource::X509Data(info)] + if info.subject_names == ["CN=leaf"] + )); + } } } diff --git a/tests/merlin_interop.rs b/tests/merlin_interop.rs index 6bb3c7a9..76e8be60 100644 --- a/tests/merlin_interop.rs +++ b/tests/merlin_interop.rs @@ -402,6 +402,25 @@ fn rejects_malformed_dsa_key_value() { ); } +#[test] +fn partial_dsa_key_value_falls_back_to_later_complete_key() { + // XMLDSig permits Y-only DSAKeyValue sources; an unusable first source must + // not prevent a later complete DSAKeyValue from verifying the signature. + let document = xml("signature-enveloped-dsa").replacen( + "\n ", + "\n AQ==\n ", + 1, + ); + assert!(document.contains("AQ==")); + + assert_valid( + "partial DSAKeyValue fallback", + VerifyContext::new() + .key_resolver(&DefaultKeyResolver::default()) + .verify(&document), + ); +} + #[test] fn rejects_missing_ambiguous_and_weak_key_resolution() { // KeyName, RetrievalMethod IDs, and legacy RSA policy each fail closed. From 06f6749909fcb3380148384291fe96c651a073f6 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 5 Aug 2026 14:05:41 +0300 Subject: [PATCH 06/26] fix(xmldsig): track xmlsec1 1.3.13 - Pin the unreleased upstream snapshot by commit and checksum - Enforce RFC 5280 X.509 serial bounds and XML whitespace rules - Add SHA-256 X509Digest coverage and a verification fuzz target --- .github/workflows/ci.yml | 28 ++-- .gitignore | 5 + README.md | 2 +- fuzz/Cargo.toml | 22 +++ fuzz/corpus/xmldsig_verify/signature.xml | 1 + fuzz/fuzz_targets/xmldsig_verify.rs | 34 +++++ scripts/import-donor-fixtures.sh | 3 +- scripts/install-xmlsec1.sh | 78 ++++++++++ src/xmldsig/keys.rs | 15 +- src/xmldsig/parse.rs | 136 ++++++++++++++++-- tests/common/xmlsec1.rs | 31 ++++ tests/fixtures/xmldsig/README.md | 6 +- .../enveloped-x509-digest-sha256.xml | 46 ++++++ tests/fixtures/xmlenc/README.md | 8 +- tests/fixtures_smoke.rs | 2 +- tests/xmlenc_encrypt_xmlsec1.rs | 45 ++---- tests/xmlsec1_interop.rs | 75 ++++------ 17 files changed, 421 insertions(+), 116 deletions(-) create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/corpus/xmldsig_verify/signature.xml create mode 100644 fuzz/fuzz_targets/xmldsig_verify.rs create mode 100755 scripts/install-xmlsec1.sh create mode 100644 tests/common/xmlsec1.rs create mode 100644 tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloped-x509-digest-sha256.xml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 246bd5ed..d1329806 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,8 +8,9 @@ on: env: CARGO_TERM_COLOR: always RUSTFLAGS: -Dwarnings - XMLSEC1_VERSION: 1.3.12 - XMLSEC1_SHA256: 24045199af12d93fe5fdbbbf7e386e823e4842071e9432e2b90ac108b889a923 + XMLSEC1_PREFIX: ${{ github.workspace }}/.tools/xmlsec1-1.3.13-5fdd47dc3575 + XMLSEC1_BIN: ${{ github.workspace }}/.tools/xmlsec1-1.3.13-5fdd47dc3575/bin/xmlsec1 + LD_LIBRARY_PATH: ${{ github.workspace }}/.tools/xmlsec1-1.3.13-5fdd47dc3575/lib jobs: build-matrix: @@ -51,17 +52,9 @@ jobs: run: sudo apt-get update - name: Build pinned xmlsec1 for XMLDSig interop tests run: | - sudo apt-get install --yes build-essential libltdl-dev libssl-dev libxml2-dev pkg-config - curl --fail --location --retry 3 --output xmlsec1.tar.gz "https://github.com/lsh123/xmlsec/releases/download/${XMLSEC1_VERSION}/xmlsec1-${XMLSEC1_VERSION}.tar.gz" - echo "${XMLSEC1_SHA256} xmlsec1.tar.gz" | sha256sum --check --strict - tar --extract --file xmlsec1.tar.gz - pushd "xmlsec1-${XMLSEC1_VERSION}" - ./configure --disable-static --with-openssl - make --jobs "$(nproc)" - sudo make install - popd - sudo ldconfig - xmlsec1 --version + sudo apt-get install --yes autoconf automake build-essential libltdl-dev libssl-dev libtool libxml2-dev pkg-config + scripts/install-xmlsec1.sh + "$XMLSEC1_BIN" --version - uses: Swatinem/rust-cache@v2 - run: cargo nextest run --all-features - run: cargo test --doc --all-features @@ -91,3 +84,12 @@ jobs: with: components: rustfmt - run: cargo fmt --all -- --check + - run: cargo fmt --manifest-path fuzz/Cargo.toml -- --check + + fuzz-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@nightly + - run: cargo install cargo-fuzz --version 0.13.1 --locked + - run: cargo fuzz run xmldsig_verify -- -runs=256 -max_len=65536 diff --git a/.gitignore b/.gitignore index 500b0060..26bbf6f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ /target +/.tools +/fuzz/artifacts +/fuzz/corpus/*/* +!/fuzz/corpus/xmldsig_verify/signature.xml +/fuzz/target Cargo.lock *.swp *.swo diff --git a/README.md b/README.md index a452013e..8872d589 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Currently implemented (core paths): Still in progress: - XMLDSig DSA-SHA256, broader HMAC verification/signing, and RSA-PSS algorithms - Complete XMLDSig and XMLEnc conformance-suite classification -- Production hardening, fuzzing, benchmarks, and API stabilization +- Expanded fuzz coverage, benchmarks, production hardening, and API stabilization ## XMLDSig Usage diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 00000000..763e43d6 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "xml-sec-fuzz" +version = "0.0.0" +publish = false +edition = "2024" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4.13" +xml-sec = { path = "..", features = ["xmldsig"] } + +[[bin]] +name = "xmldsig_verify" +path = "fuzz_targets/xmldsig_verify.rs" +test = false +doc = false +bench = false + +[workspace] +members = ["."] diff --git a/fuzz/corpus/xmldsig_verify/signature.xml b/fuzz/corpus/xmldsig_verify/signature.xml new file mode 100644 index 00000000..797a6034 --- /dev/null +++ b/fuzz/corpus/xmldsig_verify/signature.xml @@ -0,0 +1 @@ + diff --git a/fuzz/fuzz_targets/xmldsig_verify.rs b/fuzz/fuzz_targets/xmldsig_verify.rs new file mode 100644 index 00000000..65cb34a9 --- /dev/null +++ b/fuzz/fuzz_targets/xmldsig_verify.rs @@ -0,0 +1,34 @@ +#![no_main] + +use std::sync::OnceLock; + +use libfuzzer_sys::fuzz_target; +use xml_sec::xmldsig::{DefaultKeyResolver, KeyResolverConfig, UriTypeSet, VerifyContext}; + +const TRUSTED_CERTIFICATE: &[u8] = + include_bytes!("../../tests/fixtures/xmldsig/phaos-xmldsig-three/certs/rsa-cert.der"); + +fn resolver() -> &'static DefaultKeyResolver { + static RESOLVER: OnceLock = OnceLock::new(); + RESOLVER.get_or_init(|| { + DefaultKeyResolver::new(KeyResolverConfig { + trusted_certs: vec![TRUSTED_CERTIFICATE.to_vec()], + ..KeyResolverConfig::default() + }) + }) +} + +fuzz_target!(|data: &[u8]| { + let Ok(xml) = std::str::from_utf8(data) else { + return; + }; + + // Match the upstream 1.3.13 verification harness: exercise parsing, + // transforms, digesting, signature verification, and X.509 lookup while + // keeping every reference and key retrieval strictly in-document. + let _ = VerifyContext::new() + .key_resolver(resolver()) + .allowed_uri_types(UriTypeSet::SAME_DOCUMENT) + .allowed_retrieval_method_uri_types(UriTypeSet::SAME_DOCUMENT) + .verify(xml); +}); diff --git a/scripts/import-donor-fixtures.sh b/scripts/import-donor-fixtures.sh index 675a9383..d591b2d9 100755 --- a/scripts/import-donor-fixtures.sh +++ b/scripts/import-donor-fixtures.sh @@ -46,7 +46,7 @@ normalize_imported_snapshot() { # upstream prose as project documentation. rm -f "$staging/Readme.txt" - # xmlsec 1.3.12's historical "-40" filenames contain an 80-bit HMAC, + # xmlsec 1.3.13's historical "-40" filenames contain an 80-bit HMAC, # matching XMLDSig 1.1's security floor. Normalize only the local names; # file contents remain byte-for-byte donor data. for extension in tmpl xml; do @@ -68,6 +68,7 @@ fixture_paths=("$@") if (( ${#fixture_paths[@]} == 0 )); then fixture_paths=( "xmldsig/aleksey-xmldsig-01/enveloping-rsa-x509chain.xml" + "xmldsig/aleksey-xmldsig-01/enveloped-x509-digest-sha256.xml" "xmldsig/merlin-xmldsig-twenty-three" "xmldsig/external-data/xml-stylesheet-2005" "xmldsig/external-data/xml-stylesheet-2005.b64" diff --git a/scripts/install-xmlsec1.sh b/scripts/install-xmlsec1.sh new file mode 100755 index 00000000..6dc20b2f --- /dev/null +++ b/scripts/install-xmlsec1.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly XMLSEC1_VERSION="1.3.13" +readonly XMLSEC1_COMMIT="5fdd47dc35753438bdc38b6e96c1a3805c67a483" +readonly XMLSEC1_ARCHIVE_SHA256="0917b7304ee2452e2110a60d18e501825c132fa5857558e0308d40457fa0992f" + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +prefix="${XMLSEC1_PREFIX:-$repo_root/.tools/xmlsec1-${XMLSEC1_VERSION}-${XMLSEC1_COMMIT:0:12}}" +marker="$prefix/.xmlsec-source-commit" + +if [[ "$prefix" != /* ]]; then + printf 'XMLSEC1_PREFIX must be an absolute path: %s\n' "$prefix" >&2 + exit 1 +fi + +if [[ -x "$prefix/bin/xmlsec1" && -f "$marker" ]] \ + && [[ "$(<"$marker")" == "$XMLSEC1_COMMIT" ]]; then + printf 'xmlsec1 %s is already installed at %s\n' "$XMLSEC1_VERSION" "$prefix" + exit 0 +fi + +work_dir="$(mktemp -d "${TMPDIR:-/tmp}/xmlsec1-${XMLSEC1_VERSION}.XXXXXX")" +trap 'rm -rf "$work_dir"' EXIT +archive="$work_dir/xmlsec.tar.gz" +source_dir="$work_dir/xmlsec-$XMLSEC1_COMMIT" +build_dir="$work_dir/build" +stage_dir="$work_dir/stage" + +curl --fail --location --retry 3 --output "$archive" \ + "https://codeload.github.com/lsh123/xmlsec/tar.gz/$XMLSEC1_COMMIT" + +if command -v sha256sum >/dev/null 2>&1; then + printf '%s %s\n' "$XMLSEC1_ARCHIVE_SHA256" "$archive" | sha256sum --check - +else + actual_sha256="$(shasum -a 256 "$archive" | awk '{print $1}')" + if [[ "$actual_sha256" != "$XMLSEC1_ARCHIVE_SHA256" ]]; then + printf 'xmlsec1 archive checksum mismatch: expected %s, got %s\n' \ + "$XMLSEC1_ARCHIVE_SHA256" "$actual_sha256" >&2 + exit 1 + fi +fi + +tar --extract --file "$archive" --directory "$work_dir" +mkdir -p "$build_dir" "$stage_dir" +OBJ_DIR="$build_dir" "$source_dir/autogen.sh" \ + --prefix="$prefix" \ + --disable-static \ + --without-gnutls \ + --without-nss \ + --with-openssl + +if command -v nproc >/dev/null 2>&1; then + build_jobs="$(nproc)" +else + build_jobs="$(sysctl -n hw.ncpu)" +fi +make --directory "$build_dir" --jobs "$build_jobs" +make --directory "$build_dir" install DESTDIR="$stage_dir" + +staged_prefix="$stage_dir$prefix" +mkdir -p "$(dirname "$prefix")" +if [[ -e "$prefix" ]]; then + mv "$prefix" "$work_dir/previous-install" +fi +mv "$staged_prefix" "$prefix" +printf '%s\n' "$XMLSEC1_COMMIT" > "$marker" + +if [[ "$(uname -s)" == "Darwin" ]]; then + DYLD_LIBRARY_PATH="$prefix/lib${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" \ + "$prefix/bin/xmlsec1" --version +else + LD_LIBRARY_PATH="$prefix/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ + "$prefix/bin/xmlsec1" --version +fi + +printf 'installed xmlsec1 %s snapshot %s at %s\n' \ + "$XMLSEC1_VERSION" "${XMLSEC1_COMMIT:0:12}" "$prefix" diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 312c0eef..43b5853f 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -646,6 +646,9 @@ mod tests { const X509_DIGEST_SIGNATURE: &str = include_str!( "../../tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloped-x509-digest-sha512.xml" ); + const X509_DIGEST_SHA256_SIGNATURE: &str = include_str!( + "../../tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloped-x509-digest-sha256.xml" + ); const RSA_KEY_VALUE_SIGNATURE: &str = include_str!( "../../tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.xml" ); @@ -838,12 +841,14 @@ mod tests { ], ..KeyResolverConfig::default() }); - let result = super::super::VerifyContext::new() - .key_resolver(&resolver) - .verify(X509_DIGEST_SIGNATURE) - .expect("X509Digest should resolve a configured certificate"); + for signature in [X509_DIGEST_SHA256_SIGNATURE, X509_DIGEST_SIGNATURE] { + let result = super::super::VerifyContext::new() + .key_resolver(&resolver) + .verify(signature) + .expect("X509Digest should resolve a configured certificate"); - assert_eq!(result.status, super::super::DsigStatus::Valid); + assert_eq!(result.status, super::super::DsigStatus::Valid); + } } #[test] diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index fe914d0b..c43147dc 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -51,7 +51,10 @@ pub(crate) const MAX_X509_DECODED_BINARY_LEN: usize = MAX_X509_BASE64_NORMALIZED_LEN.div_ceil(4) * 3; const MAX_X509_SUBJECT_NAME_TEXT_LEN: usize = 16_384; const MAX_X509_ISSUER_NAME_TEXT_LEN: usize = 16_384; -const MAX_X509_SERIAL_NUMBER_TEXT_LEN: usize = 4096; +// RFC 5280 permits at most 20 DER content octets for a positive certificate +// serial number. The sign bit leaves 159 value bits, or at most 49 decimal digits. +const MAX_X509_SERIAL_NUMBER_TEXT_LEN: usize = 49; +const MAX_X509_SERIAL_NUMBER_BYTES: usize = 20; const MAX_X509_DATA_ENTRY_COUNT: usize = 64; pub(crate) const MAX_X509_DATA_TOTAL_BINARY_LEN: usize = 1_048_576; const MAX_X509_CHAIN_DEPTH: usize = 9; @@ -1508,12 +1511,14 @@ fn format_x509_serial_value_hex(serial: &[u8]) -> String { fn x509_serial_decimal_to_hex(serial: &str) -> Option { let serial = serial.trim(); - let serial = serial.strip_prefix('+').unwrap_or(serial); - if serial.is_empty() || !serial.bytes().all(|byte| byte.is_ascii_digit()) { + if serial.is_empty() + || serial.len() > MAX_X509_SERIAL_NUMBER_TEXT_LEN + || !serial.bytes().all(|byte| byte.is_ascii_digit()) + { return None; } - let mut bytes = Vec::::new(); + let mut bytes = [0_u8; MAX_X509_SERIAL_NUMBER_BYTES]; for digit in serial.bytes().map(|byte| byte - b'0') { let mut carry = u16::from(digit); for byte in bytes.iter_mut().rev() { @@ -1521,12 +1526,17 @@ fn x509_serial_decimal_to_hex(serial: &str) -> Option { *byte = value as u8; carry = value >> 8; } - while carry > 0 { - bytes.insert(0, carry as u8); - carry >>= 8; + if carry != 0 { + return None; } } + // DER INTEGER is signed, so a positive 20-octet serial must keep its high + // bit clear. Values requiring a 21st sign-extension octet exceed RFC 5280. + if bytes[0] & 0x80 != 0 { + return None; + } + Some(format_x509_serial_value_hex(&bytes)) } @@ -1578,12 +1588,8 @@ fn parse_x509_issuer_serial(node: Node<'_, '_>) -> Result<(String, String), Pars let serial_node = children[1]; ensure_no_element_children(serial_node, "X509SerialNumber")?; - let serial_number = collect_text_content_bounded( - serial_node, - MAX_X509_SERIAL_NUMBER_TEXT_LEN, - "X509SerialNumber", - )?; - if issuer_name.trim().is_empty() || serial_number.trim().is_empty() { + let serial_number = collect_x509_serial_number(serial_node)?; + if issuer_name.trim().is_empty() { return Err(ParseError::InvalidStructure( "X509IssuerSerial requires non-empty X509IssuerName and X509SerialNumber".into(), )); @@ -1724,6 +1730,46 @@ fn collect_text_content_bounded( Ok(text) } +fn collect_x509_serial_number(node: Node<'_, '_>) -> Result { + let mut serial = String::with_capacity(MAX_X509_SERIAL_NUMBER_TEXT_LEN); + let mut trailing_whitespace = false; + + for byte in node + .children() + .filter_map(|child| child.is_text().then(|| child.text()).flatten()) + .flat_map(str::bytes) + { + if matches!(byte, b' ' | b'\t' | b'\r' | b'\n') { + trailing_whitespace |= !serial.is_empty(); + continue; + } + if trailing_whitespace || !byte.is_ascii_digit() { + return Err(ParseError::InvalidStructure( + "invalid X509SerialNumber decimal value".into(), + )); + } + if serial.len() == MAX_X509_SERIAL_NUMBER_TEXT_LEN { + return Err(ParseError::InvalidStructure( + "X509SerialNumber exceeds maximum allowed decimal length".into(), + )); + } + serial.push(char::from(byte)); + } + + if serial.is_empty() { + return Err(ParseError::InvalidStructure( + "X509IssuerSerial requires non-empty X509IssuerName and X509SerialNumber".into(), + )); + } + if x509_serial_decimal_to_hex(&serial).is_none() { + return Err(ParseError::InvalidStructure( + "invalid X509SerialNumber decimal value or RFC 5280 range".into(), + )); + } + + Ok(serial) +} + fn ensure_no_element_children(node: Node<'_, '_>, element_name: &str) -> Result<(), ParseError> { if node.children().any(|child| child.is_element()) { return Err(ParseError::InvalidStructure(format!( @@ -2579,6 +2625,8 @@ BA== #[test] fn parse_key_info_rejects_malformed_issuer_serial_even_with_matching_subject() { + // Lexically invalid serials must fail while parsing X509IssuerSerial, + // before another selector or embedded certificate can mask them. let cert = fixture_cert_base64("../../tests/fixtures/keys/rsa/rsa-2048-cert.pem"); let xml = format!( r#" @@ -2596,7 +2644,7 @@ BA== let err = parse_key_info(doc.root_element()).unwrap_err(); assert!( - matches!(err, ParseError::InvalidStructure(message) if message.contains("lookup identifiers")) + matches!(err, ParseError::InvalidStructure(message) if message.contains("invalid X509SerialNumber")) ); } @@ -2678,10 +2726,68 @@ BA== assert_eq!(format_x509_serial_value_hex(&[0x00, 0x00]), "00"); } + #[test] + fn x509_serial_decimal_parser_enforces_rfc5280_positive_range() { + // RFC 5280 limits positive certificate serials to 20 DER content + // octets, leaving 159 value bits because the high bit is the sign. + let max_serial = "730750818665451459101842416358141509827966271487"; + assert_eq!( + x509_serial_decimal_to_hex(max_serial), + Some("7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF".into()) + ); + assert_eq!( + x509_serial_decimal_to_hex("0000000000000000000000000000000000000000000000001"), + Some("01".into()) + ); + + for invalid in [ + "", + "+1", + "-1", + "1a", + "00000000000000000000000000000000000000000000000001", + "730750818665451459101842416358141509827966271488", + "1461501637330902918203684832716283019655932542976", + ] { + assert_eq!( + x509_serial_decimal_to_hex(invalid), + None, + "invalid serial {invalid:?} must be rejected" + ); + } + } + + #[test] + fn parse_x509_serial_normalizes_boundary_whitespace_and_rejects_overflow() { + // XML Schema collapses integer whitespace before validation; the + // normalized value must still obey the RFC 5280 positive range. + let max_serial = "730750818665451459101842416358141509827966271487"; + let valid = format!( + "CN=issuer\n {max_serial}\t" + ); + let doc = Document::parse(&valid).unwrap(); + let parsed = parse_key_info(doc.root_element()).unwrap(); + let KeyInfoSource::X509Data(x509) = &parsed.sources[0] else { + panic!("expected X509Data source"); + }; + assert_eq!(x509.issuer_serials[0].1, max_serial); + + let overflow = valid.replace( + max_serial, + "730750818665451459101842416358141509827966271488", + ); + let doc = Document::parse(&overflow).unwrap(); + assert!(matches!( + parse_key_info(doc.root_element()), + Err(ParseError::InvalidStructure(message)) + if message.contains("invalid X509SerialNumber") + )); + } + #[test] fn parse_key_info_accepts_large_textual_x509_entries_within_entry_budget() { let issuer_name = "C".repeat(MAX_X509_ISSUER_NAME_TEXT_LEN); - let serial_number = "7".repeat(MAX_X509_SERIAL_NUMBER_TEXT_LEN); + let serial_number = "0".repeat(MAX_X509_SERIAL_NUMBER_TEXT_LEN - 1) + "1"; let issuer_serials = (0..52) .map(|_| { format!( diff --git a/tests/common/xmlsec1.rs b/tests/common/xmlsec1.rs new file mode 100644 index 00000000..d64cd89b --- /dev/null +++ b/tests/common/xmlsec1.rs @@ -0,0 +1,31 @@ +use std::ffi::OsString; +use std::process::Command; + +pub const REQUIRED_VERSION: (u16, u16, u16) = (1, 3, 13); + +pub fn command() -> Command { + let binary = std::env::var_os("XMLSEC1_BIN").unwrap_or_else(|| OsString::from("xmlsec1")); + Command::new(binary) +} + +pub fn version_supports_interop(version: &str) -> bool { + version + .split_whitespace() + .find_map(|token| { + let mut components = token.split('.'); + Some(( + components.next()?.parse::().ok()?, + components.next()?.parse::().ok()?, + components.next()?.parse::().ok()?, + )) + }) + .is_some_and(|version| version >= REQUIRED_VERSION) +} + +pub fn is_available() -> bool { + let Ok(output) = command().arg("--version").output() else { + return false; + }; + output.status.success() + && std::str::from_utf8(&output.stdout).is_ok_and(version_supports_interop) +} diff --git a/tests/fixtures/xmldsig/README.md b/tests/fixtures/xmldsig/README.md index 0bdeeed8..61d3c1d8 100644 --- a/tests/fixtures/xmldsig/README.md +++ b/tests/fixtures/xmldsig/README.md @@ -2,6 +2,9 @@ This directory contains the XMLDSig test documents used by integration tests. They are checked into the repository so CI never depends on a local donor clone. +The current compatibility oracle is the xmlsec1 1.3.13 development snapshot at +commit `5fdd47dc35753438bdc38b6e96c1a3805c67a483`; upstream had bumped the +version but had not published a release tag when this snapshot was pinned. ## Importing Vectors @@ -23,7 +26,8 @@ fixture provenance and CI coverage difficult to audit. Core xmlsec1-generated XMLDSig vectors used by the signing and verification pipeline tests. They cover RSA SHA-1/SHA-256/SHA-384/SHA-512, ECDSA P-256 and -P-384, X.509 KeyInfo, and template signing. +P-384, SHA-256/SHA-512 X.509 digest selectors, X.509 KeyInfo, and template +signing. ### `merlin-xmldsig-twenty-three` diff --git a/tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloped-x509-digest-sha256.xml b/tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloped-x509-digest-sha256.xml new file mode 100644 index 00000000..5436ff54 --- /dev/null +++ b/tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloped-x509-digest-sha256.xml @@ -0,0 +1,46 @@ + + + + Hello, World! + + + + + + + + + + not(ancestor-or-self::dsig:Signature) + + + + SsyGDfQDqAg9cuEzSIJDsrp8cSWGzoRqH8E3atXJ4Dw= + + + IlzeEjrSo0bjBM6Cqma9zl63bd0yZHUyZqxJh/29SZ83W35pwCKJFFs3CqIvgK6K +WLKXcL0tW5INPFovZL6wvLNk9wpOgayqkRUppZReAvkq5BxIWloXPl+ymK4sdHec +yAZ9RKgVbFLDZv2emLH03atwTCbAejSwzzgCAiJVZhXDLJFwoBPFjZGhbTcCE/h+ +xW1BLA7DpB94Q8bOmIxeM1SNyTZdGtN0tqIzUnOAC2+eT3nogOZN5bOqPOW065bB +IgvGSmoXqMicYynqO7oPGl+ehqxGwD+R7ipiETaQEvRbt+cRLklGbhApAw0Uyp1M +BzU/OSuOSgqibjGT5QA8e80t+pONoRUxfkp+vYL+kn66qk84dZndZ2HTfEKDnoAE +Txoq4c46Of/Dk8zywRnnzg6nUeNQg5NYXqZIQO4ysI+K+CrRhqPD5PGxfin/VRNg +pOQpNHMdS+Zk47CpWFuL92Plp4yDB58nufbZEY7KnjQ7TV9ArNWZj0dkBQekOJc7 +34aFsqyuyEPsRB03ZiBpT51W/dRxSkSu7/k6qJdi39qBt0m4NVU1sFMGpUw3Fdd2 +TDGf+U3yTUyqky9mIMeWjpirstKeKf6723BF8Kvj3/GPOwJ2NmuYD7UtQyH9Awx8 +jnE3gnDSzNvqxi7sgDK4WLgHvQzCsvYAFsEHl0LeX7I= + + + + fZd23DD+/7HSo72ZyFMENaMmbxjDF2SfThmux0P6qTY= + + + f8KWWGMregazVv77Mw49A/Oicjd5+wKvabdY2YfCGJM= + + + YRUR3UCYtsvTFvFnU9UHFRrZo9imcTVPdMfw8BpVKQk= + + + + + diff --git a/tests/fixtures/xmlenc/README.md b/tests/fixtures/xmlenc/README.md index 14d5e689..86ee9d41 100644 --- a/tests/fixtures/xmlenc/README.md +++ b/tests/fixtures/xmlenc/README.md @@ -4,6 +4,9 @@ These fixtures are tracked so decryption interoperability tests do not depend on network access or a local xmlsec1 checkout. They were imported from the `xmlsec_1_3_12` tag of [lsh123/xmlsec](https://github.com/lsh123/xmlsec/tree/xmlsec_1_3_12/tests). +The pinned 1.3.13 development snapshot at commit +`5fdd47dc35753438bdc38b6e96c1a3805c67a483` contains no changes to these +fixture bytes; reciprocal CLI tests run against that newer snapshot. Imported donor artifacts are kept byte-for-byte, including upstream wording and spelling. Repository-specific clarifications belong in this wrapper rather @@ -57,8 +60,9 @@ algorithms, Diffie-Hellman agreement, or deliberately malformed metadata. ## Importing Vectors -Point the repository helper at an xmlsec1 1.3.12 test checkout and pass paths -under the destination corpus. A directory argument imports its complete tree: +Point the repository helper at the pinned xmlsec1 1.3.13 development checkout +and pass paths under the destination corpus. A directory argument imports its +complete tree: ```sh XMLSEC_DONOR_ROOT=/path/to/xmlsec/tests \ diff --git a/tests/fixtures_smoke.rs b/tests/fixtures_smoke.rs index 3a4b852b..a6163838 100644 --- a/tests/fixtures_smoke.rs +++ b/tests/fixtures_smoke.rs @@ -178,7 +178,7 @@ fn fixture_file_count_matches_expected() { let expected = [ ("keys", 24), ("c14n", 41), - ("xmldsig", 126), + ("xmldsig", 127), ("saml", 2), ("xmlenc", 482), ]; diff --git a/tests/xmlenc_encrypt_xmlsec1.rs b/tests/xmlenc_encrypt_xmlsec1.rs index 86c5da34..4ae169d7 100644 --- a/tests/xmlenc_encrypt_xmlsec1.rs +++ b/tests/xmlenc_encrypt_xmlsec1.rs @@ -5,11 +5,13 @@ use std::{ fs, path::{Path, PathBuf}, - process::Command, sync::atomic::{AtomicU64, Ordering}, time::{SystemTime, UNIX_EPOCH}, }; +#[path = "common/xmlsec1.rs"] +mod xmlsec1; + use rsa::{RsaPublicKey, pkcs8::DecodePublicKey}; use xml_sec::xmlenc::{ DataEncryptionAlgorithm, EncryptedDataBuilder, EncryptionRecipient, OaepDigestAlgorithm, @@ -51,32 +53,10 @@ impl Drop for TemporaryFile { } } -fn xmlsec1_version_supports_interop(version: &str) -> bool { - version - .split_whitespace() - .find_map(|token| { - let mut components = token.split('.'); - Some(( - components.next()?.parse::().ok()?, - components.next()?.parse::().ok()?, - components.next()?.parse::().ok()?, - )) - }) - .is_some_and(|version| version >= (1, 3, 8)) -} - -fn xmlsec1_is_available() -> bool { - let Ok(output) = Command::new("xmlsec1").arg("--version").output() else { - return false; - }; - output.status.success() - && std::str::from_utf8(&output.stdout).is_ok_and(xmlsec1_version_supports_interop) -} - fn decrypt_with_xmlsec1(encrypted_xml: &str, key_option: &str, key_path: &Path) -> Vec { let input = TemporaryFile::write("xmlenc-input", "xml", encrypted_xml.as_bytes()); let output = TemporaryFile::path("xmlenc-output", "data"); - let command_output = Command::new("xmlsec1") + let command_output = xmlsec1::command() .arg("decrypt") .arg("--lax-key-search") .arg(key_option) @@ -98,17 +78,20 @@ fn decrypt_with_xmlsec1(encrypted_xml: &str, key_option: &str, key_path: &Path) #[test] fn xmlsec1_version_gate_accepts_ci_version() { - assert!(!xmlsec1_version_supports_interop("xmlsec1 1.3.7 (openssl)")); - assert!(xmlsec1_version_supports_interop("xmlsec1 1.3.8 (openssl)")); - assert!(xmlsec1_version_supports_interop("xmlsec1 1.3.12 (openssl)")); + assert!(!xmlsec1::version_supports_interop( + "xmlsec1 1.3.12 (openssl)" + )); + assert!(xmlsec1::version_supports_interop( + "xmlsec1 1.3.13 (openssl)" + )); } #[test] fn xmlsec1_decrypts_direct_aes_gcm_from_xml_sec() { // This validates nonce/tag framing and direct KeyName XML against an // independent implementation rather than our reciprocal decrypt path. - if !xmlsec1_is_available() { - eprintln!("skipping XMLEnc interop: xmlsec1 >= 1.3.8 is not installed"); + if !xmlsec1::is_available() { + eprintln!("skipping XMLEnc interop: xmlsec1 >= 1.3.13 is not installed"); return; } let key = [0x4a; 16]; @@ -134,8 +117,8 @@ fn xmlsec1_decrypts_direct_aes_gcm_from_xml_sec() { fn xmlsec1_decrypts_rsa_oaep_wrapped_aes_cbc_from_xml_sec() { // This covers generated session-key transport, OAEP digest/MGF metadata, // nested EncryptedKey lookup, and XMLEnc CBC random-padding framing. - if !xmlsec1_is_available() { - eprintln!("skipping XMLEnc interop: xmlsec1 >= 1.3.8 is not installed"); + if !xmlsec1::is_available() { + eprintln!("skipping XMLEnc interop: xmlsec1 >= 1.3.13 is not installed"); return; } let public_key_path = Path::new("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"); diff --git a/tests/xmlsec1_interop.rs b/tests/xmlsec1_interop.rs index d059138b..5585b58f 100644 --- a/tests/xmlsec1_interop.rs +++ b/tests/xmlsec1_interop.rs @@ -2,10 +2,12 @@ use std::fs; use std::path::{Path, PathBuf}; -use std::process::Command; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +#[path = "common/xmlsec1.rs"] +mod xmlsec1; + use xml_sec::c14n::{C14nAlgorithm, C14nMode}; use xml_sec::xmldsig::{ DefaultKeyResolver, DigestAlgorithm, DsigStatus, EcdsaP256SigningKey, EcdsaP384SigningKey, @@ -118,41 +120,22 @@ fn encoded_payload_xml(id_attribute: &str) -> String { ) } -// `--add-id-attr`, used by the reciprocal interop helpers below, was added in 1.3.8. -fn xmlsec1_version_supports_interop(version: &str) -> bool { - version - .split_whitespace() - .find_map(|token| { - let mut components = token.split('.'); - Some(( - components.next()?.parse::().ok()?, - components.next()?.parse::().ok()?, - components.next()?.parse::().ok()?, - )) - }) - .is_some_and(|version| version >= (1, 3, 8)) -} - -fn xmlsec1_is_available() -> bool { - let Ok(output) = Command::new("xmlsec1").arg("--version").output() else { - return false; - }; - - output.status.success() - && std::str::from_utf8(&output.stdout).is_ok_and(xmlsec1_version_supports_interop) -} - #[test] -fn xmlsec1_version_gate_requires_add_id_attr_support() { - assert!(!xmlsec1_version_supports_interop("xmlsec1 1.3.0 (openssl)")); - assert!(!xmlsec1_version_supports_interop("xmlsec1 1.3.7 (openssl)")); - assert!(xmlsec1_version_supports_interop("xmlsec1 1.3.8 (openssl)")); - assert!(xmlsec1_version_supports_interop("xmlsec1 1.3.12 (openssl)")); - assert!(xmlsec1_version_supports_interop("xmlsec1 2.0.0 (openssl)")); - assert!(!xmlsec1_version_supports_interop( +fn xmlsec1_version_gate_requires_pinned_snapshot() { + assert!(!xmlsec1::version_supports_interop( + "xmlsec1 1.3.8 (openssl)" + )); + assert!(!xmlsec1::version_supports_interop( + "xmlsec1 1.3.12 (openssl)" + )); + assert!(xmlsec1::version_supports_interop( + "xmlsec1 1.3.13 (openssl)" + )); + assert!(xmlsec1::version_supports_interop("xmlsec1 2.0.0 (openssl)")); + assert!(!xmlsec1::version_supports_interop( "xmlsec1 1.2.37 (openssl)" )); - assert!(!xmlsec1_version_supports_interop("xmlsec1 unknown")); + assert!(!xmlsec1::version_supports_interop("xmlsec1 unknown")); } fn signed_payload_xml(key: &dyn SigningKey, builder: &SignatureBuilder) -> String { @@ -184,7 +167,7 @@ fn interop_fixture_references_the_enveloped_root() { fn verify_with_xmlsec1(signed_xml: &str, public_key: &Path) -> std::process::Output { let input = TemporaryXmlFile::write("xmlsec1-interop", signed_xml); - Command::new("xmlsec1") + xmlsec1::command() .arg("--verify") .arg("--lax-key-search") .arg("--add-id-attr") @@ -204,7 +187,7 @@ fn sign_with_xmlsec1( ) -> String { let output_file = TemporaryXmlFile::write("xmlsec1-signed", ""); let key_and_certificate = format!("{},{}", private_key.display(), certificate.display()); - let output = Command::new("xmlsec1") + let output = xmlsec1::command() .arg("--sign") .arg("--add-id-attr") .arg("Id") @@ -243,7 +226,7 @@ fn assert_xmlsec1_accepts(signed_xml: &str, public_key: &str) { fn xmlsec1_verifies_rsa_sha256_signature_from_xml_sec() { // A separate implementation must accept the generated enveloped signature, // including its reference digest, exclusive C14N, and RSA SignatureValue. - if !xmlsec1_is_available() { + if !xmlsec1::is_available() { eprintln!("skipping xmlsec1 interoperability test: xmlsec1 is not installed"); return; } @@ -266,7 +249,7 @@ fn xmlsec1_verifies_rsa_sha256_signature_from_xml_sec() { fn xmlsec1_verifies_base64_reference_signature_from_xml_sec() { // The donor implementation must derive the same decoded octets from a // node set containing nested elements and comments. - if !xmlsec1_is_available() { + if !xmlsec1::is_available() { eprintln!("skipping xmlsec1 interoperability test: xmlsec1 is not installed"); return; } @@ -287,7 +270,7 @@ fn xmlsec1_verifies_base64_reference_signature_from_xml_sec() { fn xml_sec_verifies_base64_reference_signature_from_xmlsec1() { // Reciprocal generation proves our parser and text-node conversion accept // the transform representation emitted and digested by xmlsec1. - if !xmlsec1_is_available() { + if !xmlsec1::is_available() { eprintln!("skipping xmlsec1 interoperability test: xmlsec1 is not installed"); return; } @@ -328,7 +311,7 @@ fn xml_sec_verifies_base64_reference_signature_from_xmlsec1() { fn xmlsec1_verifies_xpath_filter2_signature_from_xml_sec() { // xmlsec1 must derive the same subtree set after ordered intersect and // subtract operations and accept our resulting RSA signature. - if !xmlsec1_is_available() { + if !xmlsec1::is_available() { eprintln!("skipping xmlsec1 interoperability test: xmlsec1 is not installed"); return; } @@ -351,7 +334,7 @@ fn xmlsec1_verifies_xpath_filter2_signature_from_xml_sec() { fn xml_sec_verifies_xpath_filter2_signature_from_xmlsec1() { // Reciprocal signing proves the parser and evaluator accept Filter 2.0 XML // and digest octets produced independently by xmlsec1. - if !xmlsec1_is_available() { + if !xmlsec1::is_available() { eprintln!("skipping xmlsec1 interoperability test: xmlsec1 is not installed"); return; } @@ -387,7 +370,7 @@ fn xmlsec1_verifies_selected_axes_without_their_owner_from_xml_sec() { // Canonical XML serializes selected attribute and namespace nodes even // when their owner element is absent, producing valid digest octets that // are intentionally not a well-balanced XML fragment. - if !xmlsec1_is_available() { + if !xmlsec1::is_available() { eprintln!("skipping xmlsec1 interoperability test: xmlsec1 is not installed"); return; } @@ -410,7 +393,7 @@ fn xmlsec1_verifies_selected_axes_without_their_owner_from_xml_sec() { fn xml_sec_verifies_selected_axes_without_their_owner_from_xmlsec1() { // Reciprocal signing proves xmlsec1 independently canonicalizes the same // esoteric node-set to the octets consumed by xml-sec. - if !xmlsec1_is_available() { + if !xmlsec1::is_available() { eprintln!("skipping xmlsec1 interoperability test: xmlsec1 is not installed"); return; } @@ -445,7 +428,7 @@ fn xml_sec_verifies_selected_axes_without_their_owner_from_xmlsec1() { fn xmlsec1_verifies_ecdsa_signatures_from_xml_sec() { // P-256 and P-384 prove that xml-sec emits XMLDSig raw r||s values that // xmlsec1 accepts for both supported ECDSA curve widths. - if !xmlsec1_is_available() { + if !xmlsec1::is_available() { eprintln!("skipping xmlsec1 interoperability test: xmlsec1 is not installed"); return; } @@ -484,7 +467,7 @@ fn xmlsec1_verifies_ecdsa_signatures_from_xml_sec() { fn xmlsec1_rejects_tampered_signature_from_xml_sec() { // The external verifier must reject a changed signed payload, proving the // test is exercising validation rather than merely command invocation. - if !xmlsec1_is_available() { + if !xmlsec1::is_available() { eprintln!("skipping xmlsec1 interoperability test: xmlsec1 is not installed"); return; } @@ -516,7 +499,7 @@ fn xmlsec1_rejects_tampered_signature_from_xml_sec() { fn xml_sec_verifies_xmlsec1_signatures_with_embedded_certificates() { // xmlsec1 must create signatures that our full pipeline accepts through // the embedded X509Data resolver, not through a separately injected key. - if !xmlsec1_is_available() { + if !xmlsec1::is_available() { eprintln!("skipping xmlsec1 interoperability test: xmlsec1 is not installed"); return; } @@ -561,7 +544,7 @@ fn xml_sec_verifies_xmlsec1_signatures_with_embedded_certificates() { fn xml_sec_rejects_tampered_xmlsec1_signature_before_crypto_verification() { // Mutating the signed Object must fail reference validation before the // verifier reaches SignatureValue cryptography, matching XMLDSig fail-fast. - if !xmlsec1_is_available() { + if !xmlsec1::is_available() { eprintln!("skipping xmlsec1 interoperability test: xmlsec1 is not installed"); return; } From fbfb5c76afa7378b431fa18dc31681106c152b1e Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 5 Aug 2026 14:21:01 +0300 Subject: [PATCH 07/26] fix(xmldsig): ignore CryptoBinary comments - Decode only XML text nodes in CryptoBinary simple content - Cover comment-split DSA and RSA key parameters --- src/xmldsig/parse.rs | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index c43147dc..fc747690 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -1013,7 +1013,11 @@ fn decode_crypto_binary( let max_base64_len = max_decoded_len.div_ceil(3) * 4; let mut cleaned = String::with_capacity(max_base64_len); - for text in node.children().filter_map(|child| child.text()) { + for text in node + .children() + .filter(|child| child.is_text()) + .filter_map(|child| child.text()) + { normalize_xml_base64_text_with_limit(text, &mut cleaned, max_base64_len).map_err( |err| match err { XmlBase64NormalizeLimitedError::InvalidWhitespace(err) => { @@ -3169,6 +3173,43 @@ BA== } } + #[test] + fn parse_dsa_crypto_binary_ignores_comment_nodes() { + // XML comments split simple content without contributing to its string value. + let xml = r#" + AQID + "#; + let doc = Document::parse(xml).unwrap(); + + assert!(matches!( + parse_key_info(doc.root_element()) + .unwrap() + .sources + .as_slice(), + [KeyInfoSource::KeyValue(KeyValueInfo::Dsa { y, .. })] if y == &[1, 2, 3] + )); + } + + #[test] + fn parse_rsa_crypto_binary_ignores_comment_nodes() { + // The shared CryptoBinary decoder must apply XML simple-content semantics to every key type. + let xml = r#" + + AQIDAw== + + "#; + let doc = Document::parse(xml).unwrap(); + + assert!(matches!( + parse_key_info(doc.root_element()) + .unwrap() + .sources + .as_slice(), + [KeyInfoSource::KeyValue(KeyValueInfo::Rsa { modulus, exponent })] + if modulus == &[1, 2, 3] && exponent == &[3] + )); + } + #[test] fn parse_key_info_rejects_unimplemented_retrieval_transform() { // Retrieval transforms must never be silently ignored when choosing a key. From 23042064c69f4af8ae25117c738d916d1a0355c6 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 5 Aug 2026 15:25:35 +0300 Subject: [PATCH 08/26] fix(xmldsig): harden external references - enforce transform policy from the terminal data type - preserve unsupported advisory retrieval methods - bound external XML parsing and retained diagnostics - run fuzz smoke explicitly on nightly --- .github/workflows/ci.yml | 4 +- src/hard_limits.rs | 10 ++ src/lib.rs | 2 + src/xmldsig/parse.rs | 34 ++++-- src/xmldsig/transforms.rs | 33 +++++- src/xmldsig/verify.rs | 219 ++++++++++++++++++++++++++++++++++---- 6 files changed, 268 insertions(+), 34 deletions(-) create mode 100644 src/hard_limits.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1329806..b22d7c2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,5 +91,5 @@ jobs: steps: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@nightly - - run: cargo install cargo-fuzz --version 0.13.1 --locked - - run: cargo fuzz run xmldsig_verify -- -runs=256 -max_len=65536 + - run: cargo +nightly install cargo-fuzz --version 0.13.1 --locked + - run: cargo +nightly fuzz run xmldsig_verify -- -runs=256 -max_len=65536 diff --git a/src/hard_limits.rs b/src/hard_limits.rs new file mode 100644 index 00000000..b4043649 --- /dev/null +++ b/src/hard_limits.rs @@ -0,0 +1,10 @@ +//! Non-configurable implementation safety ceilings. +//! +//! These caps bound allocations even when a future compiled deployment policy +//! permits larger inputs. Deployment policy may only select stricter values. + +/// Maximum XML nodes allocated while parsing one verification or transform document. +pub(crate) const XML_DOCUMENT_NODE_CEILING: u32 = 100_000; + +/// Maximum bytes retained across one verification result's diagnostic buffers. +pub(crate) const STORED_PRE_DIGEST_BYTE_CEILING: usize = 32 * 1024 * 1024; diff --git a/src/lib.rs b/src/lib.rs index 61acd7cf..0c44774a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,6 +32,8 @@ pub mod c14n; pub mod error; +#[cfg(feature = "xmldsig")] +mod hard_limits; #[cfg(any(feature = "xmldsig", feature = "xmlenc"))] mod xml; diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index fc747690..2b8ac35a 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -194,6 +194,10 @@ pub enum RetrievalMethodTransforms { None, /// Filter a same-document node-set to one `ds:X509Data`-rooted subtree. X509DataNodeSetFilter, + /// A transform chain attached to a RetrievalMethod type this implementation + /// does not materialize. Resolvers may ignore this advisory key source and + /// continue with later `` children. + Unsupported, } /// Parsed `` dispatch result. @@ -628,10 +632,19 @@ pub fn parse_key_info(key_info_node: Node) -> Result { "RetrievalMethod URI exceeds maximum length".into(), )); } - let transforms = parse_retrieval_method_transforms(child)?; + let resource_type = child.attribute("Type").map(str::to_string); + let transforms = if resource_type.as_deref() + == Some("http://www.w3.org/2000/09/xmldsig#X509Data") + { + parse_retrieval_method_transforms(child)? + } else if element_children(child).next().is_some() { + RetrievalMethodTransforms::Unsupported + } else { + RetrievalMethodTransforms::None + }; sources.push(KeyInfoSource::RetrievalMethod { uri: uri.to_string(), - resource_type: child.attribute("Type").map(str::to_string), + resource_type, transforms, }); } @@ -3211,18 +3224,25 @@ BA== } #[test] - fn parse_key_info_rejects_unimplemented_retrieval_transform() { - // Retrieval transforms must never be silently ignored when choosing a key. + fn parse_key_info_preserves_advisory_unsupported_retrieval_transform() { + // Unsupported RetrievalMethod types are advisory key sources. Their + // transform syntax must not hide a later source the resolver can use. let xml = r##" - + + fallback "##; let doc = Document::parse(xml).unwrap(); + let key_info = parse_key_info(doc.root_element()) + .expect("unsupported advisory retrieval must not reject all KeyInfo sources"); assert!(matches!( - parse_key_info(doc.root_element()), - Err(ParseError::InvalidStructure(_)) + key_info.sources.as_slice(), + [ + KeyInfoSource::RetrievalMethod { resource_type: Some(resource_type), .. }, + KeyInfoSource::KeyName(name), + ] if resource_type == "urn:vendor:key" && name == "fallback" )); } diff --git a/src/xmldsig/transforms.rs b/src/xmldsig/transforms.rs index 9e27e9c3..c9c3d941 100644 --- a/src/xmldsig/transforms.rs +++ b/src/xmldsig/transforms.rs @@ -35,6 +35,7 @@ use super::xpath::{ apply_xpath_filter2_with_semantics_and_budget, compile_xpath, is_xpath_whitespace, }; use crate::c14n::{self, C14nAlgorithm}; +use crate::hard_limits::XML_DOCUMENT_NODE_CEILING; /// The algorithm URI for the enveloped signature transform. pub const ENVELOPED_SIGNATURE_URI: &str = "http://www.w3.org/2000/09/xmldsig#enveloped-signature"; @@ -775,8 +776,15 @@ fn execute_transform_chain<'s, 'e, 'd>( // recursion, so these retained buffers remain a bounded subset of the // signature-wide canonicalization work budget. let xml = decode_xml_octets(&bytes)?; - let document = roxmltree::Document::parse(&xml) - .map_err(|error| TransformError::XmlParse(error.to_string()))?; + let document = roxmltree::Document::parse_with_options( + &xml, + roxmltree::ParsingOptions { + allow_dtd: false, + nodes_limit: XML_DOCUMENT_NODE_CEILING, + entity_resolver: None, + }, + ) + .map_err(|error| TransformError::XmlParse(error.to_string()))?; context.state.document_reparsed(); let nodes = super::types::NodeSet::entire_document_with_comments_with_budget( &document, @@ -1776,6 +1784,27 @@ mod tests { )); } + #[test] + fn binary_to_node_set_adapter_bounds_external_xml_nodes_during_parse() { + // The parser must reject a dense external XML resource before allocating + // an unbounded roxmltree arena or beginning XPath materialization. + let signature_document = Document::parse("").unwrap(); + let xml = format!( + "{}", + "".repeat(XML_DOCUMENT_NODE_CEILING as usize + 1), + ); + let transforms = [Transform::XPath(XPathExpression::new("true()"))]; + + let error = execute_transforms( + signature_document.root_element(), + TransformData::Binary(xml.into_bytes()), + &transforms, + ) + .expect_err("external XML exceeding the node ceiling must fail during parse"); + + assert!(matches!(error, TransformError::XmlParse(_))); + } + #[test] fn xpath_projection_uses_shared_materialization_budget() { // XPath projects exact attribute and namespace identities back into a diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index 13ce7099..7f2a3ae1 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -12,9 +12,11 @@ use base64::Engine; use roxmltree::{Document, Node, NodeId}; +use std::cell::Cell; use std::collections::{HashMap, HashSet}; use crate::c14n::canonicalize; +use crate::hard_limits::{STORED_PRE_DIGEST_BYTE_CEILING, XML_DOCUMENT_NODE_CEILING}; use super::digest::{DigestAlgorithm, compute_digest, constant_time_eq}; use super::parse::{ @@ -295,6 +297,11 @@ impl<'a> VerifyContext<'a> { } /// Store pre-digest buffers for diagnostics. + /// + /// Retained reference buffers and canonicalized `` share a + /// non-configurable 32 MiB safety ceiling. Verification returns + /// [`ReferenceProcessingError::PreDigestDataTooLarge`] rather than retaining + /// more diagnostic data. pub fn store_pre_digest(mut self, enabled: bool) -> Self { self.store_pre_digest = enabled; self @@ -436,7 +443,8 @@ impl ReferencesResult { /// - `signature_node`: The `` element (for enveloped-signature transform). /// - `reference_set`: Whether this reference belongs to `` or ``. /// - `reference_index`: Zero-based index of this reference inside `reference_set`. -/// - `store_pre_digest`: If true, store the pre-digest bytes in the result. +/// - `store_pre_digest`: If true, store the pre-digest bytes in the result, +/// subject to the signature-wide diagnostic retention ceiling. /// /// # Errors /// @@ -452,10 +460,12 @@ pub fn process_reference( store_pre_digest: bool, ) -> Result { let execution_budget = TransformExecutionBudget::default(); + let pre_digest_budget = PreDigestRetentionBudget::default(); let execution = ReferenceExecutionContext { store_pre_digest, transform_options: TransformOptions::default(), transform_budget: &execution_budget, + pre_digest_budget: &pre_digest_budget, }; process_reference_with_options( reference, @@ -471,6 +481,42 @@ struct ReferenceExecutionContext<'a> { store_pre_digest: bool, transform_options: TransformOptions, transform_budget: &'a TransformExecutionBudget, + pre_digest_budget: &'a PreDigestRetentionBudget, +} + +struct PreDigestRetentionBudget { + remaining: Cell, + max_bytes: usize, +} + +impl Default for PreDigestRetentionBudget { + fn default() -> Self { + Self { + remaining: Cell::new(STORED_PRE_DIGEST_BYTE_CEILING), + max_bytes: STORED_PRE_DIGEST_BYTE_CEILING, + } + } +} + +impl PreDigestRetentionBudget { + fn charge(&self, bytes: usize) -> Result<(), ReferenceProcessingError> { + let Some(remaining) = self.remaining.get().checked_sub(bytes) else { + self.remaining.set(0); + return Err(ReferenceProcessingError::PreDigestDataTooLarge { + max_bytes: self.max_bytes, + }); + }; + self.remaining.set(remaining); + Ok(()) + } + + #[cfg(test)] + fn with_limit(max_bytes: usize) -> Self { + Self { + remaining: Cell::new(max_bytes), + max_bytes, + } + } } fn process_reference_with_options( @@ -513,17 +559,20 @@ fn process_reference_with_options( }) }; + let pre_digest_data = if execution.store_pre_digest { + execution.pre_digest_budget.charge(pre_digest_bytes.len())?; + Some(pre_digest_bytes) + } else { + None + }; + Ok(ReferenceResult { reference_set, reference_index, uri: uri.to_owned(), digest_algorithm: reference.digest_method, status, - pre_digest_data: if execution.store_pre_digest { - Some(pre_digest_bytes) - } else { - None - }, + pre_digest_data, }) } @@ -545,10 +594,12 @@ pub fn process_all_references( store_pre_digest: bool, ) -> Result { let execution_budget = TransformExecutionBudget::default(); + let pre_digest_budget = PreDigestRetentionBudget::default(); let execution = ReferenceExecutionContext { store_pre_digest, transform_options: TransformOptions::default(), transform_budget: &execution_budget, + pre_digest_budget: &pre_digest_budget, }; process_all_references_with_options(references, resolver, signature_node, &execution) } @@ -604,6 +655,13 @@ pub enum ReferenceProcessingError { /// Transform execution failed. #[error("transform failed: {0}")] Transform(#[source] super::types::TransformError), + + /// Diagnostic pre-digest buffers would exceed their signature-wide cap. + #[error("stored pre-digest data exceeds signature-wide maximum of {max_bytes} bytes")] + PreDigestDataTooLarge { + /// Maximum bytes retained across all reference diagnostics. + max_bytes: usize, + }, } /// End-to-end XMLDSig verification result for one ``. @@ -768,7 +826,7 @@ fn verify_signature_with_context( xml, roxmltree::ParsingOptions { allow_dtd: ctx.allow_internal_dtd, - nodes_limit: 100_000, + nodes_limit: XML_DOCUMENT_NODE_CEILING, entity_resolver: None, }, )?; @@ -815,7 +873,6 @@ fn verify_signature_with_context( &signed_info.references, ctx.allowed_uri_types, ctx.allowed_transform_uris(), - ctx.external_resources, )?; if let Some(resources) = ctx.external_resources { @@ -851,10 +908,12 @@ fn verify_signature_with_context( )?; } let execution_budget = TransformExecutionBudget::default(); + let pre_digest_budget = PreDigestRetentionBudget::default(); let execution = ReferenceExecutionContext { store_pre_digest: ctx.store_pre_digest, transform_options: ctx.transform_options, transform_budget: &execution_budget, + pre_digest_budget: &pre_digest_budget, }; let references = process_all_references_with_options( &signed_info.references, @@ -884,6 +943,9 @@ fn verify_signature_with_context( &signed_info.c14n_method, &mut canonical_signed_info, )?; + if ctx.store_pre_digest { + pre_digest_budget.charge(canonical_signed_info.len())?; + } let signature_value = decode_signature_value(signature_children.signature_value_node)?; if signed_info.signature_method == SignatureAlgorithm::HmacSha1 { @@ -1151,7 +1213,6 @@ fn process_manifest_references( std::slice::from_ref(reference), ctx.allowed_uri_types, ctx.allowed_transform_uris(), - ctx.external_resources, ) { Ok(()) => {} Err( @@ -1390,7 +1451,6 @@ fn enforce_reference_policies( references: &[Reference], allowed_uri_types: UriTypeSet, allowed_transforms: Option<&HashSet>, - external_resources: Option<&HashMap>>, ) -> Result<(), SignatureVerificationPipelineError> { for reference in references { let uri = reference @@ -1415,13 +1475,14 @@ fn enforce_reference_policies( } } - let dereferences_to_binary = !uri.is_empty() - && !uri.starts_with('#') - && external_resources.is_some_and(|resources| resources.contains_key(uri)); - let produces_binary = dereferences_to_binary - || reference.transforms.last().is_some_and(|transform| { - matches!(transform, Transform::C14n(_) | Transform::Base64Decode) - }); + // External dereference has an octet-stream data type independent of + // whether the caller supplied the resource. Every transform then + // determines the next type, including implicit binary-to-node-set + // adapters before XML-level transforms. + let mut produces_binary = classify_uri(uri) == UriClass::External; + for transform in &reference.transforms { + produces_binary = matches!(transform, Transform::C14n(_) | Transform::Base64Decode); + } if !produces_binary && !allowed.contains(DEFAULT_IMPLICIT_C14N_URI) { return Err(SignatureVerificationPipelineError::DisallowedTransform { algorithm: DEFAULT_IMPLICIT_C14N_URI.to_owned(), @@ -1771,6 +1832,31 @@ mod tests { } } + struct FallbackKeyInfoResolver; + + impl KeyResolver for FallbackKeyInfoResolver { + fn resolve<'a>( + &'a self, + key_info: Option<&KeyInfo>, + _algorithm: SignatureAlgorithm, + ) -> Result>, SignatureVerificationPipelineError> + { + let sources = &key_info.expect("KeyInfo must be parsed").sources; + assert!(matches!( + sources.as_slice(), + [ + super::super::parse::KeyInfoSource::RetrievalMethod { .. }, + super::super::parse::KeyInfoSource::KeyName(name), + ] if name == "fallback" + )); + Ok(Some(Box::new(AcceptingKey))) + } + + fn consumes_document_key_info(&self) -> bool { + true + } + } + fn minimal_signature_xml(reference_uri: &str, transforms_xml: &str) -> String { format!( r#" @@ -2959,6 +3045,29 @@ mod tests { )); } + #[test] + fn verify_context_ignores_unsupported_retrieval_before_valid_key_source() { + // An advisory vendor RetrievalMethod cannot prevent the resolver from + // reaching a later supported source in document order. + let xml = signature_with_target_reference("AQ==").replace( + "\n ", + r##" + + + + + fallback + + "##, + ); + + let result = VerifyContext::new() + .key_resolver(&FallbackKeyInfoResolver) + .verify(&xml) + .expect("unsupported advisory retrieval must not abort key resolution"); + assert_eq!(result.status, DsigStatus::Valid); + } + #[test] fn verify_context_preserves_signaturevalue_decode_errors_when_resolver_misses() { let xml = signature_with_target_reference("@@@"); @@ -3002,7 +3111,7 @@ mod tests { allow_external: false, }; - let err = enforce_reference_policies(&references, uri_types, None, None) + let err = enforce_reference_policies(&references, uri_types, None) .expect_err("missing URI must fail before allow_empty policy is evaluated"); assert!(matches!( err, @@ -3028,7 +3137,6 @@ mod tests { std::slice::from_ref(&reference), UriTypeSet::default(), Some(&allowed), - None, ) .expect("terminal binary output must not require implicit C14N"); } @@ -3043,7 +3151,6 @@ mod tests { std::slice::from_ref(&terminal_base64), UriTypeSet::default(), Some(&without_implicit_c14n), - None, ) .expect("terminal Base64 output must not require implicit C14N"); @@ -3052,7 +3159,6 @@ mod tests { std::slice::from_ref(&no_transforms), UriTypeSet::default(), Some(&without_implicit_c14n), - None, ) .expect_err("a node-set result must require allowlisted implicit C14N"); assert!(matches!( @@ -3061,15 +3167,78 @@ mod tests { if algorithm == DEFAULT_IMPLICIT_C14N_URI )); - let external_resources = HashMap::from([("urn:payload".to_owned(), b"bytes".to_vec())]); let detached = make_reference("urn:payload", vec![], DigestAlgorithm::Sha256, vec![0; 32]); enforce_reference_policies( std::slice::from_ref(&detached), UriTypeSet::ALL, Some(&without_implicit_c14n), - Some(&external_resources), ) .expect("external octets without transforms must not require implicit C14N"); + + let external_xpath = make_reference( + "urn:payload", + vec![Transform::XPath( + super::super::transforms::XPathExpression::new("true()"), + )], + DigestAlgorithm::Sha256, + vec![0; 32], + ); + let error = enforce_reference_policies( + std::slice::from_ref(&external_xpath), + UriTypeSet::ALL, + Some(&HashSet::from([XPATH_TRANSFORM_URI.to_owned()])), + ) + .expect_err("external XML converted to a node-set must require implicit C14N"); + assert!(matches!( + error, + SignatureVerificationPipelineError::DisallowedTransform { ref algorithm } + if algorithm == DEFAULT_IMPLICIT_C14N_URI + )); + } + + #[test] + fn stored_pre_digest_budget_counts_repeated_external_references() { + // The caller map owns one bounded payload, but diagnostic retention is + // charged per Reference because every result owns its pre-digest bytes. + let document = + Document::parse("") + .unwrap(); + let payload = vec![b'x'; 7]; + let digest = compute_digest(DigestAlgorithm::Sha256, &payload); + let references = (0..5) + .map(|_| { + make_reference( + "urn:repeated", + Vec::new(), + DigestAlgorithm::Sha256, + digest.clone(), + ) + }) + .collect::>(); + let resources = HashMap::from([("urn:repeated".to_owned(), payload)]); + let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources); + let transform_budget = TransformExecutionBudget::default(); + let pre_digest_budget = PreDigestRetentionBudget::with_limit(32); + let execution = ReferenceExecutionContext { + store_pre_digest: true, + transform_options: TransformOptions::default(), + transform_budget: &transform_budget, + pre_digest_budget: &pre_digest_budget, + }; + + let error = process_all_references_with_options( + &references, + &resolver, + document.root_element(), + &execution, + ) + .expect_err( + "retained diagnostics must not multiply one external allocation past the aggregate cap", + ); + assert!(matches!( + error, + ReferenceProcessingError::PreDigestDataTooLarge { max_bytes: 32 } + )); } #[test] @@ -3391,10 +3560,12 @@ mod tests { make_reference("", vec![transform], DigestAlgorithm::Sha256, digest), ]; let budget = TransformExecutionBudget::with_xpath_limit(12); + let pre_digest_budget = PreDigestRetentionBudget::default(); let execution = ReferenceExecutionContext { store_pre_digest: false, transform_options: TransformOptions::default(), transform_budget: &budget, + pre_digest_budget: &pre_digest_budget, }; let error = process_all_references_with_options( @@ -3427,10 +3598,12 @@ mod tests { make_reference("#selected", vec![], DigestAlgorithm::Sha256, digest), ]; let budget = TransformExecutionBudget::with_node_set_materialization_limit(30); + let pre_digest_budget = PreDigestRetentionBudget::default(); let execution = ReferenceExecutionContext { store_pre_digest: false, transform_options: TransformOptions::default(), transform_budget: &budget, + pre_digest_budget: &pre_digest_budget, }; let error = process_all_references_with_options( From fa4a0881108de6137d216d319e94079df7ee2d75 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 5 Aug 2026 15:28:57 +0300 Subject: [PATCH 09/26] ci: unpin stale cargo-fuzz lockfile Keep cargo-fuzz 0.13.1 pinned while allowing compatible transitive patch releases on current nightly. --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b22d7c2d..8426539f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,5 +91,8 @@ jobs: steps: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@nightly - - run: cargo +nightly install cargo-fuzz --version 0.13.1 --locked + # cargo-fuzz 0.13.1's published lockfile pins rustix 0.36.5, which no + # longer compiles on current nightly. Keep the tool version pinned while + # allowing compatible patch-level transitive dependencies. + - run: cargo +nightly install cargo-fuzz --version 0.13.1 - run: cargo +nightly fuzz run xmldsig_verify -- -runs=256 -max_len=65536 From 9f3017b4376e4950e2f14bb943acea8a39c9ea23 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Thu, 6 Aug 2026 00:12:54 +0300 Subject: [PATCH 10/26] fix(xmldsig): harden interop boundaries - make xmlsec1 replacement transactional through validation - enforce ordered X.509 names and positive serials - support direct typed X509Data retrieval safely - tighten interop version parsing and fixture documentation --- scripts/install-xmlsec1.sh | 32 ++++++++++- src/xmldsig/keys.rs | 10 ++-- src/xmldsig/parse.rs | 80 ++++++++++++++++++++------ src/xmldsig/verify.rs | 85 +++++++++++++++++++++++++-- tests/common/xmlsec1.rs | 36 ++++++++---- tests/fixtures/xmldsig/README.md | 13 +++-- tests/install_xmlsec1.rs | 99 ++++++++++++++++++++++++++++++++ tests/xmlsec1_interop.rs | 12 ++++ 8 files changed, 320 insertions(+), 47 deletions(-) create mode 100644 tests/install_xmlsec1.rs diff --git a/scripts/install-xmlsec1.sh b/scripts/install-xmlsec1.sh index 6dc20b2f..b59f0db4 100755 --- a/scripts/install-xmlsec1.sh +++ b/scripts/install-xmlsec1.sh @@ -21,7 +21,31 @@ if [[ -x "$prefix/bin/xmlsec1" && -f "$marker" ]] \ fi work_dir="$(mktemp -d "${TMPDIR:-/tmp}/xmlsec1-${XMLSEC1_VERSION}.XXXXXX")" -trap 'rm -rf "$work_dir"' EXIT +previous_install="$work_dir/previous-install" +had_previous_install=false + +cleanup() { + local status=$? + local remove_work_dir=true + trap - EXIT + + # Keep replacement transactional through the version smoke test. The + # staged move is not a commit if installation or validation fails. + if (( status != 0 )) && [[ "$had_previous_install" == true ]]; then + rm -rf "$prefix" + if ! mv "$previous_install" "$prefix"; then + printf 'failed to restore previous xmlsec1 installation at %s; backup remains at %s\n' \ + "$prefix" "$previous_install" >&2 + status=1 + remove_work_dir=false + fi + fi + if [[ "$remove_work_dir" == true ]]; then + rm -rf "$work_dir" + fi + exit "$status" +} +trap cleanup EXIT archive="$work_dir/xmlsec.tar.gz" source_dir="$work_dir/xmlsec-$XMLSEC1_COMMIT" build_dir="$work_dir/build" @@ -61,7 +85,8 @@ make --directory "$build_dir" install DESTDIR="$stage_dir" staged_prefix="$stage_dir$prefix" mkdir -p "$(dirname "$prefix")" if [[ -e "$prefix" ]]; then - mv "$prefix" "$work_dir/previous-install" + mv "$prefix" "$previous_install" + had_previous_install=true fi mv "$staged_prefix" "$prefix" printf '%s\n' "$XMLSEC1_COMMIT" > "$marker" @@ -74,5 +99,8 @@ else "$prefix/bin/xmlsec1" --version fi +rm -rf "$previous_install" +had_previous_install=false + printf 'installed xmlsec1 %s snapshot %s at %s\n' \ "$XMLSEC1_VERSION" "${XMLSEC1_COMMIT:0:12}" "$prefix" diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 43b5853f..2c6670c0 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -687,7 +687,7 @@ mod tests { fn x509_signature_with_leaf_subject() -> String { replace_unprefixed_key_info( X509_DIGEST_SIGNATURE, - "C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), CN=Test Key rsa-4096", + "CN=Test Key rsa-4096,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US", ) } @@ -921,7 +921,7 @@ mod tests { #[test] fn selector_resolved_certificate_preserves_supplied_crls() { - let selector = "C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), CN=Test Key rsa-2048CRL_PLACEHOLDER"; + let selector = "CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=USCRL_PLACEHOLDER"; let crl = crl_der(include_str!( "../../tests/fixtures/keys/rsa/rsa-2048-cert-revoked-crl.pem" )); @@ -963,8 +963,8 @@ mod tests { // Every selector form documented by KeyInfo must independently locate // the same configured RSA certificate without embedded key material. let selectors = [ - "C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), CN=Test Key rsa-2048", - "C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), OU=Second level CA, CN=Aleksey Sanin, Email=xmlsec@aleksey.com680572598617295163017172295025714171905498632019", + "CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US", + "Email=xmlsec@aleksey.com,CN=Aleksey Sanin,OU=Second level CA,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US680572598617295163017172295025714171905498632019", "bcOXN/nsVl8GatRbcKrPbzIbw0Y=", ]; let configured_certificate = certificate_der(include_str!( @@ -991,7 +991,7 @@ mod tests { fn resolves_configured_chain_selectors_across_certificates() { // Selector categories may identify different members of one configured // chain; the unique leaf remains the signing certificate. - let key_info = r#"C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), CN=Test Key rsa-20480X0XrEVCio75sBcl1TxymJ2IOiU="#; + let key_info = r#"CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US0X0XrEVCio75sBcl1TxymJ2IOiU="#; let xml = replace_unprefixed_key_info(RSA_KEY_VALUE_SIGNATURE, key_info); let resolver = DefaultKeyResolver::new(KeyResolverConfig { trusted_certs: vec![ diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index 2b8ac35a..34e445ac 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -20,6 +20,7 @@ use roxmltree::{Document, Node}; use x509_parser::extensions::ParsedExtension; use x509_parser::prelude::FromDer; use x509_parser::public_key::PublicKey; +use x509_parser::x509::X509Name; use super::digest::{DigestAlgorithm, compute_digest, constant_time_eq}; use super::transforms::{self, Transform}; @@ -1357,7 +1358,7 @@ fn distinguished_names_equal(left: &str, right: &str) -> bool { } let left = components(left); let right = components(right); - left == right || left.iter().eq(right.iter().rev()) + left == right } fn ensure_x509_data_entry_budget(info: &X509DataInfo) -> Result<(), ParseError> { @@ -1447,8 +1448,12 @@ pub(crate) fn parse_x509_certificate(cert_der: &[u8]) -> Result Result) -> String { + let mut rdns = name.iter_rdn().cloned().collect::>(); + rdns.reverse(); + X509Name::new(rdns, name.as_raw()).to_string() +} + fn format_x509_serial_hex(serial: &[u8]) -> String { serial .iter() @@ -1528,6 +1539,7 @@ fn format_x509_serial_value_hex(serial: &[u8]) -> String { fn x509_serial_decimal_to_hex(serial: &str) -> Option { let serial = serial.trim(); + let serial = serial.strip_prefix('+').unwrap_or(serial); if serial.is_empty() || serial.len() > MAX_X509_SERIAL_NUMBER_TEXT_LEN || !serial.bytes().all(|byte| byte.is_ascii_digit()) @@ -1553,6 +1565,9 @@ fn x509_serial_decimal_to_hex(serial: &str) -> Option { if bytes[0] & 0x80 != 0 { return None; } + if bytes.iter().all(|byte| *byte == 0) { + return None; + } Some(format_x509_serial_value_hex(&bytes)) } @@ -1750,6 +1765,7 @@ fn collect_text_content_bounded( fn collect_x509_serial_number(node: Node<'_, '_>) -> Result { let mut serial = String::with_capacity(MAX_X509_SERIAL_NUMBER_TEXT_LEN); let mut trailing_whitespace = false; + let mut explicit_positive = false; for byte in node .children() @@ -1757,7 +1773,11 @@ fn collect_x509_serial_number(node: Node<'_, '_>) -> Result .flat_map(str::bytes) { if matches!(byte, b' ' | b'\t' | b'\r' | b'\n') { - trailing_whitespace |= !serial.is_empty(); + trailing_whitespace |= explicit_positive || !serial.is_empty(); + continue; + } + if byte == b'+' && serial.is_empty() && !explicit_positive && !trailing_whitespace { + explicit_positive = true; continue; } if trailing_whitespace || !byte.is_ascii_digit() { @@ -1956,9 +1976,9 @@ mod tests { {cert_base64} - C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), CN=Test Key rsa-2048 + CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US - C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), OU=Second level CA, CN=Aleksey Sanin, Email=xmlsec@aleksey.com + Email=xmlsec@aleksey.com,CN=Aleksey Sanin,OU=Second level CA,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US 680572598617295163017172295025714171905498632019 bcOXN/nsVl8GatRbcKrPbzIbw0Y= @@ -1992,14 +2012,14 @@ mod tests { assert_eq!( x509_info.subject_names, vec![ - "C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), CN=Test Key rsa-2048" + "CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US" .to_string() ] ); assert_eq!( x509_info.issuer_serials, vec![( - "C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), OU=Second level CA, CN=Aleksey Sanin, Email=xmlsec@aleksey.com".to_string(), + "Email=xmlsec@aleksey.com,CN=Aleksey Sanin,OU=Second level CA,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US".to_string(), "680572598617295163017172295025714171905498632019".to_string() )] ); @@ -2496,7 +2516,7 @@ BA== r#" - C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), OU=Second level CA, CN=Aleksey Sanin, Email=xmlsec@aleksey.com + Email=xmlsec@aleksey.com,CN=Aleksey Sanin,OU=Second level CA,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US 680572598617295163017172295025714171905498632019 {root} @@ -2526,7 +2546,7 @@ BA== let xml = format!( r#" - C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), CN=Test Key rsa-2048 + CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US 0X0XrEVCio75sBcl1TxymJ2IOiU= {root} {intermediate} @@ -2560,7 +2580,7 @@ BA== r#" - C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), OU=Second level CA, CN=Aleksey Sanin, Email=xmlsec@aleksey.com + Email=xmlsec@aleksey.com,CN=Aleksey Sanin,OU=Second level CA,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US 680572598617295163017172295025714171905498632019 {root} @@ -2626,7 +2646,7 @@ BA== let xml = format!( r#" - C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), CN=Test Key rsa-2048 + CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US CN=Not In The Embedded Chain {cert} @@ -2648,9 +2668,9 @@ BA== let xml = format!( r#" - C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), CN=Test Key rsa-2048 + CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US - C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), OU=Second level CA, CN=Aleksey Sanin, Email=xmlsec@aleksey.com + Email=xmlsec@aleksey.com,CN=Aleksey Sanin,OU=Second level CA,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US not-a-decimal-serial {cert} @@ -2671,7 +2691,7 @@ BA== let xml = format!( r#" - C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), CN=Test Key rsa-2048 + CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US AQIDBA== {cert} @@ -2692,7 +2712,7 @@ BA== let xml = format!( r#" - C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), CN=Test Key rsa-2048 + CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US 60zMLKCfzQ3qnXAzABzRNpdgQ8Q= {first_cert} {second_cert} @@ -2756,10 +2776,14 @@ BA== x509_serial_decimal_to_hex("0000000000000000000000000000000000000000000000001"), Some("01".into()) ); + assert_eq!(x509_serial_decimal_to_hex("+1"), Some("01".into())); for invalid in [ "", - "+1", + "0", + "000", + "+0", + "++1", "-1", "1a", "00000000000000000000000000000000000000000000000001", @@ -2789,6 +2813,14 @@ BA== }; assert_eq!(x509.issuer_serials[0].1, max_serial); + let explicit_positive = valid.replace(max_serial, "+42"); + let doc = Document::parse(&explicit_positive).unwrap(); + let parsed = parse_key_info(doc.root_element()).unwrap(); + let KeyInfoSource::X509Data(x509) = &parsed.sources[0] else { + panic!("expected X509Data source"); + }; + assert_eq!(x509.issuer_serials[0].1, "42"); + let overflow = valid.replace( max_serial, "730750818665451459101842416358141509827966271488", @@ -2801,6 +2833,20 @@ BA== )); } + #[test] + fn distinguished_name_matching_preserves_rdn_order() { + // RFC 4514 permits alternate encodings within an RDN, but reversing + // the RDN sequence identifies a different hierarchical name. + assert!(distinguished_names_equal( + "CN=leaf, O=example", + "CN=leaf,O=example" + )); + assert!(!distinguished_names_equal( + "CN=leaf,O=example", + "O=example,CN=leaf" + )); + } + #[test] fn parse_key_info_accepts_large_textual_x509_entries_within_entry_budget() { let issuer_name = "C".repeat(MAX_X509_ISSUER_NAME_TEXT_LEN); diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index 7f2a3ae1..0b01acec 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -1094,11 +1094,6 @@ fn materialize_retrieval_methods( if !allowed_uri_types.allows(&uri) { return Err(SignatureVerificationPipelineError::DisallowedUri { uri }); } - if transforms != RetrievalMethodTransforms::X509DataNodeSetFilter { - return Err(SignatureVerificationPipelineError::InvalidStructure { - reason: "X509Data RetrievalMethod requires the supported XPath selection", - }); - } let id = same_document_reference_id(&uri).ok_or( SignatureVerificationPipelineError::InvalidStructure { reason: "X509Data RetrievalMethod requires a same-document URI", @@ -1109,7 +1104,26 @@ fn materialize_retrieval_methods( reason: "X509Data RetrievalMethod target is missing or ambiguous", }, )?; - let node = select_retrieved_x509_data_root(target)?; + let node = match transforms { + RetrievalMethodTransforms::None + if target.has_tag_name((XMLDSIG_NS, "X509Data")) => + { + target + } + RetrievalMethodTransforms::None => { + return Err(SignatureVerificationPipelineError::InvalidStructure { + reason: "untransformed X509Data RetrievalMethod must target X509Data directly", + }); + } + RetrievalMethodTransforms::X509DataNodeSetFilter => { + select_retrieved_x509_data_root(target)? + } + RetrievalMethodTransforms::Unsupported => { + return Err(SignatureVerificationPipelineError::InvalidStructure { + reason: "X509Data RetrievalMethod contains unsupported transforms", + }); + } + }; let data = parse_x509_data_dispatch_with_budget(node, &mut total_binary_len) .map_err(SignatureVerificationPipelineError::ParseKeyInfo)?; materialized.push(super::parse::KeyInfoSource::X509Data(data)); @@ -2635,6 +2649,65 @@ mod tests { } } + #[test] + fn retrieval_method_materializes_direct_untransformed_x509_data() { + // A typed RetrievalMethod may point directly at the XML structure it + // identifies; no transform is needed when X509Data is the URI root. + let xml = r##" + + CN=leaf + "##; + let document = Document::parse(xml).unwrap(); + let key_info_node = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) + .unwrap(); + let mut key_info = parse_key_info(key_info_node).unwrap(); + + materialize_retrieval_methods( + &mut key_info, + &UriReferenceResolver::new(&document), + None, + UriTypeSet::SAME_DOCUMENT, + ) + .expect("a direct X509Data target needs no transform"); + assert!(matches!( + key_info.sources.as_slice(), + [super::super::parse::KeyInfoSource::X509Data(info)] + if info.subject_names == ["CN=leaf"] + )); + } + + #[test] + fn retrieval_method_requires_xpath_for_x509_data_below_uri_root() { + // Without a transform the dereferenced holder, not its descendant, + // is the result and therefore cannot masquerade as typed X509Data. + let xml = r##" + + CN=leaf + "##; + let document = Document::parse(xml).unwrap(); + let key_info_node = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) + .unwrap(); + let mut key_info = parse_key_info(key_info_node).unwrap(); + + let error = materialize_retrieval_methods( + &mut key_info, + &UriReferenceResolver::new(&document), + None, + UriTypeSet::SAME_DOCUMENT, + ) + .expect_err("a wrapper target requires an explicit selection transform"); + assert!(matches!( + error, + SignatureVerificationPipelineError::InvalidStructure { + reason: "untransformed X509Data RetrievalMethod must target X509Data directly" + } + )); + } + #[test] fn retrieval_method_rejects_target_inside_external_x509_data_ancestor() { // XPath filtering cannot add an ancestor that was outside the URI's diff --git a/tests/common/xmlsec1.rs b/tests/common/xmlsec1.rs index d64cd89b..24250897 100644 --- a/tests/common/xmlsec1.rs +++ b/tests/common/xmlsec1.rs @@ -9,17 +9,31 @@ pub fn command() -> Command { } pub fn version_supports_interop(version: &str) -> bool { - version - .split_whitespace() - .find_map(|token| { - let mut components = token.split('.'); - Some(( - components.next()?.parse::().ok()?, - components.next()?.parse::().ok()?, - components.next()?.parse::().ok()?, - )) - }) - .is_some_and(|version| version >= REQUIRED_VERSION) + let mut tokens = version.split_whitespace(); + if tokens.next() != Some("xmlsec1") { + return false; + } + let Some(version) = tokens.next() else { + return false; + }; + let mut components = version.split('.'); + let parsed = ( + components + .next() + .and_then(|value| value.parse::().ok()), + components + .next() + .and_then(|value| value.parse::().ok()), + components + .next() + .and_then(|value| value.parse::().ok()), + ); + match parsed { + (Some(major), Some(minor), Some(patch)) if components.next().is_none() => { + (major, minor, patch) >= REQUIRED_VERSION + } + _ => false, + } } pub fn is_available() -> bool { diff --git a/tests/fixtures/xmldsig/README.md b/tests/fixtures/xmldsig/README.md index 61d3c1d8..d54304b0 100644 --- a/tests/fixtures/xmldsig/README.md +++ b/tests/fixtures/xmldsig/README.md @@ -31,9 +31,9 @@ signing. ### `merlin-xmldsig-twenty-three` -W3C/Merlin basic signature vectors. Some files intentionally remain outside -the supported algorithm set, such as DSA, and are accounted for as skips or -fail-closed cases by the donor verification suite. +W3C/Merlin basic signature vectors. DSA-SHA1 and HMAC-SHA1 are supported for +legacy verification, including XMLDSig's permitted HMAC truncation. Unsupported +DSA and HMAC variants remain fail-closed. ### `xmldsig11-interop-2012` @@ -50,7 +50,7 @@ Currently verified as valid: Currently fail-closed: -- HMAC algorithms. +- HMAC algorithms other than HMAC-SHA1. - SHA-224 digest or signature algorithms. - P-521 KeyValue resolution. - `KeyInfoReference` dereference. @@ -61,8 +61,9 @@ Currently fail-closed: XMLDSig Second Edition errata vectors. They exercise HMAC-SHA1, external URI references, XPath transforms, and Canonical XML 1.1. XPath and C14N 1.1 are -implemented; documents that additionally require HMAC, an external resource, -or an unsupported key source remain explicitly classified as fail-closed. +implemented; HMAC-SHA1 is supported for verification, while documents that +require another HMAC variant, an unavailable external resource, or an +unsupported key source remain explicitly classified as fail-closed. ### `merlin-xpath-filter2` diff --git a/tests/install_xmlsec1.rs b/tests/install_xmlsec1.rs new file mode 100644 index 00000000..17ed17c6 --- /dev/null +++ b/tests/install_xmlsec1.rs @@ -0,0 +1,99 @@ +#![cfg(unix)] + +//! Integration coverage for the pinned xmlsec1 installation workflow. + +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +struct TestDirectory(PathBuf); + +impl TestDirectory { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "xml-sec-install-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock must follow the Unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(&path).expect("temporary test directory must be creatable"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + + fn tool(&self, name: &str, source: &str) { + let path = self.path().join("tools").join(name); + std::fs::write(&path, source).expect("fake tool must be writable"); + let mut permissions = std::fs::metadata(&path) + .expect("fake tool metadata must be readable") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions).expect("fake tool must be executable"); + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).expect("temporary test directory must be removable"); + } +} + +#[test] +fn failed_install_replacement_restores_previous_xmlsec() { + // The staged directory move is the commit point. A failure there must + // leave the previously working installation intact rather than letting + // EXIT cleanup delete its backup. + let root = TestDirectory::new(); + let tools = root.path().join("tools"); + let prefix = root.path().join("xmlsec-prefix"); + std::fs::create_dir_all(prefix.join("bin")).expect("old installation must be creatable"); + std::fs::create_dir_all(&tools).expect("fake tool directory must be creatable"); + std::fs::write(prefix.join("sentinel"), "previous installation") + .expect("old installation sentinel must be writable"); + + root.tool( + "curl", + "#!/bin/sh\nwhile [ \"$1\" != \"--output\" ]; do shift; done\n: > \"$2\"\n", + ); + root.tool("sha256sum", "#!/bin/sh\nexit 0\n"); + root.tool( + "tar", + "#!/bin/sh\nwhile [ \"$1\" != \"--directory\" ]; do shift; done\nwork=$2\nsource=\"$work/xmlsec-5fdd47dc35753438bdc38b6e96c1a3805c67a483\"\nmkdir -p \"$source\"\nprintf '#!/bin/sh\\nexit 0\\n' > \"$source/autogen.sh\"\nchmod +x \"$source/autogen.sh\"\n", + ); + root.tool("nproc", "#!/bin/sh\nprintf '1\\n'\n"); + root.tool( + "make", + "#!/bin/sh\nfor arg in \"$@\"; do\n case \"$arg\" in DESTDIR=*) dest=${arg#DESTDIR=} ;; esac\ndone\nif [ -n \"${dest:-}\" ]; then\n mkdir -p \"$dest$XMLSEC1_PREFIX/bin\"\n printf '#!/bin/sh\\nexit 0\\n' > \"$dest$XMLSEC1_PREFIX/bin/xmlsec1\"\n chmod +x \"$dest$XMLSEC1_PREFIX/bin/xmlsec1\"\nfi\n", + ); + root.tool( + "mv", + "#!/bin/sh\ncount=0\n[ ! -f \"$MV_COUNT_FILE\" ] || count=$(cat \"$MV_COUNT_FILE\")\ncount=$((count + 1))\nprintf '%s\\n' \"$count\" > \"$MV_COUNT_FILE\"\n[ \"$count\" -ne 2 ] || exit 23\nexec /bin/mv \"$@\"\n", + ); + + let inherited_path = std::env::var_os("PATH").expect("test process must have PATH"); + let path = + std::env::join_paths(std::iter::once(tools).chain(std::env::split_paths(&inherited_path))) + .expect("test PATH must be joinable"); + let status = Command::new("bash") + .arg("scripts/install-xmlsec1.sh") + .env("XMLSEC1_PREFIX", &prefix) + .env("MV_COUNT_FILE", root.path().join("mv-count")) + .env("PATH", path) + .status() + .expect("installation script must run"); + + assert!( + !status.success(), + "injected staged move failure must propagate" + ); + assert_eq!( + std::fs::read_to_string(prefix.join("sentinel")) + .expect("previous installation must be restored"), + "previous installation" + ); +} diff --git a/tests/xmlsec1_interop.rs b/tests/xmlsec1_interop.rs index 5585b58f..a4524710 100644 --- a/tests/xmlsec1_interop.rs +++ b/tests/xmlsec1_interop.rs @@ -136,6 +136,18 @@ fn xmlsec1_version_gate_requires_pinned_snapshot() { "xmlsec1 1.2.37 (openssl)" )); assert!(!xmlsec1::version_supports_interop("xmlsec1 unknown")); + for malformed in [ + "OpenSSL 3.0.0", + "xmlsec1 unknown OpenSSL 3.0.0", + "xmlsec1 1.3", + "xmlsec1 1.3.13.1", + "prefix xmlsec1 1.3.13", + ] { + assert!( + !xmlsec1::version_supports_interop(malformed), + "malformed xmlsec1 version output {malformed:?} must fail closed" + ); + } } fn signed_payload_xml(key: &dyn SigningKey, builder: &SignatureBuilder) -> String { From f9e1e5fc331c926b7b90a9070d1e30e98c64b8ee Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Thu, 6 Aug 2026 11:16:17 +0300 Subject: [PATCH 11/26] fix(ci): verify immutable interop inputs - fetch and verify the pinned xmlsec1 Git object - pin workflow actions and bound fuzz runtime - keep X.509 name normalization on public APIs - cover source mismatch and rollback behavior --- .github/workflows/ci.yml | 42 ++++++++----- scripts/install-xmlsec1.sh | 27 ++++---- src/xmldsig/parse.rs | 5 +- tests/install_xmlsec1.rs | 126 ++++++++++++++++++++++++++----------- 4 files changed, 129 insertions(+), 71 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8426539f..011fd82e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + env: CARGO_TERM_COLOR: always RUSTFLAGS: -Dwarnings @@ -21,11 +24,13 @@ jobs: matrix: rust: [stable, "1.92.0"] steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: ${{ matrix.rust }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - run: cargo build --all-features build: @@ -43,11 +48,13 @@ jobs: matrix: rust: [stable, "1.92.0"] steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: ${{ matrix.rust }} - - uses: taiki-e/install-action@nextest + - uses: taiki-e/install-action@acdba816b0980ba6b63f1109f89a046e15fc301a # nextest - name: Refresh apt package index run: sudo apt-get update - name: Build pinned xmlsec1 for XMLDSig interop tests @@ -55,7 +62,7 @@ jobs: sudo apt-get install --yes autoconf automake build-essential libltdl-dev libssl-dev libtool libxml2-dev pkg-config scripts/install-xmlsec1.sh "$XMLSEC1_BIN" --version - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - run: cargo nextest run --all-features - run: cargo test --doc --all-features @@ -69,28 +76,35 @@ jobs: clippy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: components: clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - run: cargo clippy --all-features --all-targets -- -D warnings fmt: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: components: rustfmt - run: cargo fmt --all -- --check - run: cargo fmt --manifest-path fuzz/Cargo.toml -- --check fuzz-smoke: + timeout-minutes: 20 runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@nightly + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@7c8d7d138f5c09cef361f8214cf96882cd029cdb # nightly # cargo-fuzz 0.13.1's published lockfile pins rustix 0.36.5, which no # longer compiles on current nightly. Keep the tool version pinned while # allowing compatible patch-level transitive dependencies. diff --git a/scripts/install-xmlsec1.sh b/scripts/install-xmlsec1.sh index b59f0db4..fb85f274 100755 --- a/scripts/install-xmlsec1.sh +++ b/scripts/install-xmlsec1.sh @@ -3,7 +3,7 @@ set -euo pipefail readonly XMLSEC1_VERSION="1.3.13" readonly XMLSEC1_COMMIT="5fdd47dc35753438bdc38b6e96c1a3805c67a483" -readonly XMLSEC1_ARCHIVE_SHA256="0917b7304ee2452e2110a60d18e501825c132fa5857558e0308d40457fa0992f" +readonly XMLSEC1_REPOSITORY="https://github.com/lsh123/xmlsec.git" repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" prefix="${XMLSEC1_PREFIX:-$repo_root/.tools/xmlsec1-${XMLSEC1_VERSION}-${XMLSEC1_COMMIT:0:12}}" @@ -46,26 +46,21 @@ cleanup() { exit "$status" } trap cleanup EXIT -archive="$work_dir/xmlsec.tar.gz" -source_dir="$work_dir/xmlsec-$XMLSEC1_COMMIT" +source_dir="$work_dir/xmlsec" build_dir="$work_dir/build" stage_dir="$work_dir/stage" -curl --fail --location --retry 3 --output "$archive" \ - "https://codeload.github.com/lsh123/xmlsec/tar.gz/$XMLSEC1_COMMIT" - -if command -v sha256sum >/dev/null 2>&1; then - printf '%s %s\n' "$XMLSEC1_ARCHIVE_SHA256" "$archive" | sha256sum --check - -else - actual_sha256="$(shasum -a 256 "$archive" | awk '{print $1}')" - if [[ "$actual_sha256" != "$XMLSEC1_ARCHIVE_SHA256" ]]; then - printf 'xmlsec1 archive checksum mismatch: expected %s, got %s\n' \ - "$XMLSEC1_ARCHIVE_SHA256" "$actual_sha256" >&2 - exit 1 - fi +git init "$source_dir" +git -C "$source_dir" remote add origin "$XMLSEC1_REPOSITORY" +git -C "$source_dir" fetch --depth=1 origin "$XMLSEC1_COMMIT" +fetched_commit="$(git -C "$source_dir" rev-parse FETCH_HEAD)" +if [[ "$fetched_commit" != "$XMLSEC1_COMMIT" ]]; then + printf 'xmlsec1 source revision mismatch: expected %s, got %s\n' \ + "$XMLSEC1_COMMIT" "$fetched_commit" >&2 + exit 1 fi +git -C "$source_dir" checkout --detach "$XMLSEC1_COMMIT" -tar --extract --file "$archive" --directory "$work_dir" mkdir -p "$build_dir" "$stage_dir" OBJ_DIR="$build_dir" "$source_dir/autogen.sh" \ --prefix="$prefix" \ diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index 34e445ac..8b7f9d04 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -1512,9 +1512,8 @@ pub(crate) fn parse_x509_certificate(cert_der: &[u8]) -> Result) -> String { - let mut rdns = name.iter_rdn().cloned().collect::>(); - rdns.reverse(); - X509Name::new(rdns, name.as_raw()).to_string() + let rdns = name.iter_rdn().cloned().collect::>(); + rdns.into_iter().rev().collect::>().to_string() } fn format_x509_serial_hex(serial: &[u8]) -> String { diff --git a/tests/install_xmlsec1.rs b/tests/install_xmlsec1.rs index 17ed17c6..ad4c82d7 100644 --- a/tests/install_xmlsec1.rs +++ b/tests/install_xmlsec1.rs @@ -43,57 +43,107 @@ impl Drop for TestDirectory { } } +struct InstallHarness { + root: TestDirectory, + tools: PathBuf, + prefix: PathBuf, +} + +impl InstallHarness { + fn new() -> Self { + let root = TestDirectory::new(); + let tools = root.path().join("tools"); + let prefix = root.path().join("xmlsec-prefix"); + + std::fs::create_dir_all(prefix.join("bin")).expect("old installation must be creatable"); + std::fs::create_dir_all(&tools).expect("fake tool directory must be creatable"); + std::fs::write(prefix.join("sentinel"), "previous installation") + .expect("old installation sentinel must be writable"); + + root.tool( + "git", + "#!/bin/sh\nif [ \"$1\" = \"init\" ]; then mkdir -p \"$2\"; exit 0; fi\n[ \"$1\" = \"-C\" ] || exit 2\nsource=$2\nshift 2\ncommand=$1\nshift\ncase \"$command\" in\n remote) exit 0 ;;\n fetch)\n for argument in \"$@\"; do requested=$argument; done\n printf '%s\\n' \"${GIT_REPORTED_COMMIT:-$requested}\" > \"$GIT_FETCHED_COMMIT_FILE\"\n printf '#!/bin/sh\\nexit 0\\n' > \"$source/autogen.sh\"\n chmod +x \"$source/autogen.sh\"\n ;;\n rev-parse) cat \"$GIT_FETCHED_COMMIT_FILE\" ;;\n checkout) exit 0 ;;\n *) exit 2 ;;\nesac\n", + ); + root.tool("nproc", "#!/bin/sh\nprintf '1\\n'\n"); + root.tool( + "make", + "#!/bin/sh\nfor arg in \"$@\"; do\n case \"$arg\" in DESTDIR=*) dest=${arg#DESTDIR=} ;; esac\ndone\nif [ -n \"${dest:-}\" ]; then\n mkdir -p \"$dest$XMLSEC1_PREFIX/bin\"\n printf '#!/bin/sh\\nexit 0\\n' > \"$dest$XMLSEC1_PREFIX/bin/xmlsec1\"\n chmod +x \"$dest$XMLSEC1_PREFIX/bin/xmlsec1\"\nfi\n", + ); + root.tool( + "mv", + "#!/bin/sh\ncount=0\n[ ! -f \"$MV_COUNT_FILE\" ] || count=$(cat \"$MV_COUNT_FILE\")\ncount=$((count + 1))\nprintf '%s\\n' \"$count\" > \"$MV_COUNT_FILE\"\n[ \"${MV_FAIL_ON:-0}\" -ne \"$count\" ] || exit 23\nexec /bin/mv \"$@\"\n", + ); + + Self { + root, + tools, + prefix, + } + } + + fn run( + &self, + mv_fail_on: Option, + reported_commit: Option<&str>, + ) -> std::process::ExitStatus { + let inherited_path = std::env::var_os("PATH").expect("test process must have PATH"); + let path = std::env::join_paths( + std::iter::once(self.tools.clone()).chain(std::env::split_paths(&inherited_path)), + ) + .expect("test PATH must be joinable"); + let mut command = Command::new("bash"); + command + .arg("scripts/install-xmlsec1.sh") + .env("XMLSEC1_PREFIX", &self.prefix) + .env( + "GIT_FETCHED_COMMIT_FILE", + self.root.path().join("fetched-commit"), + ) + .env("MV_COUNT_FILE", self.root.path().join("mv-count")) + .env("PATH", path); + if let Some(mv_fail_on) = mv_fail_on { + command.env("MV_FAIL_ON", mv_fail_on.to_string()); + } + if let Some(reported_commit) = reported_commit { + command.env("GIT_REPORTED_COMMIT", reported_commit); + } + command.status().expect("installation script must run") + } +} + #[test] fn failed_install_replacement_restores_previous_xmlsec() { // The staged directory move is the commit point. A failure there must // leave the previously working installation intact rather than letting // EXIT cleanup delete its backup. - let root = TestDirectory::new(); - let tools = root.path().join("tools"); - let prefix = root.path().join("xmlsec-prefix"); - std::fs::create_dir_all(prefix.join("bin")).expect("old installation must be creatable"); - std::fs::create_dir_all(&tools).expect("fake tool directory must be creatable"); - std::fs::write(prefix.join("sentinel"), "previous installation") - .expect("old installation sentinel must be writable"); - - root.tool( - "curl", - "#!/bin/sh\nwhile [ \"$1\" != \"--output\" ]; do shift; done\n: > \"$2\"\n", - ); - root.tool("sha256sum", "#!/bin/sh\nexit 0\n"); - root.tool( - "tar", - "#!/bin/sh\nwhile [ \"$1\" != \"--directory\" ]; do shift; done\nwork=$2\nsource=\"$work/xmlsec-5fdd47dc35753438bdc38b6e96c1a3805c67a483\"\nmkdir -p \"$source\"\nprintf '#!/bin/sh\\nexit 0\\n' > \"$source/autogen.sh\"\nchmod +x \"$source/autogen.sh\"\n", - ); - root.tool("nproc", "#!/bin/sh\nprintf '1\\n'\n"); - root.tool( - "make", - "#!/bin/sh\nfor arg in \"$@\"; do\n case \"$arg\" in DESTDIR=*) dest=${arg#DESTDIR=} ;; esac\ndone\nif [ -n \"${dest:-}\" ]; then\n mkdir -p \"$dest$XMLSEC1_PREFIX/bin\"\n printf '#!/bin/sh\\nexit 0\\n' > \"$dest$XMLSEC1_PREFIX/bin/xmlsec1\"\n chmod +x \"$dest$XMLSEC1_PREFIX/bin/xmlsec1\"\nfi\n", + let harness = InstallHarness::new(); + let status = harness.run(Some(2), None); + + assert!( + !status.success(), + "injected staged move failure must propagate" ); - root.tool( - "mv", - "#!/bin/sh\ncount=0\n[ ! -f \"$MV_COUNT_FILE\" ] || count=$(cat \"$MV_COUNT_FILE\")\ncount=$((count + 1))\nprintf '%s\\n' \"$count\" > \"$MV_COUNT_FILE\"\n[ \"$count\" -ne 2 ] || exit 23\nexec /bin/mv \"$@\"\n", + assert_eq!( + std::fs::read_to_string(harness.prefix.join("sentinel")) + .expect("previous installation must be restored"), + "previous installation" ); +} - let inherited_path = std::env::var_os("PATH").expect("test process must have PATH"); - let path = - std::env::join_paths(std::iter::once(tools).chain(std::env::split_paths(&inherited_path))) - .expect("test PATH must be joinable"); - let status = Command::new("bash") - .arg("scripts/install-xmlsec1.sh") - .env("XMLSEC1_PREFIX", &prefix) - .env("MV_COUNT_FILE", root.path().join("mv-count")) - .env("PATH", path) - .status() - .expect("installation script must run"); +#[test] +fn installer_rejects_source_revision_mismatch() { + // Artifact compression is not source identity. The installer must reject + // a fetch whose resolved Git object differs from the pinned commit. + let harness = InstallHarness::new(); + let status = harness.run(None, Some("0000000000000000000000000000000000000000")); assert!( !status.success(), - "injected staged move failure must propagate" + "mismatched source revision must fail closed" ); assert_eq!( - std::fs::read_to_string(prefix.join("sentinel")) - .expect("previous installation must be restored"), + std::fs::read_to_string(harness.prefix.join("sentinel")) + .expect("failed source verification must preserve the previous installation"), "previous installation" ); } From 6905e49c11a4799751fb1ddaf4829ecf1462eac4 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Thu, 6 Aug 2026 11:18:53 +0300 Subject: [PATCH 12/26] fix(ci): use maintained action refs Keep trusted actions on reviewable version channels while retaining read-only workflow permissions, credential-free checkouts, and the fuzz runtime budget. --- .github/workflows/ci.yml | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 011fd82e..935e2d1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,13 +24,13 @@ jobs: matrix: rust: [stable, "1.92.0"] steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@v7 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + - uses: dtolnay/rust-toolchain@stable with: toolchain: ${{ matrix.rust }} - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - uses: Swatinem/rust-cache@v2 - run: cargo build --all-features build: @@ -48,13 +48,13 @@ jobs: matrix: rust: [stable, "1.92.0"] steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@v7 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + - uses: dtolnay/rust-toolchain@stable with: toolchain: ${{ matrix.rust }} - - uses: taiki-e/install-action@acdba816b0980ba6b63f1109f89a046e15fc301a # nextest + - uses: taiki-e/install-action@nextest - name: Refresh apt package index run: sudo apt-get update - name: Build pinned xmlsec1 for XMLDSig interop tests @@ -62,7 +62,7 @@ jobs: sudo apt-get install --yes autoconf automake build-essential libltdl-dev libssl-dev libtool libxml2-dev pkg-config scripts/install-xmlsec1.sh "$XMLSEC1_BIN" --version - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - uses: Swatinem/rust-cache@v2 - run: cargo nextest run --all-features - run: cargo test --doc --all-features @@ -76,22 +76,22 @@ jobs: clippy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@v7 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + - uses: dtolnay/rust-toolchain@stable with: components: clippy - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - uses: Swatinem/rust-cache@v2 - run: cargo clippy --all-features --all-targets -- -D warnings fmt: runs-on: ubuntu-latest steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@v7 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + - uses: dtolnay/rust-toolchain@stable with: components: rustfmt - run: cargo fmt --all -- --check @@ -101,10 +101,10 @@ jobs: timeout-minutes: 20 runs-on: ubuntu-latest steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@v7 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@7c8d7d138f5c09cef361f8214cf96882cd029cdb # nightly + - uses: dtolnay/rust-toolchain@nightly # cargo-fuzz 0.13.1's published lockfile pins rustix 0.36.5, which no # longer compiles on current nightly. Keep the tool version pinned while # allowing compatible patch-level transitive dependencies. From eb1840eab847b4131cfcbaa2541382890e3cf8a0 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Thu, 6 Aug 2026 11:38:36 +0300 Subject: [PATCH 13/26] fix(xmldsig): harden interop setup - update direct dependencies to base64 0.23 and x509-cert 0.3 - compare certificate selectors through RFC 4514 structured names - remove failed first-time xmlsec1 installations transactionally --- Cargo.toml | 4 +- scripts/install-xmlsec1.sh | 20 ++++--- src/xmldsig/parse.rs | 120 ++++++++++++++++++++++++++++++++----- tests/install_xmlsec1.rs | 41 +++++++++++-- 4 files changed, 156 insertions(+), 29 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e52f7f0e..cb4b8274 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,11 +42,12 @@ cbc = { version = "0.2.1", optional = true } # X.509 certificates x509-parser = { version = "0.18", features = ["verify"], optional = true } +x509-cert = { version = "0.3", default-features = false, optional = true } der = { version = "0.8", optional = true } crypto-bigint = { version = "0.7", optional = true } # Base64 encoding/decoding -base64 = "0.22" +base64 = "0.23" # Error handling thiserror = "2" @@ -75,6 +76,7 @@ xmldsig = [ # XML Digital Signatures (sign + verify) "dep:sxd-document-no-unsafe", "dep:sxd-xpath-no-unsafe", "dep:x509-parser", + "dep:x509-cert", ] xmlenc = [ # XML Encryption (encrypt + decrypt) "dep:aes", diff --git a/scripts/install-xmlsec1.sh b/scripts/install-xmlsec1.sh index fb85f274..bc0b3f37 100755 --- a/scripts/install-xmlsec1.sh +++ b/scripts/install-xmlsec1.sh @@ -23,6 +23,7 @@ fi work_dir="$(mktemp -d "${TMPDIR:-/tmp}/xmlsec1-${XMLSEC1_VERSION}.XXXXXX")" previous_install="$work_dir/previous-install" had_previous_install=false +promoted_install=false cleanup() { local status=$? @@ -31,13 +32,17 @@ cleanup() { # Keep replacement transactional through the version smoke test. The # staged move is not a commit if installation or validation fails. - if (( status != 0 )) && [[ "$had_previous_install" == true ]]; then - rm -rf "$prefix" - if ! mv "$previous_install" "$prefix"; then - printf 'failed to restore previous xmlsec1 installation at %s; backup remains at %s\n' \ - "$prefix" "$previous_install" >&2 - status=1 - remove_work_dir=false + if (( status != 0 )); then + if [[ "$promoted_install" == true ]]; then + rm -rf "$prefix" + fi + if [[ "$had_previous_install" == true ]]; then + if ! mv "$previous_install" "$prefix"; then + printf 'failed to restore previous xmlsec1 installation at %s; backup remains at %s\n' \ + "$prefix" "$previous_install" >&2 + status=1 + remove_work_dir=false + fi fi fi if [[ "$remove_work_dir" == true ]]; then @@ -84,6 +89,7 @@ if [[ -e "$prefix" ]]; then had_previous_install=true fi mv "$staged_prefix" "$prefix" +promoted_install=true printf '%s\n' "$XMLSEC1_COMMIT" > "$marker" if [[ "$(uname -s)" == "Darwin" ]]; then diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index 8b7f9d04..8844c26b 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -16,7 +16,9 @@ //! //! ``` +use der::Decode; use roxmltree::{Document, Node}; +use x509_cert::name::Name; use x509_parser::extensions::ParsedExtension; use x509_parser::prelude::FromDer; use x509_parser::public_key::PublicKey; @@ -1349,16 +1351,73 @@ pub(crate) fn x509_selector_categories_match_chain( } fn distinguished_names_equal(left: &str, right: &str) -> bool { - fn components(name: &str) -> Vec<&str> { - name.trim() - .split(',') - .map(str::trim) - .filter(|component| !component.is_empty()) - .collect() - } - let left = components(left); - let right = components(right); - left == right + fn trailing_whitespace_is_escaped(value: &str) -> bool { + let Some(prefix) = value.as_bytes().strip_suffix(b" ") else { + return false; + }; + prefix + .iter() + .rev() + .take_while(|byte| **byte == b'\\') + .count() + % 2 + == 1 + } + + fn remove_separator_padding(name: &str) -> String { + let mut normalized = String::with_capacity(name.len()); + let mut chars = name + .trim_start_matches([' ', '\t', '\r', '\n']) + .chars() + .peekable(); + let mut escaped = false; + + while let Some(ch) = chars.next() { + if escaped { + normalized.push(ch); + escaped = false; + continue; + } + if ch == '\\' { + normalized.push(ch); + escaped = true; + continue; + } + if matches!(ch, ',' | '+') { + while normalized + .chars() + .next_back() + .is_some_and(|last| matches!(last, ' ' | '\t' | '\r' | '\n')) + && !trailing_whitespace_is_escaped(&normalized) + { + normalized.pop(); + } + normalized.push(ch); + while chars + .next_if(|next| matches!(next, ' ' | '\t' | '\r' | '\n')) + .is_some() + {} + continue; + } + normalized.push(ch); + } + + while normalized + .chars() + .next_back() + .is_some_and(|last| matches!(last, ' ' | '\t' | '\r' | '\n')) + && !trailing_whitespace_is_escaped(&normalized) + { + normalized.pop(); + } + + normalized + } + + let parse_name = |value: &str| remove_separator_padding(value).parse::().ok(); + parse_name(left) + .zip(parse_name(right)) + .is_some_and(|(left, right)| left == right) } fn ensure_x509_data_entry_budget(info: &X509DataInfo) -> Result<(), ParseError> { @@ -1452,8 +1511,8 @@ pub(crate) fn parse_x509_certificate(cert_der: &[u8]) -> Result Result) -> String { - let rdns = name.iter_rdn().cloned().collect::>(); - rdns.into_iter().rev().collect::>().to_string() +fn x509_name_to_rfc4514(name: &X509Name<'_>) -> Result { + let name = Name::from_der(name.as_raw()).map_err(|error| { + ParseError::InvalidStructure(format!( + "X509Certificate distinguished name is invalid DER: {error}" + )) + })?; + Ok(name.to_string()) } fn format_x509_serial_hex(serial: &[u8]) -> String { @@ -2846,6 +2909,33 @@ BA== )); } + #[test] + fn distinguished_name_matching_handles_rfc4514_escaped_values() { + // Certificate values containing RFC 4514 separators and boundary spaces + // must remain one attribute when matched against an XMLDSig selector. + let value = " leading,plus+equals=slash\\trailing "; + let mut params = rcgen::CertificateParams::new(Vec::new()).unwrap(); + params + .distinguished_name + .push(rcgen::DnType::CommonName, value); + let key = rcgen::KeyPair::generate().unwrap(); + let certificate = params.self_signed(&key).unwrap(); + let parsed = parse_x509_certificate(certificate.der()).unwrap(); + + assert_eq!( + parsed.subject_dn, + r"CN=\ leading\,plus\+equals=slash\\trailing\ " + ); + assert!(distinguished_names_equal( + r"CN=\ leading\,plus\+equals=slash\\trailing\ ", + &parsed.subject_dn + )); + assert!(distinguished_names_equal( + "\n CN=\\ leading\\,plus\\+equals=slash\\\\trailing\\ \n", + &parsed.subject_dn + )); + } + #[test] fn parse_key_info_accepts_large_textual_x509_entries_within_entry_budget() { let issuer_name = "C".repeat(MAX_X509_ISSUER_NAME_TEXT_LEN); diff --git a/tests/install_xmlsec1.rs b/tests/install_xmlsec1.rs index ad4c82d7..1f27a334 100644 --- a/tests/install_xmlsec1.rs +++ b/tests/install_xmlsec1.rs @@ -51,14 +51,25 @@ struct InstallHarness { impl InstallHarness { fn new() -> Self { + Self::with_previous_install(true) + } + + fn without_previous_install() -> Self { + Self::with_previous_install(false) + } + + fn with_previous_install(has_previous_install: bool) -> Self { let root = TestDirectory::new(); let tools = root.path().join("tools"); let prefix = root.path().join("xmlsec-prefix"); - std::fs::create_dir_all(prefix.join("bin")).expect("old installation must be creatable"); + if has_previous_install { + std::fs::create_dir_all(prefix.join("bin")) + .expect("old installation must be creatable"); + std::fs::write(prefix.join("sentinel"), "previous installation") + .expect("old installation sentinel must be writable"); + } std::fs::create_dir_all(&tools).expect("fake tool directory must be creatable"); - std::fs::write(prefix.join("sentinel"), "previous installation") - .expect("old installation sentinel must be writable"); root.tool( "git", @@ -67,7 +78,7 @@ impl InstallHarness { root.tool("nproc", "#!/bin/sh\nprintf '1\\n'\n"); root.tool( "make", - "#!/bin/sh\nfor arg in \"$@\"; do\n case \"$arg\" in DESTDIR=*) dest=${arg#DESTDIR=} ;; esac\ndone\nif [ -n \"${dest:-}\" ]; then\n mkdir -p \"$dest$XMLSEC1_PREFIX/bin\"\n printf '#!/bin/sh\\nexit 0\\n' > \"$dest$XMLSEC1_PREFIX/bin/xmlsec1\"\n chmod +x \"$dest$XMLSEC1_PREFIX/bin/xmlsec1\"\nfi\n", + "#!/bin/sh\nfor arg in \"$@\"; do\n case \"$arg\" in DESTDIR=*) dest=${arg#DESTDIR=} ;; esac\ndone\nif [ -n \"${dest:-}\" ]; then\n mkdir -p \"$dest$XMLSEC1_PREFIX/bin\"\n printf '#!/bin/sh\\nexit \"${XMLSEC1_SMOKE_EXIT:-0}\"\\n' > \"$dest$XMLSEC1_PREFIX/bin/xmlsec1\"\n chmod +x \"$dest$XMLSEC1_PREFIX/bin/xmlsec1\"\nfi\n", ); root.tool( "mv", @@ -85,6 +96,7 @@ impl InstallHarness { &self, mv_fail_on: Option, reported_commit: Option<&str>, + smoke_exit: Option, ) -> std::process::ExitStatus { let inherited_path = std::env::var_os("PATH").expect("test process must have PATH"); let path = std::env::join_paths( @@ -107,6 +119,9 @@ impl InstallHarness { if let Some(reported_commit) = reported_commit { command.env("GIT_REPORTED_COMMIT", reported_commit); } + if let Some(smoke_exit) = smoke_exit { + command.env("XMLSEC1_SMOKE_EXIT", smoke_exit.to_string()); + } command.status().expect("installation script must run") } } @@ -117,7 +132,7 @@ fn failed_install_replacement_restores_previous_xmlsec() { // leave the previously working installation intact rather than letting // EXIT cleanup delete its backup. let harness = InstallHarness::new(); - let status = harness.run(Some(2), None); + let status = harness.run(Some(2), None, None); assert!( !status.success(), @@ -135,7 +150,7 @@ fn installer_rejects_source_revision_mismatch() { // Artifact compression is not source identity. The installer must reject // a fetch whose resolved Git object differs from the pinned commit. let harness = InstallHarness::new(); - let status = harness.run(None, Some("0000000000000000000000000000000000000000")); + let status = harness.run(None, Some("0000000000000000000000000000000000000000"), None); assert!( !status.success(), @@ -147,3 +162,17 @@ fn installer_rejects_source_revision_mismatch() { "previous installation" ); } + +#[test] +fn failed_first_install_removes_promoted_prefix() { + // A failed smoke test must not leave an executable plus source marker that + // a later invocation could mistake for a validated installation. + let harness = InstallHarness::without_previous_install(); + let status = harness.run(None, None, Some(17)); + + assert!(!status.success(), "injected smoke failure must propagate"); + assert!( + !harness.prefix.exists(), + "failed first installation must remove its promoted prefix" + ); +} From 3bc02d166b4fe82102a7f265d5e102d489d10b77 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Thu, 6 Aug 2026 14:01:30 +0300 Subject: [PATCH 14/26] fix(xmldsig): honor matching context - Apply X.520 DN matching and XML Base URI resolution - Separate selector lookup certificates from trust anchors --- Cargo.toml | 2 + docs/xmldsig.md | 11 +- fuzz/fuzz_targets/xmldsig_verify.rs | 2 +- src/c14n/mod.rs | 2 +- src/xmldsig/keys.rs | 121 ++++++++++----- src/xmldsig/parse.rs | 101 ++++++++++++- src/xmldsig/uri.rs | 23 +++ src/xmldsig/verify.rs | 198 ++++++++++++++++++++++++- tests/donor_full_verification_suite.rs | 2 +- tests/merlin_interop.rs | 14 +- 10 files changed, 413 insertions(+), 63 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index cb4b8274..7cfe8722 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ cbc = { version = "0.2.1", optional = true } # X.509 certificates x509-parser = { version = "0.18", features = ["verify"], optional = true } x509-cert = { version = "0.3", default-features = false, optional = true } +x520-stringprep = { version = "1", features = ["alloc"], optional = true } der = { version = "0.8", optional = true } crypto-bigint = { version = "0.7", optional = true } @@ -77,6 +78,7 @@ xmldsig = [ # XML Digital Signatures (sign + verify) "dep:sxd-xpath-no-unsafe", "dep:x509-parser", "dep:x509-cert", + "dep:x520-stringprep", ] xmlenc = [ # XML Encryption (encrypt + decrypt) "dep:aes", diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 53943670..2ffd5af6 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -24,8 +24,11 @@ interoperating with legacy libxmlsec1 `here()` behavior can explicitly select ## Verification Policy -For production verification, configure `KeyResolverConfig` with explicit trust anchors when -certificate-chain validation is required. Embedded certificates provide key material; they do +For production verification, configure `KeyResolverConfig::lookup_certs` with untrusted +certificates that selector-only `X509Data` may address, and configure +`KeyResolverConfig::trusted_certs` only with explicit trust anchors. With chain validation +enabled, a selected lookup certificate must chain to a trusted anchor. A trusted certificate +selected directly remains an anchor, while embedded certificates provide key material and do not become trusted merely because they appear in ``. `VerifyResult::status` reports core validation: `Valid` means the cryptographic signature and @@ -54,7 +57,9 @@ complete map to 32 MiB. External key retrieval has an independent policy boundar also opt in with `VerifyContext::allowed_retrieval_method_uri_types`. Allowing external signed payloads never implicitly allows external key material. `RetrievalMethod` currently accepts untransformed external `rawX509Certificate` data and the Merlin same-document `X509Data` XPath -selection. Other retrieval transform chains fail closed instead of being ignored. +selection. Relative external `Reference` and `RetrievalMethod` URIs are resolved against the +owning element's effective `xml:base` using RFC 3986 before lookup, so resource-map keys must use +that resolved URI. Other retrieval transform chains fail closed instead of being ignored. Internal DTD declarations are disabled by default and require `VerifyContext::allow_internal_dtd(true)`. External entity resolution remains disabled. XSLT is diff --git a/fuzz/fuzz_targets/xmldsig_verify.rs b/fuzz/fuzz_targets/xmldsig_verify.rs index 65cb34a9..076de9c8 100644 --- a/fuzz/fuzz_targets/xmldsig_verify.rs +++ b/fuzz/fuzz_targets/xmldsig_verify.rs @@ -12,7 +12,7 @@ fn resolver() -> &'static DefaultKeyResolver { static RESOLVER: OnceLock = OnceLock::new(); RESOLVER.get_or_init(|| { DefaultKeyResolver::new(KeyResolverConfig { - trusted_certs: vec![TRUSTED_CERTIFICATE.to_vec()], + lookup_certs: vec![TRUSTED_CERTIFICATE.to_vec()], ..KeyResolverConfig::default() }) }) diff --git a/src/c14n/mod.rs b/src/c14n/mod.rs index 633ddd4c..75fc60d2 100644 --- a/src/c14n/mod.rs +++ b/src/c14n/mod.rs @@ -28,7 +28,7 @@ pub(crate) mod ns_exclusive; pub(crate) mod ns_inclusive; pub(crate) mod prefix; pub(crate) mod serialize; -mod xml_base; +pub(crate) mod xml_base; use std::collections::HashSet; diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 2c6670c0..5cf75b8b 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -204,6 +204,9 @@ pub enum KeyResolutionError { /// the documented TOFU model without constructing a certificate path. #[derive(Debug, Clone, PartialEq, Eq)] pub struct KeyResolverConfig { + /// DER-encoded certificates available to X.509 selectors but not trusted + /// unless they chain to an entry in [`Self::trusted_certs`]. + pub lookup_certs: Vec>, /// DER-encoded certificates accepted as trust anchors. pub trusted_certs: Vec>, /// Verification keys addressable by `` content. @@ -223,6 +226,7 @@ pub struct KeyResolverConfig { impl Default for KeyResolverConfig { fn default() -> Self { Self { + lookup_certs: Vec::new(), trusted_certs: Vec::new(), named_keys: HashMap::new(), verify_chains: false, @@ -264,7 +268,7 @@ impl DefaultKeyResolver { .get(signing_index) .ok_or(KeyResolutionError::InvalidCertificate)?; if self.config.verify_chains { - self.verify_x509_policy(info, None)?; + self.verify_x509_policy(info)?; } certificate_der } else { @@ -281,10 +285,7 @@ impl DefaultKeyResolver { crls: info.crls.clone(), ..X509DataInfo::default() }; - // Validate the selected certificate's own policy before - // requiring a distinct configured certificate as its anchor. - self.verify_x509_policy(&selected, None)?; - self.verify_x509_policy(&selected, Some(certificate))?; + self.verify_x509_policy(&selected)?; } certificate }; @@ -304,23 +305,9 @@ impl DefaultKeyResolver { })) } - fn verify_x509_policy( - &self, - info: &X509DataInfo, - selected_lookup_certificate: Option<&[u8]>, - ) -> Result<(), KeyResolutionError> { - let trusted_certs = self - .config - .trusted_certs - .iter() - .filter(|certificate| { - selected_lookup_certificate - .is_none_or(|selected| certificate.as_slice() != selected) - }) - .cloned() - .collect::>(); + fn verify_x509_policy(&self, info: &X509DataInfo) -> Result<(), KeyResolutionError> { let options = X509ChainOptions { - trusted_certs: &trusted_certs, + trusted_certs: &self.config.trusted_certs, verification_time: self .config .verification_time @@ -341,7 +328,12 @@ impl DefaultKeyResolver { } let mut matches = Vec::new(); - for certificate_der in &self.config.trusted_certs { + for certificate_der in self + .config + .trusted_certs + .iter() + .chain(&self.config.lookup_certs) + { let parsed = parse_x509_certificate(certificate_der) .map_err(|_| KeyResolutionError::InvalidCertificate)?; let is_match = x509_certificate_matches_any_selector(info, &parsed, certificate_der) @@ -726,6 +718,7 @@ mod tests { let config = KeyResolverConfig::default(); assert!(config.trusted_certs.is_empty()); + assert!(config.lookup_certs.is_empty()); assert!(config.named_keys.is_empty()); assert!(!config.verify_chains); assert!(!config.check_crls); @@ -834,8 +827,8 @@ mod tests { // embedding key material or supplying a preset verification key. let leaf_certificate_der = certificate_der(RSA_4096_CERTIFICATE); let resolver = DefaultKeyResolver::new(KeyResolverConfig { + lookup_certs: vec![leaf_certificate_der], trusted_certs: vec![ - leaf_certificate_der, certificate_der(include_str!("../../tests/fixtures/keys/ca2cert.pem")), certificate_der(include_str!("../../tests/fixtures/keys/cacert.pem")), ], @@ -855,9 +848,13 @@ mod tests { fn selector_resolved_certificate_obeys_chain_policy() { // Enabling chain verification must apply validity policy even when // X509Data contains only selectors and the matching cert is configured. - let certificate_der = certificate_der(RSA_4096_CERTIFICATE); + let leaf_certificate_der = certificate_der(RSA_4096_CERTIFICATE); let resolver = DefaultKeyResolver::new(KeyResolverConfig { - trusted_certs: vec![certificate_der], + lookup_certs: vec![leaf_certificate_der], + trusted_certs: vec![ + certificate_der(include_str!("../../tests/fixtures/keys/ca2cert.pem")), + certificate_der(include_str!("../../tests/fixtures/keys/cacert.pem")), + ], verify_chains: true, verification_time: Some(SystemTime::UNIX_EPOCH), ..KeyResolverConfig::default() @@ -867,12 +864,52 @@ mod tests { .verify(&x509_signature_with_leaf_subject()) .expect_err("selector-resolved certificate must satisfy chain policy"); - assert!(matches!( - error, - DsigError::KeyResolution(KeyResolutionError::Chain( - super::super::X509ChainError::CertificateNotValid(_) - )) - )); + assert!( + matches!( + &error, + DsigError::KeyResolution(KeyResolutionError::Chain( + super::super::X509ChainError::CertificateNotValid(_) + )) + ), + "unexpected selector policy error: {error:?}" + ); + } + + #[test] + fn selector_resolved_configured_root_remains_a_trust_anchor() { + // A certificate explicitly configured in trusted_certs remains an + // anchor when X509Data selects it by subject instead of embedding it. + let mut params = rcgen::CertificateParams::new(Vec::new()) + .expect("empty SAN list should produce valid certificate parameters"); + params + .distinguished_name + .push(rcgen::DnType::CommonName, "configured root"); + params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + let key_pair = rcgen::KeyPair::generate().expect("test key generation should succeed"); + let certificate = params + .self_signed(&key_pair) + .expect("test root should be self-signable"); + let certificate_der = certificate.der().to_vec(); + let key_info_xml = concat!( + "", + "CN=configured root", + "" + ); + let document = roxmltree::Document::parse(key_info_xml) + .expect("static selector KeyInfo should parse as XML"); + let key_info = super::super::parse_key_info(document.root_element()) + .expect("static selector KeyInfo should satisfy XMLDSig structure"); + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + trusted_certs: vec![certificate_der], + verify_chains: true, + ..KeyResolverConfig::default() + }); + + let resolved = resolver + .resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) + .expect("configured self-signed certificate should validate as its own anchor"); + + assert!(resolved.is_some()); } #[test] @@ -881,7 +918,7 @@ mod tests { // trust anchor; chain verification still requires a separate issuer. let certificate_der = certificate_der(RSA_4096_CERTIFICATE); let resolver = DefaultKeyResolver::new(KeyResolverConfig { - trusted_certs: vec![certificate_der], + lookup_certs: vec![certificate_der], verify_chains: true, verification_time: Some(fixture_certificate_time()), ..KeyResolverConfig::default() @@ -906,7 +943,8 @@ mod tests { let leaf = certificate_der(RSA_4096_CERTIFICATE); let issuer = certificate_der(include_str!("../../tests/fixtures/keys/ca2cert.pem")); let resolver = DefaultKeyResolver::new(KeyResolverConfig { - trusted_certs: vec![leaf, issuer], + lookup_certs: vec![leaf], + trusted_certs: vec![issuer], verify_chains: true, verification_time: Some(fixture_certificate_time()), ..KeyResolverConfig::default() @@ -930,10 +968,10 @@ mod tests { &selector.replace("CRL_PLACEHOLDER", &STANDARD.encode(crl)), ); let resolver = DefaultKeyResolver::new(KeyResolverConfig { + lookup_certs: vec![certificate_der(include_str!( + "../../tests/fixtures/keys/rsa/rsa-2048-cert.pem" + ))], trusted_certs: vec![ - certificate_der(include_str!( - "../../tests/fixtures/keys/rsa/rsa-2048-cert.pem" - )), certificate_der(include_str!("../../tests/fixtures/keys/ca2cert.pem")), certificate_der(include_str!("../../tests/fixtures/keys/cacert.pem")), ], @@ -964,6 +1002,7 @@ mod tests { // the same configured RSA certificate without embedded key material. let selectors = [ "CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US", + "CN= test key rsa-2048 ,O=xml security library (HTTP://WWW.ALEKSEY.COM/XMLSEC),ST=california,C=us", "Email=xmlsec@aleksey.com,CN=Aleksey Sanin,OU=Second level CA,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US680572598617295163017172295025714171905498632019", "bcOXN/nsVl8GatRbcKrPbzIbw0Y=", ]; @@ -975,7 +1014,7 @@ mod tests { let key_info = format!("{selector}"); let xml = replace_unprefixed_key_info(RSA_KEY_VALUE_SIGNATURE, &key_info); let resolver = DefaultKeyResolver::new(KeyResolverConfig { - trusted_certs: vec![configured_certificate.clone()], + lookup_certs: vec![configured_certificate.clone()], ..KeyResolverConfig::default() }); let result = super::super::VerifyContext::new() @@ -994,7 +1033,7 @@ mod tests { let key_info = r#"CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US0X0XrEVCio75sBcl1TxymJ2IOiU="#; let xml = replace_unprefixed_key_info(RSA_KEY_VALUE_SIGNATURE, key_info); let resolver = DefaultKeyResolver::new(KeyResolverConfig { - trusted_certs: vec![ + lookup_certs: vec![ certificate_der(include_str!( "../../tests/fixtures/keys/rsa/rsa-2048-cert.pem" )), @@ -1016,7 +1055,7 @@ mod tests { let key_info = "CN=not-the-signer"; let xml = replace_unprefixed_key_info(RSA_KEY_VALUE_SIGNATURE, key_info); let resolver = DefaultKeyResolver::new(KeyResolverConfig { - trusted_certs: vec![certificate_der(include_str!( + lookup_certs: vec![certificate_der(include_str!( "../../tests/fixtures/keys/rsa/rsa-2048-cert.pem" ))], ..KeyResolverConfig::default() @@ -1037,7 +1076,7 @@ mod tests { // Duplicate configured certificates must not make key selection order-dependent. let certificate = certificate_der(RSA_4096_CERTIFICATE); let resolver = DefaultKeyResolver::new(KeyResolverConfig { - trusted_certs: vec![certificate.clone(), certificate], + lookup_certs: vec![certificate.clone(), certificate], ..KeyResolverConfig::default() }); let error = super::super::VerifyContext::new() @@ -1058,7 +1097,7 @@ mod tests { let key_info = "AQ=="; let xml = replace_unprefixed_key_info(RSA_KEY_VALUE_SIGNATURE, key_info); let resolver = DefaultKeyResolver::new(KeyResolverConfig { - trusted_certs: vec![certificate_der(include_str!( + lookup_certs: vec![certificate_der(include_str!( "../../tests/fixtures/keys/rsa/rsa-2048-cert.pem" ))], ..KeyResolverConfig::default() diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index 8844c26b..e82d89b8 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -18,6 +18,7 @@ use der::Decode; use roxmltree::{Document, Node}; +use x509_cert::ext::pkix::name::DirectoryString; use x509_cert::name::Name; use x509_parser::extensions::ParsedExtension; use x509_parser::prelude::FromDer; @@ -31,6 +32,7 @@ use super::whitespace::{ normalize_xml_base64_text_with_limit, }; use crate::c14n::C14nAlgorithm; +use crate::c14n::xml_base::{compute_effective_xml_base, resolve_uri}; /// XMLDSig namespace URI. pub(crate) const XMLDSIG_NS: &str = "http://www.w3.org/2000/09/xmldsig#"; @@ -627,14 +629,23 @@ pub fn parse_key_info(key_info_node: Node) -> Result { } (Some(XMLDSIG_NS), "RetrievalMethod") => { ensure_no_non_whitespace_text(child, "RetrievalMethod")?; - let uri = child.attribute("URI").ok_or_else(|| { + let lexical_uri = child.attribute("URI").ok_or_else(|| { ParseError::InvalidStructure("RetrievalMethod requires URI".into()) })?; - if uri.len() > MAX_KEY_NAME_TEXT_LEN { + if lexical_uri.len() > MAX_KEY_NAME_TEXT_LEN { return Err(ParseError::InvalidStructure( "RetrievalMethod URI exceeds maximum length".into(), )); } + let uri = if lexical_uri.is_empty() || lexical_uri.starts_with('#') { + lexical_uri.to_owned() + } else { + // RetrievalMethod is parsed independently from later key + // materialization, so retain its resolved resource identity. + compute_effective_xml_base(child, None) + .map(|base| resolve_uri(&base, lexical_uri)) + .unwrap_or_else(|| lexical_uri.to_owned()) + }; let resource_type = child.attribute("Type").map(str::to_string); let transforms = if resource_type.as_deref() == Some("http://www.w3.org/2000/09/xmldsig#X509Data") @@ -646,7 +657,7 @@ pub fn parse_key_info(key_info_node: Node) -> Result { RetrievalMethodTransforms::None }; sources.push(KeyInfoSource::RetrievalMethod { - uri: uri.to_string(), + uri, resource_type, transforms, }); @@ -1351,6 +1362,60 @@ pub(crate) fn x509_selector_categories_match_chain( } fn distinguished_names_equal(left: &str, right: &str) -> bool { + fn attribute_values_equal( + left: &x509_cert::attr::AttributeTypeAndValue, + right: &x509_cert::attr::AttributeTypeAndValue, + ) -> bool { + if left.oid != right.oid { + return false; + } + match ( + DirectoryString::try_from(&left.value), + DirectoryString::try_from(&right.value), + ) { + (Ok(left), Ok(right)) => { + // RFC 5280 section 7.1 requires caseIgnoreMatch with LDAP/X.520 + // string preparation for PrintableString and UTF8String names. + let Ok(left) = + x520_stringprep::x520_stringprep_to_case_ignore_string(left.value().as_ref()) + else { + return false; + }; + let Ok(right) = + x520_stringprep::x520_stringprep_to_case_ignore_string(right.value().as_ref()) + else { + return false; + }; + left.trim_matches(' ') == right.trim_matches(' ') + } + _ => left.value == right.value, + } + } + + fn rdns_equal( + left: &x509_cert::name::RelativeDistinguishedName, + right: &x509_cert::name::RelativeDistinguishedName, + ) -> bool { + if left.len() != right.len() { + return false; + } + // A DN is an ordered RDN sequence, but each individual RDN is a set. + let right = right.iter().collect::>(); + let mut matched = vec![false; right.len()]; + left.iter().all(|left_attribute| { + right + .iter() + .enumerate() + .find(|(index, right_attribute)| { + !matched[*index] && attribute_values_equal(left_attribute, right_attribute) + }) + .is_some_and(|(index, _)| { + matched[index] = true; + true + }) + }) + } + fn trailing_whitespace_is_escaped(value: &str) -> bool { let Some(prefix) = value.as_bytes().strip_suffix(b" ") else { return false; @@ -1417,7 +1482,13 @@ fn distinguished_names_equal(left: &str, right: &str) -> bool { let parse_name = |value: &str| remove_separator_padding(value).parse::().ok(); parse_name(left) .zip(parse_name(right)) - .is_some_and(|(left, right)| left == right) + .is_some_and(|(left, right)| { + left.len() == right.len() + && left + .iter_rdn() + .zip(right.iter_rdn()) + .all(|(left, right)| rdns_equal(left, right)) + }) } fn ensure_x509_data_entry_budget(info: &X509DataInfo) -> Result<(), ParseError> { @@ -2909,6 +2980,28 @@ BA== )); } + #[test] + fn distinguished_name_matching_applies_x520_string_preparation() { + // RFC 5280 requires caseIgnoreMatch with insignificant-space handling + // for DirectoryString values rather than exact ASN.1 value equality. + assert!(distinguished_names_equal( + "CN= TEST key ,O=Example", + "CN=test key,O=example" + )); + assert!(distinguished_names_equal( + "CN=Straße,O=Example", + "CN=STRASSE,O=EXAMPLE" + )); + assert!(distinguished_names_equal( + "CN=test+OU=security,O=example", + "OU=SECURITY+CN=TEST,O=EXAMPLE" + )); + assert!(!distinguished_names_equal( + "1.2.3.4=#040141,O=example", + "1.2.3.4=#040142,O=example" + )); + } + #[test] fn distinguished_name_matching_handles_rfc4514_escaped_values() { // Certificate values containing RFC 4514 separators and boundary spaces diff --git a/src/xmldsig/uri.rs b/src/xmldsig/uri.rs index 795b66d3..3e51d4ea 100644 --- a/src/xmldsig/uri.rs +++ b/src/xmldsig/uri.rs @@ -17,6 +17,8 @@ use std::collections::{HashMap, HashSet}; use roxmltree::{Document, Node, NodeId}; +use crate::c14n::xml_base::{compute_effective_xml_base, resolve_uri}; + use super::types::{NodeSet, NodeSetMaterializationBudget, TransformData, TransformError}; /// Default ID attribute names to scan when building the ID index. @@ -158,6 +160,23 @@ impl<'a> UriReferenceResolver<'a> { self.dereference_with_optional_budget(uri, Some(budget)) } + pub(crate) fn dereference_from_with_budget( + &self, + uri: &str, + origin: Node<'_, '_>, + budget: &NodeSetMaterializationBudget, + ) -> Result, TransformError> { + // XMLDSig assigns special dereference semantics to lexical empty and + // fragment-only references. Only external references use XML Base. + if uri.is_empty() || uri.starts_with('#') { + return self.dereference_with_budget(uri, budget); + } + let resolved = compute_effective_xml_base(origin, None) + .map(|base| resolve_uri(&base, uri)) + .unwrap_or_else(|| uri.to_owned()); + self.dereference_with_budget(&resolved, budget) + } + fn dereference_with_optional_budget( &self, uri: &str, @@ -271,6 +290,10 @@ impl<'a> UriReferenceResolver<'a> { self.id_map.get(id).copied() } + pub(crate) fn node_for_node_id(&self, id: NodeId) -> Option> { + self.doc.get_node(id) + } + /// Get the number of registered IDs. pub fn id_count(&self) -> usize { self.id_map.len() diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index 0b01acec..63199d82 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -473,10 +473,44 @@ pub fn process_reference( signature_node, reference_set, reference_index, + reference_origin_node(signature_node, reference_set, reference_index), &execution, ) } +fn reference_origin_node<'a, 'input>( + signature_node: Node<'a, 'input>, + reference_set: ReferenceSet, + reference_index: usize, +) -> Option> { + let is_reference = |node: &Node<'_, '_>| { + node.is_element() + && node.tag_name().namespace() == Some(XMLDSIG_NS) + && node.tag_name().name() == "Reference" + }; + match reference_set { + ReferenceSet::SignedInfo => signature_node + .children() + .find(|node| { + node.is_element() + && node.tag_name().namespace() == Some(XMLDSIG_NS) + && node.tag_name().name() == "SignedInfo" + })? + .children() + .filter(is_reference) + .nth(reference_index), + ReferenceSet::Manifest => signature_node + .descendants() + .filter(|node| { + node.is_element() + && node.tag_name().namespace() == Some(XMLDSIG_NS) + && node.tag_name().name() == "Manifest" + }) + .flat_map(|manifest| manifest.children().filter(is_reference)) + .nth(reference_index), + } +} + struct ReferenceExecutionContext<'a> { store_pre_digest: bool, transform_options: TransformOptions, @@ -525,6 +559,7 @@ fn process_reference_with_options( signature_node: Node<'_, '_>, reference_set: ReferenceSet, reference_index: usize, + reference_node: Option>, execution: &ReferenceExecutionContext<'_>, ) -> Result { // 1. Dereference URI. Omitted URI is distinct from URI="" in XMLDSig and @@ -533,8 +568,22 @@ fn process_reference_with_options( .uri .as_deref() .ok_or(ReferenceProcessingError::MissingUri)?; - let initial_data = resolver - .dereference_with_budget(uri, execution.transform_budget.node_set_materialization()) + let initial_data = reference_node + .map_or_else( + || { + resolver.dereference_with_budget( + uri, + execution.transform_budget.node_set_materialization(), + ) + }, + |node| { + resolver.dereference_from_with_budget( + uri, + node, + execution.transform_budget.node_set_materialization(), + ) + }, + ) .map_err(ReferenceProcessingError::UriDereference)?; // 2. Apply transform chain @@ -619,6 +668,7 @@ fn process_all_references_with_options( signature_node, ReferenceSet::SignedInfo, i, + reference_origin_node(signature_node, ReferenceSet::SignedInfo, i), execution, )?; let failed = matches!(result.status, DsigStatus::Invalid(_)); @@ -1222,7 +1272,7 @@ fn process_manifest_references( return Ok(Vec::new()); } results.reserve(manifest_references.len()); - for (index, reference) in &manifest_references { + for (index, reference, reference_node_id) in &manifest_references { match enforce_reference_policies( std::slice::from_ref(reference), ctx.allowed_uri_types, @@ -1268,6 +1318,7 @@ fn process_manifest_references( signature_node, ReferenceSet::Manifest, *index, + resolver.node_for_node_id(*reference_node_id), execution, ) { Ok(result) => results.push(result), @@ -1357,7 +1408,7 @@ fn parse_manifest_references( }); } match parse_reference_with_xpath_budget(child, xpath_parse_budget) { - Ok(reference) => references.push((reference_index, reference)), + Ok(reference) => references.push((reference_index, reference, child.id())), Err(ParseError::Transform(super::TransformError::UnsupportedTransform(_))) => { let digest_algorithm = reference_digest_method(child).map_err(|error| { SignatureVerificationPipelineError::ParseManifestReference(error) @@ -1392,7 +1443,7 @@ fn parse_manifest_references( } struct ParsedManifestReferences { - references: Vec<(usize, Reference)>, + references: Vec<(usize, Reference, NodeId)>, invalid_results: Vec, } @@ -1777,6 +1828,103 @@ mod tests { } } + #[test] + fn reference_resolution_uses_each_elements_effective_xml_base() { + // Equal lexical URIs under different xml:base values identify distinct + // caller-owned resources and must not collide in the resolver. + let first = b"first payload"; + let second = b"second payload"; + let first_digest = base64::engine::general_purpose::STANDARD + .encode(compute_digest(DigestAlgorithm::Sha256, first)); + let second_digest = base64::engine::general_purpose::STANDARD + .encode(compute_digest(DigestAlgorithm::Sha256, second)); + let xml = format!( + r#" + + + + + + {first_digest} + + + + {second_digest} + + AA== + "# + ); + let document = Document::parse(&xml).unwrap(); + let signature = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "Signature"))) + .unwrap(); + let signed_info_node = signature + .children() + .find(|node| node.has_tag_name((XMLDSIG_NS, "SignedInfo"))) + .unwrap(); + let signed_info = parse_signed_info(signed_info_node).unwrap(); + let resources = HashMap::from([ + ( + "https://example.test/base/one/payload.bin".into(), + first.to_vec(), + ), + ( + "https://example.test/two/payload.bin".into(), + second.to_vec(), + ), + ]); + let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources); + + let result = process_all_references(&signed_info.references, &resolver, signature, false) + .expect("each Reference should resolve against its own effective base"); + + assert!(result.all_valid()); + } + + #[test] + fn manifest_reference_resolution_uses_its_effective_xml_base() { + // Manifest references carry their own XML Base context and must not + // accidentally reuse the SignedInfo or Signature element context. + let payload = b"manifest payload"; + let digest = base64::engine::general_purpose::STANDARD + .encode(compute_digest(DigestAlgorithm::Sha256, payload)); + let xml = format!( + r#" + + + + {digest} + + + "# + ); + let document = Document::parse(&xml).unwrap(); + let signature = document.root_element(); + let reference_node = signature + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "Reference"))) + .unwrap(); + let reference = super::super::parse::parse_reference(reference_node).unwrap(); + let resources = HashMap::from([( + "https://example.test/manifests/payload.bin".to_string(), + payload.to_vec(), + )]); + let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources); + + let result = process_reference( + &reference, + &resolver, + signature, + ReferenceSet::Manifest, + 0, + false, + ) + .expect("Manifest Reference should inherit its own XML Base context"); + + assert_eq!(result.status, DsigStatus::Valid); + } + struct RejectingKey; impl VerifyingKey for RejectingKey { @@ -2678,6 +2826,46 @@ mod tests { )); } + #[test] + fn raw_x509_retrieval_method_uses_inherited_xml_base() { + // RetrievalMethod URI is an attribute URI reference, so XML Base uses + // the effective base of the element bearing that attribute. + const RAW_X509_TYPE: &str = "http://www.w3.org/2000/09/xmldsig#rawX509Certificate"; + let xml = format!( + r#" + + "# + ); + let document = Document::parse(&xml).unwrap(); + let key_info_node = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) + .unwrap(); + let mut key_info = parse_key_info(key_info_node).unwrap(); + let certificate = include_bytes!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/balor.der" + ) + .to_vec(); + let resources = HashMap::from([( + "https://example.test/keys/signer.der".to_string(), + certificate, + )]); + + materialize_retrieval_methods( + &mut key_info, + &UriReferenceResolver::new(&document), + Some(&resources), + UriTypeSet::ALL, + ) + .expect("RetrievalMethod should resolve against inherited xml:base"); + + assert!(matches!( + key_info.sources.as_slice(), + [super::super::parse::KeyInfoSource::X509Data(info)] + if info.certificates.len() == 1 + )); + } + #[test] fn retrieval_method_requires_xpath_for_x509_data_below_uri_root() { // Without a transform the dereferenced holder, not its descendant, diff --git a/tests/donor_full_verification_suite.rs b/tests/donor_full_verification_suite.rs index 5f7e3514..5fbe5ca2 100644 --- a/tests/donor_full_verification_suite.rs +++ b/tests/donor_full_verification_suite.rs @@ -178,7 +178,7 @@ fn donor_full_verification_suite_accepts_every_supported_case() { Expectation::Selected { certificate_paths } => { let xml = read_fixture(&root.join(case.xml_path)); let resolver = DefaultKeyResolver::new(KeyResolverConfig { - trusted_certs: certificate_paths + lookup_certs: certificate_paths .iter() .map(|path| read_pem_der(&root.join(path), "CERTIFICATE")) .collect(), diff --git a/tests/merlin_interop.rs b/tests/merlin_interop.rs index 76e8be60..d828433f 100644 --- a/tests/merlin_interop.rs +++ b/tests/merlin_interop.rs @@ -163,12 +163,10 @@ fn verifies_all_merlin_documents_with_upstream_expectations() { ("signature-x509-is", Some("macha.pem")), ("signature-x509-ski", Some("nemain.pem")), ] { - let mut trusted_certs = vec![cert("ca.pem")]; - if let Some(selected) = selected { - trusted_certs.push(cert(selected)); - } + let lookup_certs = selected.into_iter().map(cert).collect(); let resolver = DefaultKeyResolver::new(KeyResolverConfig { - trusted_certs, + lookup_certs, + trusted_certs: vec![cert("ca.pem")], verify_chains: true, verification_time: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(VERIFY_2005)), ..KeyResolverConfig::default() @@ -184,7 +182,8 @@ fn verifies_all_merlin_documents_with_upstream_expectations() { } let retrieval = DefaultKeyResolver::new(KeyResolverConfig { - trusted_certs: vec![cert("ca.pem"), cert("balor.pem")], + lookup_certs: vec![cert("balor.pem")], + trusted_certs: vec![cert("ca.pem")], verify_chains: true, verification_time: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(VERIFY_2005)), ..KeyResolverConfig::default() @@ -496,7 +495,8 @@ fn rejects_dtd_and_unsupported_retrieval_defaults() { )); let retrieval = DefaultKeyResolver::new(KeyResolverConfig { - trusted_certs: vec![cert("ca.pem"), cert("balor.pem")], + lookup_certs: vec![cert("balor.pem")], + trusted_certs: vec![cert("ca.pem")], verify_chains: true, verification_time: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(VERIFY_2005)), ..KeyResolverConfig::default() From bcdccc40fcc26a1ff95a1a4b5eb61ba93c19b1e8 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Thu, 6 Aug 2026 15:37:54 +0300 Subject: [PATCH 15/26] fix(xmldsig): preserve resolution context - Align Manifest and XML Base reference origins - Preserve padded serials and lookup intermediates - Clarify transactional installer rollback state --- docs/xmldsig.md | 9 +-- scripts/install-xmlsec1.sh | 8 +-- src/c14n/xml_base.rs | 30 ++++---- src/xmldsig/keys.rs | 141 +++++++++++++++++++++++++++++-------- src/xmldsig/parse.rs | 117 ++++++++++++++++++++++-------- src/xmldsig/verify.rs | 100 +++++++++++++++++++++++++- 6 files changed, 323 insertions(+), 82 deletions(-) diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 2ffd5af6..a98dfa73 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -25,11 +25,12 @@ interoperating with legacy libxmlsec1 `here()` behavior can explicitly select ## Verification Policy For production verification, configure `KeyResolverConfig::lookup_certs` with untrusted -certificates that selector-only `X509Data` may address, and configure +certificates that selector-only `X509Data` may address or use as path intermediates, and configure `KeyResolverConfig::trusted_certs` only with explicit trust anchors. With chain validation -enabled, a selected lookup certificate must chain to a trusted anchor. A trusted certificate -selected directly remains an anchor, while embedded certificates provide key material and do -not become trusted merely because they appear in ``. +enabled, a selected lookup certificate may chain through other lookup certificates but must end at +a trusted anchor. A trusted certificate selected directly remains an anchor, while embedded +certificates provide key material and do not become trusted merely because they appear in +``. `VerifyResult::status` reports core validation: `Valid` means the cryptographic signature and every `` reference succeeded. `Invalid(reason)` means core validation completed but diff --git a/scripts/install-xmlsec1.sh b/scripts/install-xmlsec1.sh index bc0b3f37..4b02622a 100755 --- a/scripts/install-xmlsec1.sh +++ b/scripts/install-xmlsec1.sh @@ -22,7 +22,7 @@ fi work_dir="$(mktemp -d "${TMPDIR:-/tmp}/xmlsec1-${XMLSEC1_VERSION}.XXXXXX")" previous_install="$work_dir/previous-install" -had_previous_install=false +previous_install_staged=false promoted_install=false cleanup() { @@ -36,7 +36,7 @@ cleanup() { if [[ "$promoted_install" == true ]]; then rm -rf "$prefix" fi - if [[ "$had_previous_install" == true ]]; then + if [[ "$previous_install_staged" == true ]]; then if ! mv "$previous_install" "$prefix"; then printf 'failed to restore previous xmlsec1 installation at %s; backup remains at %s\n' \ "$prefix" "$previous_install" >&2 @@ -86,7 +86,7 @@ staged_prefix="$stage_dir$prefix" mkdir -p "$(dirname "$prefix")" if [[ -e "$prefix" ]]; then mv "$prefix" "$previous_install" - had_previous_install=true + previous_install_staged=true fi mv "$staged_prefix" "$prefix" promoted_install=true @@ -101,7 +101,7 @@ else fi rm -rf "$previous_install" -had_previous_install=false +previous_install_staged=false printf 'installed xmlsec1 %s snapshot %s at %s\n' \ "$XMLSEC1_VERSION" "${XMLSEC1_COMMIT:0:12}" "$prefix" diff --git a/src/c14n/xml_base.rs b/src/c14n/xml_base.rs index 7c49a031..b2ff1082 100644 --- a/src/c14n/xml_base.rs +++ b/src/c14n/xml_base.rs @@ -123,6 +123,15 @@ pub(crate) fn resolve_uri(base: &str, reference: &str) -> String { return reference.to_string(); } + // Query- and fragment-only references preserve the complete base path for + // both absolute and relative bases (RFC 3986 section 5.2.2). + if reference.starts_with('?') { + return format!("{}{reference}", strip_query_fragment(base)); + } + if reference.starts_with('#') { + return format!("{}{reference}", base.split('#').next().unwrap_or(base)); + } + // Parse base URI components let base_parts = match parse_base(base) { Some(parts) => parts, @@ -144,19 +153,6 @@ pub(crate) fn resolve_uri(base: &str, reference: &str) -> String { let authority = base_parts.authority; let base_path = base_parts.path; - // Reference starts with ? → query-only: keep base scheme+authority+path. - // Reference starts with # → fragment-only: keep base scheme+authority+path+query. - // Per RFC 3986 §5.2.2, these replace only the query/fragment components. - if reference.starts_with('?') || reference.starts_with('#') { - let base_no_qf = strip_query_fragment(base); - if reference.starts_with('?') { - return format!("{base_no_qf}{reference}"); - } - // Fragment-only: keep query too - let base_no_frag = base.split('#').next().unwrap_or(base); - return format!("{base_no_frag}{reference}"); - } - // Split reference into path and query/fragment suffix. We apply // remove_dot_segments only to the path portion, then reattach the // query/fragment to the result. @@ -463,6 +459,14 @@ mod tests { assert_eq!(resolve_uri("a/b", "c"), "a/c"); } + #[test] + fn resolve_query_and_fragment_against_schemeless_base() { + // RFC 3986 replaces only the query or fragment even when the effective + // XML Base is itself relative rather than scheme-bearing. + assert_eq!(resolve_uri("a/b?old#frag", "?new"), "a/b?new"); + assert_eq!(resolve_uri("a/b?old#frag", "#new"), "a/b?old#new"); + } + #[test] fn resolve_urn_reference() { // URN has a scheme, should be returned as-is diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 5cf75b8b..935d2192 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -16,9 +16,9 @@ use super::{ DsigError, KeyInfo, KeyInfoSource, KeyResolver, KeyValueInfo, SignatureAlgorithm, VerifyingKey, X509ChainOptions, X509DataInfo, parse::{ - EC_P256_OID, EC_P384_OID, ParseError, parse_x509_certificate, - x509_certificate_matches_any_selector, x509_data_has_lookup_identifiers, - x509_selector_categories_match_chain, + EC_P256_OID, EC_P384_OID, ParseError, build_x509_certificate_chain_from, + parse_x509_certificate, x509_certificate_matches_any_selector, + x509_data_has_lookup_identifiers, x509_selector_categories_match_chain, }, verify_dsa_signature_spki, verify_ecdsa_signature_spki, verify_rsa_signature_spki, verify_x509_certificate_chain, @@ -204,8 +204,9 @@ pub enum KeyResolutionError { /// the documented TOFU model without constructing a certificate path. #[derive(Debug, Clone, PartialEq, Eq)] pub struct KeyResolverConfig { - /// DER-encoded certificates available to X.509 selectors but not trusted - /// unless they chain to an entry in [`Self::trusted_certs`]. + /// DER-encoded certificates available to X.509 selectors and as untrusted + /// path intermediates. They establish trust only by chaining to an entry in + /// [`Self::trusted_certs`]. pub lookup_certs: Vec>, /// DER-encoded certificates accepted as trust anchors. pub trusted_certs: Vec>, @@ -266,31 +267,28 @@ impl DefaultKeyResolver { let certificate_der = info .certificates .get(signing_index) - .ok_or(KeyResolutionError::InvalidCertificate)?; + .ok_or(KeyResolutionError::InvalidCertificate)? + .clone(); if self.config.verify_chains { self.verify_x509_policy(info)?; } certificate_der } else { - let Some(certificate) = self.resolve_configured_x509(info)? else { + let Some(selected) = self.resolve_configured_x509(info)? else { return Ok(None); }; if self.config.verify_chains { - let parsed = parse_x509_certificate(certificate) - .map_err(|_| KeyResolutionError::InvalidCertificate)?; - let selected = X509DataInfo { - certificates: vec![certificate.clone()], - parsed_certificates: vec![parsed], - certificate_chain: vec![0], - crls: info.crls.clone(), - ..X509DataInfo::default() - }; self.verify_x509_policy(&selected)?; } - certificate + selected + .certificate_chain + .first() + .and_then(|index| selected.certificates.get(*index)) + .ok_or(KeyResolutionError::InvalidCertificate)? + .clone() }; - let (rest, certificate) = X509Certificate::from_der(certificate_der) + let (rest, certificate) = X509Certificate::from_der(&certificate_der) .map_err(|_| KeyResolutionError::InvalidCertificate)?; if !rest.is_empty() { return Err(KeyResolutionError::InvalidCertificate); @@ -300,7 +298,7 @@ impl DefaultKeyResolver { Ok(Some(VerificationKey { algorithm, public_key_bytes, - certificate_der: Some(certificate_der.clone()), + certificate_der: Some(certificate_der), name: None, })) } @@ -319,14 +317,22 @@ impl DefaultKeyResolver { Ok(()) } - fn resolve_configured_x509<'a>( - &'a self, + fn resolve_configured_x509( + &self, info: &X509DataInfo, - ) -> Result>, KeyResolutionError> { + ) -> Result, KeyResolutionError> { if !x509_data_has_lookup_identifiers(info) { return Ok(None); } + let mut available = X509DataInfo { + subject_names: info.subject_names.clone(), + issuer_serials: info.issuer_serials.clone(), + skis: info.skis.clone(), + crls: info.crls.clone(), + digests: info.digests.clone(), + ..X509DataInfo::default() + }; let mut matches = Vec::new(); for certificate_der in self .config @@ -344,14 +350,16 @@ impl DefaultKeyResolver { _ => KeyResolutionError::InvalidCertificate, })?; if is_match { - matches.push((certificate_der, parsed)); + matches.push((available.certificates.len(), parsed.clone())); } + available.certificates.push(certificate_der.clone()); + available.parsed_certificates.push(parsed); } let matched_chain = X509DataInfo { certificates: matches .iter() - .map(|(certificate, _)| (*certificate).clone()) + .map(|(index, _)| available.certificates[*index].clone()) .collect(), parsed_certificates: matches.iter().map(|(_, parsed)| parsed.clone()).collect(), ..X509DataInfo::default() @@ -372,9 +380,9 @@ impl DefaultKeyResolver { return Ok(None); } - match matches.as_slice() { - [] => Ok(None), - [(certificate, _)] => Ok(Some(certificate)), + let signing_index = match matches.as_slice() { + [] => return Ok(None), + [(index, _)] => *index, _ => { let leaves = matches .iter() @@ -386,11 +394,19 @@ impl DefaultKeyResolver { }) .collect::>(); match leaves.as_slice() { - [(certificate, _)] => Ok(Some(certificate)), - _ => Err(KeyResolutionError::AmbiguousCertificate), + [(index, _)] => *index, + _ => return Err(KeyResolutionError::AmbiguousCertificate), } } - } + }; + available.certificate_chain = build_x509_certificate_chain_from(&available, signing_index) + .map_err(|error| match error { + ParseError::InvalidStructure(reason) if reason.contains("ambiguous") => { + KeyResolutionError::AmbiguousCertificate + } + _ => KeyResolutionError::InvalidCertificate, + })?; + Ok(Some(available)) } fn resolve_key_value( @@ -957,6 +973,71 @@ mod tests { assert_eq!(result.status, super::super::DsigStatus::Valid); } + #[test] + fn selector_resolved_leaf_uses_lookup_intermediate() { + // Lookup certificates may complete an untrusted path, but only the + // separately configured root is allowed to establish trust. + let mut root_params = + rcgen::CertificateParams::new(Vec::new()).expect("empty root SAN list should be valid"); + root_params + .distinguished_name + .push(rcgen::DnType::CommonName, "lookup root"); + root_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + root_params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign]; + let root = rcgen::CertifiedIssuer::self_signed( + root_params, + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("root certificate should be self-signable"); + + let mut intermediate_params = rcgen::CertificateParams::new(Vec::new()) + .expect("empty intermediate SAN list should be valid"); + intermediate_params + .distinguished_name + .push(rcgen::DnType::CommonName, "lookup intermediate"); + intermediate_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + intermediate_params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign]; + let intermediate = rcgen::CertifiedIssuer::signed_by( + intermediate_params, + rcgen::KeyPair::generate().expect("intermediate key generation should succeed"), + &root, + ) + .expect("root should sign the intermediate certificate"); + + let mut leaf_params = + rcgen::CertificateParams::new(Vec::new()).expect("empty leaf SAN list should be valid"); + leaf_params + .distinguished_name + .push(rcgen::DnType::CommonName, "lookup leaf"); + let leaf = leaf_params + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &intermediate, + ) + .expect("intermediate should sign the leaf certificate"); + let key_info_xml = concat!( + "", + "CN=lookup leaf", + "" + ); + let document = roxmltree::Document::parse(key_info_xml) + .expect("static selector KeyInfo should parse as XML"); + let key_info = super::super::parse_key_info(document.root_element()) + .expect("static selector KeyInfo should satisfy XMLDSig structure"); + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + lookup_certs: vec![leaf.der().to_vec(), intermediate.der().to_vec()], + trusted_certs: vec![root.der().to_vec()], + verify_chains: true, + ..KeyResolverConfig::default() + }); + + let resolved = resolver + .resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) + .expect("selector-resolved leaf should chain through the lookup intermediate"); + + assert!(resolved.is_some()); + } + #[test] fn selector_resolved_certificate_preserves_supplied_crls() { let selector = "CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=USCRL_PLACEHOLDER"; diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index e82d89b8..206b3316 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -56,9 +56,11 @@ pub(crate) const MAX_X509_DECODED_BINARY_LEN: usize = MAX_X509_BASE64_NORMALIZED_LEN.div_ceil(4) * 3; const MAX_X509_SUBJECT_NAME_TEXT_LEN: usize = 16_384; const MAX_X509_ISSUER_NAME_TEXT_LEN: usize = 16_384; +const MAX_X509_SERIAL_NUMBER_RAW_TEXT_LEN: usize = 16_384; // RFC 5280 permits at most 20 DER content octets for a positive certificate -// serial number. The sign bit leaves 159 value bits, or at most 49 decimal digits. -const MAX_X509_SERIAL_NUMBER_TEXT_LEN: usize = 49; +// serial number. The sign bit leaves 159 value bits, or at most 49 significant +// decimal digits; XML Schema permits insignificant leading zeroes. +const MAX_X509_SERIAL_NUMBER_VALUE_DIGITS: usize = 49; const MAX_X509_SERIAL_NUMBER_BYTES: usize = 20; const MAX_X509_DATA_ENTRY_COUNT: usize = 64; pub(crate) const MAX_X509_DATA_TOTAL_BINARY_LEN: usize = 1_048_576; @@ -1151,6 +1153,21 @@ fn build_x509_certificate_chain(info: &X509DataInfo) -> Result, Parse } let signing_idx = select_x509_signing_certificate(info)?; + build_x509_certificate_chain_from(info, signing_idx) +} + +/// Order an available certificate pool from a preselected signing certificate. +pub(crate) fn build_x509_certificate_chain_from( + info: &X509DataInfo, + signing_idx: usize, +) -> Result, ParseError> { + if signing_idx >= info.parsed_certificates.len() + || info.parsed_certificates.len() != info.certificates.len() + { + return Err(ParseError::InvalidStructure( + "X509Data certificate metadata is inconsistent".into(), + )); + } let mut chain = vec![signing_idx]; loop { @@ -1673,8 +1690,9 @@ fn format_x509_serial_value_hex(serial: &[u8]) -> String { fn x509_serial_decimal_to_hex(serial: &str) -> Option { let serial = serial.trim(); let serial = serial.strip_prefix('+').unwrap_or(serial); - if serial.is_empty() - || serial.len() > MAX_X509_SERIAL_NUMBER_TEXT_LEN + let serial = serial.trim_start_matches('0'); + let serial = if serial.is_empty() { "0" } else { serial }; + if serial.len() > MAX_X509_SERIAL_NUMBER_VALUE_DIGITS || !serial.bytes().all(|byte| byte.is_ascii_digit()) { return None; @@ -1896,41 +1914,57 @@ fn collect_text_content_bounded( } fn collect_x509_serial_number(node: Node<'_, '_>) -> Result { - let mut serial = String::with_capacity(MAX_X509_SERIAL_NUMBER_TEXT_LEN); + let mut serial = String::with_capacity(MAX_X509_SERIAL_NUMBER_VALUE_DIGITS); + let mut raw_text_len = 0usize; let mut trailing_whitespace = false; let mut explicit_positive = false; + let mut saw_digit = false; - for byte in node + for chunk in node .children() .filter_map(|child| child.is_text().then(|| child.text()).flatten()) - .flat_map(str::bytes) { - if matches!(byte, b' ' | b'\t' | b'\r' | b'\n') { - trailing_whitespace |= explicit_positive || !serial.is_empty(); - continue; - } - if byte == b'+' && serial.is_empty() && !explicit_positive && !trailing_whitespace { - explicit_positive = true; - continue; - } - if trailing_whitespace || !byte.is_ascii_digit() { + raw_text_len = raw_text_len.saturating_add(chunk.len()); + if raw_text_len > MAX_X509_SERIAL_NUMBER_RAW_TEXT_LEN { return Err(ParseError::InvalidStructure( - "invalid X509SerialNumber decimal value".into(), + "X509SerialNumber exceeds maximum allowed text length".into(), )); } - if serial.len() == MAX_X509_SERIAL_NUMBER_TEXT_LEN { - return Err(ParseError::InvalidStructure( - "X509SerialNumber exceeds maximum allowed decimal length".into(), - )); + for byte in chunk.bytes() { + if matches!(byte, b' ' | b'\t' | b'\r' | b'\n') { + trailing_whitespace |= explicit_positive || saw_digit; + continue; + } + if byte == b'+' && !saw_digit && !explicit_positive && !trailing_whitespace { + explicit_positive = true; + continue; + } + if trailing_whitespace || !byte.is_ascii_digit() { + return Err(ParseError::InvalidStructure( + "invalid X509SerialNumber decimal value".into(), + )); + } + saw_digit = true; + if byte == b'0' && serial.is_empty() { + continue; + } + if serial.len() == MAX_X509_SERIAL_NUMBER_VALUE_DIGITS { + return Err(ParseError::InvalidStructure( + "X509SerialNumber exceeds maximum allowed decimal value".into(), + )); + } + serial.push(char::from(byte)); } - serial.push(char::from(byte)); } - if serial.is_empty() { + if !saw_digit { return Err(ParseError::InvalidStructure( "X509IssuerSerial requires non-empty X509IssuerName and X509SerialNumber".into(), )); } + if serial.is_empty() { + serial.push('0'); + } if x509_serial_decimal_to_hex(&serial).is_none() { return Err(ParseError::InvalidStructure( "invalid X509SerialNumber decimal value or RFC 5280 range".into(), @@ -2700,9 +2734,10 @@ BA== #[test] fn parse_key_info_uses_decimal_issuer_serial_to_select_x509_signing_certificate() { + let serial = "680572598617295163017172295025714171905498632019"; + let padded_serial = format!("{}{}", "0".repeat(64), serial); assert_eq!( - x509_serial_decimal_to_hex("680572598617295163017172295025714171905498632019") - .as_deref(), + x509_serial_decimal_to_hex(&padded_serial).as_deref(), Some("7735EE487F6862DAF1B3956D961CCB0FA6F34F53") ); let root = fixture_cert_base64("../../tests/fixtures/keys/cacert.pem"); @@ -2714,7 +2749,7 @@ BA== Email=xmlsec@aleksey.com,CN=Aleksey Sanin,OU=Second level CA,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US - 680572598617295163017172295025714171905498632019 + {padded_serial} {root} {intermediate} @@ -2862,7 +2897,7 @@ BA== #[test] fn build_x509_certificate_chain_rejects_chain_exceeding_max_depth() { - let parsed_certificates = (0..=MAX_X509_CHAIN_DEPTH) + let parsed_certificates: Vec = (0..=MAX_X509_CHAIN_DEPTH) .map(|idx| ParsedX509Certificate { subject_dn: format!("CN=cert-{idx}"), issuer_dn: if idx == MAX_X509_CHAIN_DEPTH { @@ -2878,7 +2913,9 @@ BA== }, }) .collect(); + let certificates = vec![Vec::new(); parsed_certificates.len()]; let info = X509DataInfo { + certificates, parsed_certificates, ..X509DataInfo::default() }; @@ -2909,6 +2946,10 @@ BA== x509_serial_decimal_to_hex("0000000000000000000000000000000000000000000000001"), Some("01".into()) ); + assert_eq!( + x509_serial_decimal_to_hex("00000000000000000000000000000000000000000000000001"), + Some("01".into()) + ); assert_eq!(x509_serial_decimal_to_hex("+1"), Some("01".into())); for invalid in [ @@ -2919,7 +2960,6 @@ BA== "++1", "-1", "1a", - "00000000000000000000000000000000000000000000000001", "730750818665451459101842416358141509827966271488", "1461501637330902918203684832716283019655932542976", ] { @@ -3032,7 +3072,7 @@ BA== #[test] fn parse_key_info_accepts_large_textual_x509_entries_within_entry_budget() { let issuer_name = "C".repeat(MAX_X509_ISSUER_NAME_TEXT_LEN); - let serial_number = "0".repeat(MAX_X509_SERIAL_NUMBER_TEXT_LEN - 1) + "1"; + let serial_number = "0".repeat(MAX_X509_SERIAL_NUMBER_VALUE_DIGITS - 1) + "1"; let issuer_serials = (0..52) .map(|_| { format!( @@ -3054,6 +3094,25 @@ BA== assert_eq!(parsed.issuer_serials.len(), 52); } + #[test] + fn parse_key_info_bounds_raw_x509_serial_text() { + // Leading zeroes are lexically valid, but their raw XML representation + // remains bounded independently from the canonical certificate value. + let serial = "0".repeat(MAX_X509_SERIAL_NUMBER_RAW_TEXT_LEN + 1); + let xml = format!( + "CN=issuer{serial}" + ); + let doc = Document::parse(&xml).unwrap(); + + let error = parse_key_info(doc.root_element()).unwrap_err(); + + assert!(matches!( + error, + ParseError::InvalidStructure(reason) + if reason == "X509SerialNumber exceeds maximum allowed text length" + )); + } + #[test] fn parse_key_info_accepts_x509data_with_only_foreign_namespace_children() { let xml = r#"( .filter(is_reference) .nth(reference_index), ReferenceSet::Manifest => signature_node - .descendants() + .children() .filter(|node| { node.is_element() && node.tag_name().namespace() == Some(XMLDSIG_NS) - && node.tag_name().name() == "Manifest" + && node.tag_name().name() == "Object" + }) + .flat_map(|object| { + object.children().filter(|node| { + node.is_element() + && node.tag_name().namespace() == Some(XMLDSIG_NS) + && node.tag_name().name() == "Manifest" + }) }) .flat_map(|manifest| manifest.children().filter(is_reference)) .nth(reference_index), @@ -1882,6 +1889,39 @@ mod tests { assert!(result.all_valid()); } + #[test] + fn query_only_reference_resolves_against_relative_xml_base() { + // A query-only URI replaces the inherited base query without changing + // its relative path; no absolute document base is required by XML Base. + let payload = b"query-selected payload"; + let digest = base64::engine::general_purpose::STANDARD + .encode(compute_digest(DigestAlgorithm::Sha256, payload)); + let xml = format!( + r#" + + + + + {digest} + + AA=="# + ); + let document = Document::parse(&xml).unwrap(); + let signature = document.root_element(); + let signed_info_node = signature + .children() + .find(|node| node.has_tag_name((XMLDSIG_NS, "SignedInfo"))) + .unwrap(); + let signed_info = parse_signed_info(signed_info_node).unwrap(); + let resources = HashMap::from([("a/b?new".to_string(), payload.to_vec())]); + let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources); + + let result = process_all_references(&signed_info.references, &resolver, signature, false) + .expect("query-only URI must resolve against the complete relative base path"); + + assert!(result.all_valid()); + } + #[test] fn manifest_reference_resolution_uses_its_effective_xml_base() { // Manifest references carry their own XML Base context and must not @@ -1925,6 +1965,62 @@ mod tests { assert_eq!(result.status, DsigStatus::Valid); } + #[test] + fn manifest_reference_index_ignores_nested_manifest_descendants() { + // The public Manifest index follows Signature/Object/Manifest structure; + // wrapper descendants must not steal an index and supply another base URI. + let payload = b"direct manifest payload"; + let digest = base64::engine::general_purpose::STANDARD + .encode(compute_digest(DigestAlgorithm::Sha256, payload)); + let xml = format!( + r#" + + + + {digest} + + + + + + {digest} + + + "# + ); + let document = Document::parse(&xml).unwrap(); + let signature = document.root_element(); + let direct_reference_node = signature + .children() + .filter(|node| node.has_tag_name((XMLDSIG_NS, "Object"))) + .nth(1) + .unwrap() + .children() + .find(|node| node.has_tag_name((XMLDSIG_NS, "Manifest"))) + .unwrap() + .children() + .find(|node| node.has_tag_name((XMLDSIG_NS, "Reference"))) + .unwrap(); + let reference = super::super::parse::parse_reference(direct_reference_node).unwrap(); + let resources = HashMap::from([( + "https://example.test/direct/payload.bin".to_string(), + payload.to_vec(), + )]); + let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources); + + let result = process_reference( + &reference, + &resolver, + signature, + ReferenceSet::Manifest, + 0, + false, + ) + .expect("Manifest index must select the direct Object/Manifest reference"); + + assert_eq!(result.status, DsigStatus::Valid); + } + struct RejectingKey; impl VerifyingKey for RejectingKey { From 3ec3a96b99c9d4dc648e962fb0fbd374791da799 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Thu, 6 Aug 2026 16:11:11 +0300 Subject: [PATCH 16/26] fix(xmldsig): harden resolution edge cases - normalize absolute URI paths before external resource lookup - disambiguate same-subject X.509 issuers by certificate signature - replace text-based chain error classification with typed errors - document and test XMLDSig HMAC byte alignment --- src/c14n/xml_base.rs | 23 +++++++++- src/xmldsig/keys.rs | 91 +++++++++++++++++++++++++++++++++++--- src/xmldsig/parse.rs | 102 ++++++++++++++++++++++++++++++++----------- src/xmldsig/uri.rs | 26 +++++++++++ src/xmldsig/x509.rs | 14 ++++++ 5 files changed, 223 insertions(+), 33 deletions(-) diff --git a/src/c14n/xml_base.rs b/src/c14n/xml_base.rs index b2ff1082..c915a034 100644 --- a/src/c14n/xml_base.rs +++ b/src/c14n/xml_base.rs @@ -118,9 +118,15 @@ pub(crate) fn resolve_uri(base: &str, reference: &str) -> String { return base.to_string(); } - // Reference with scheme → use as-is (already absolute) + // A scheme-bearing reference supplies every target component, but RFC 3986 + // section 5.2.2 still requires dot-segment removal from its path. if has_scheme(reference) { - return reference.to_string(); + let (absolute, suffix) = split_path_suffix(reference); + let parts = parse_base(absolute).expect("has_scheme accepted the absolute reference"); + let path = remove_dot_segments(parts.path); + let mut result = recompose(parts.scheme, parts.authority, &path); + result.push_str(suffix); + return result; } // Query- and fragment-only references preserve the complete base path for @@ -383,6 +389,19 @@ mod tests { ); } + #[test] + fn resolve_absolute_reference_removes_dot_segments() { + // RFC 3986 applies dot-segment removal to an absolute reference too; + // its existing scheme only prevents inheritance from the base URI. + assert_eq!( + resolve_uri( + "https://base.example/ignored/", + "https://example.test/a/../data.bin?version=1#payload" + ), + "https://example.test/data.bin?version=1#payload" + ); + } + #[test] fn resolve_empty_reference() { assert_eq!(resolve_uri("http://a.com/b/c", ""), "http://a.com/b/c"); diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 935d2192..b07f4799 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -16,9 +16,10 @@ use super::{ DsigError, KeyInfo, KeyInfoSource, KeyResolver, KeyValueInfo, SignatureAlgorithm, VerifyingKey, X509ChainOptions, X509DataInfo, parse::{ - EC_P256_OID, EC_P384_OID, ParseError, build_x509_certificate_chain_from, - parse_x509_certificate, x509_certificate_matches_any_selector, - x509_data_has_lookup_identifiers, x509_selector_categories_match_chain, + EC_P256_OID, EC_P384_OID, ParseError, X509ChainBuildError, + build_x509_certificate_chain_from, parse_x509_certificate, + x509_certificate_matches_any_selector, x509_data_has_lookup_identifiers, + x509_selector_categories_match_chain, }, verify_dsa_signature_spki, verify_ecdsa_signature_spki, verify_rsa_signature_spki, verify_x509_certificate_chain, @@ -401,9 +402,7 @@ impl DefaultKeyResolver { }; available.certificate_chain = build_x509_certificate_chain_from(&available, signing_index) .map_err(|error| match error { - ParseError::InvalidStructure(reason) if reason.contains("ambiguous") => { - KeyResolutionError::AmbiguousCertificate - } + X509ChainBuildError::AmbiguousIssuer => KeyResolutionError::AmbiguousCertificate, _ => KeyResolutionError::InvalidCertificate, })?; Ok(Some(available)) @@ -789,6 +788,12 @@ mod tests { .with_output_length_bits(79), Err(KeyResolutionError::InvalidHmacOutputLength) )); + assert!(matches!( + HmacSha1VerificationKey::new(b"secret".to_vec()) + .expect("the fixture HMAC secret is non-empty") + .with_output_length_bits(81), + Err(KeyResolutionError::InvalidHmacOutputLength) + )); } #[test] @@ -1038,6 +1043,80 @@ mod tests { assert!(resolved.is_some()); } + #[test] + fn selector_resolved_leaf_disambiguates_same_subject_issuers_by_signature() { + // Certificate renewal may leave multiple configured intermediates with + // the same subject DN. The leaf signature, not pool order, identifies + // the one issuer that belongs to the verification path. + let mut root_params = + rcgen::CertificateParams::new(Vec::new()).expect("empty root SAN list should be valid"); + root_params + .distinguished_name + .push(rcgen::DnType::CommonName, "shared-issuer root"); + root_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + root_params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign]; + let root = rcgen::CertifiedIssuer::self_signed( + root_params, + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("root certificate should be self-signable"); + + let intermediate = |key: rcgen::KeyPair| { + let mut params = rcgen::CertificateParams::new(Vec::new()) + .expect("empty intermediate SAN list should be valid"); + params + .distinguished_name + .push(rcgen::DnType::CommonName, "renewed intermediate"); + params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign]; + rcgen::CertifiedIssuer::signed_by(params, key, &root) + .expect("root should sign the intermediate certificate") + }; + let unrelated_intermediate = intermediate( + rcgen::KeyPair::generate().expect("unrelated intermediate key generation should work"), + ); + let signing_intermediate = intermediate( + rcgen::KeyPair::generate().expect("signing intermediate key generation should work"), + ); + + let mut leaf_params = + rcgen::CertificateParams::new(Vec::new()).expect("empty leaf SAN list should be valid"); + leaf_params + .distinguished_name + .push(rcgen::DnType::CommonName, "same-subject leaf"); + let leaf = leaf_params + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &signing_intermediate, + ) + .expect("the selected intermediate should sign the leaf certificate"); + let key_info_xml = concat!( + "", + "CN=same-subject leaf", + "" + ); + let document = roxmltree::Document::parse(key_info_xml) + .expect("static selector KeyInfo should parse as XML"); + let key_info = super::super::parse_key_info(document.root_element()) + .expect("static selector KeyInfo should satisfy XMLDSig structure"); + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + lookup_certs: vec![ + leaf.der().to_vec(), + unrelated_intermediate.der().to_vec(), + signing_intermediate.der().to_vec(), + ], + trusted_certs: vec![root.der().to_vec()], + verify_chains: true, + ..KeyResolverConfig::default() + }); + + let resolved = resolver + .resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) + .expect("the leaf signature should select its unique same-subject issuer"); + + assert!(resolved.is_some()); + } + #[test] fn selector_resolved_certificate_preserves_supplied_crls() { let selector = "CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=USCRL_PLACEHOLDER"; diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index 206b3316..94b4b658 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -31,6 +31,7 @@ use super::whitespace::{ XmlBase64NormalizeLimitedError, is_xml_whitespace_only, normalize_xml_base64_text, normalize_xml_base64_text_with_limit, }; +use super::x509::certificate_signature_matches; use crate::c14n::C14nAlgorithm; use crate::c14n::xml_base::{compute_effective_xml_base, resolve_uri}; @@ -478,6 +479,9 @@ fn parse_hmac_output_length( .trim() .parse::() .map_err(|_| ParseError::InvalidStructure("invalid HMACOutputLength".into()))?; + // XMLDSig 1.1 section 6.3.1 requires HMAC truncation to end on a + // byte boundary because SignatureValue is encoded as complete octets: + // https://www.w3.org/TR/xmldsig-core1/#sec-HMAC if !(80..=160).contains(&bits) || !bits.is_multiple_of(8) { return Err(ParseError::InvalidStructure( "HMACOutputLength must be a byte-aligned value from 80 through 160".into(), @@ -1153,28 +1157,54 @@ fn build_x509_certificate_chain(info: &X509DataInfo) -> Result, Parse } let signing_idx = select_x509_signing_certificate(info)?; - build_x509_certificate_chain_from(info, signing_idx) + build_x509_certificate_chain_from(info, signing_idx).map_err(ParseError::from) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum X509ChainBuildError { + InconsistentMetadata, + DepthExceeded, + Cycle, + IssuerSignatureMismatch, + AmbiguousIssuer, +} + +impl From for ParseError { + fn from(error: X509ChainBuildError) -> Self { + let reason = match error { + X509ChainBuildError::InconsistentMetadata => { + "X509Data certificate metadata is inconsistent" + } + X509ChainBuildError::DepthExceeded => { + "X509Data certificate chain exceeds maximum depth" + } + X509ChainBuildError::Cycle => "X509Data certificate chain contains a cycle", + X509ChainBuildError::IssuerSignatureMismatch => { + "X509Data issuer candidates do not verify the certificate signature" + } + X509ChainBuildError::AmbiguousIssuer => { + "X509Data certificate chain contains ambiguous issuer certificates" + } + }; + Self::InvalidStructure(reason.into()) + } } /// Order an available certificate pool from a preselected signing certificate. pub(crate) fn build_x509_certificate_chain_from( info: &X509DataInfo, signing_idx: usize, -) -> Result, ParseError> { +) -> Result, X509ChainBuildError> { if signing_idx >= info.parsed_certificates.len() || info.parsed_certificates.len() != info.certificates.len() { - return Err(ParseError::InvalidStructure( - "X509Data certificate metadata is inconsistent".into(), - )); + return Err(X509ChainBuildError::InconsistentMetadata); } let mut chain = vec![signing_idx]; loop { if chain.len() > MAX_X509_CHAIN_DEPTH { - return Err(ParseError::InvalidStructure( - "X509Data certificate chain exceeds maximum depth".into(), - )); + return Err(X509ChainBuildError::DepthExceeded); } let current_idx = *chain @@ -1193,27 +1223,33 @@ pub(crate) fn build_x509_certificate_chain_from( .map(|(idx, _)| idx) .collect::>(); - match candidates.as_slice() { + let issuer_idx = match candidates.as_slice() { [] => break, - [issuer_idx] => { - if chain.contains(issuer_idx) { - return Err(ParseError::InvalidStructure( - "X509Data certificate chain contains a cycle".into(), - )); - } - if chain.len() == MAX_X509_CHAIN_DEPTH { - return Err(ParseError::InvalidStructure( - "X509Data certificate chain exceeds maximum depth".into(), - )); - } - chain.push(*issuer_idx); - } + [issuer_idx] => *issuer_idx, _ => { - return Err(ParseError::InvalidStructure( - "X509Data certificate chain contains ambiguous issuer certificates".into(), - )); + let verified = candidates + .into_iter() + .filter(|issuer_idx| { + certificate_signature_matches( + &info.certificates[current_idx], + &info.certificates[*issuer_idx], + ) + }) + .collect::>(); + match verified.as_slice() { + [issuer_idx] => *issuer_idx, + [] => return Err(X509ChainBuildError::IssuerSignatureMismatch), + _ => return Err(X509ChainBuildError::AmbiguousIssuer), + } } + }; + if chain.contains(&issuer_idx) { + return Err(X509ChainBuildError::Cycle); + } + if chain.len() == MAX_X509_CHAIN_DEPTH { + return Err(X509ChainBuildError::DepthExceeded); } + chain.push(issuer_idx); } Ok(chain) @@ -3671,6 +3707,22 @@ BA== )); } + #[test] + fn parse_hmac_output_length_rejects_non_octet_truncation() { + // XMLDSig 1.1 section 6.3.1 requires a byte boundary even though the + // HMACOutputLength schema represents the value as a bit count. + let xml = r#" + 81 + "#; + let document = Document::parse(xml).unwrap(); + + assert!(matches!( + parse_hmac_output_length(document.root_element(), SignatureAlgorithm::HmacSha1), + Err(ParseError::InvalidStructure(reason)) + if reason == "HMACOutputLength must be a byte-aligned value from 80 through 160" + )); + } + #[test] fn parse_signed_info_rsa_sha256_with_reference() { let xml = r#" diff --git a/src/xmldsig/uri.rs b/src/xmldsig/uri.rs index 3e51d4ea..5b6e93d7 100644 --- a/src/xmldsig/uri.rs +++ b/src/xmldsig/uri.rs @@ -545,6 +545,32 @@ mod tests { assert!(data.into_node_set().is_ok()); } + #[test] + fn absolute_external_uri_uses_normalized_resource_identity() { + // Caller maps are keyed by the resolved RFC 3986 identity, not by an + // unnormalized spelling embedded in an untrusted Signature document. + let xml = r#" + + "#; + let doc = Document::parse(xml).unwrap(); + let reference = doc + .descendants() + .find(|node| node.has_tag_name("reference")) + .unwrap(); + let resources = HashMap::from([( + "https://example.test/data.bin".to_owned(), + b"payload".to_vec(), + )]); + let budget = NodeSetMaterializationBudget::default(); + let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources); + + let data = resolver + .dereference_from_with_budget(reference.attribute("URI").unwrap(), reference, &budget) + .unwrap(); + + assert_eq!(data.into_binary().unwrap(), b"payload"); + } + #[test] fn namespaced_id_attr_found_by_local_name() { // roxmltree strips prefix: `wsu:Id` → local name "Id", which is in DEFAULT_ID_ATTRS diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index f6c0b7ba..5daf7a19 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -219,6 +219,20 @@ fn verify_certificate_signature( ) } +/// Test a candidate certificate-path edge without assigning trust to either +/// certificate. Path construction uses this only to distinguish certificates +/// that share an issuer subject name; full policy validation still happens +/// after the complete path has been assembled. +pub(crate) fn certificate_signature_matches(certificate_der: &[u8], issuer_der: &[u8]) -> bool { + let (Ok(certificate), Ok(issuer)) = ( + parse_certificate(certificate_der), + parse_certificate(issuer_der), + ) else { + return false; + }; + certificate.issuer() == issuer.subject() && verify_certificate_signature(&certificate, &issuer) +} + fn verify_crl_signature(crl: &CertificateRevocationList<'_>, issuer: &X509Certificate<'_>) -> bool { if crl.verify_signature(issuer.public_key()).is_ok() { return true; From 88541da215b0d0884c376ae751929db1dd119d3a Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Thu, 6 Aug 2026 16:47:13 +0300 Subject: [PATCH 17/26] fix(xmldsig): close URI and trust edge cases - preserve relative URI identity for pathless schemeless bases - split Unicode URI suffixes only at UTF-8 boundaries - terminate paths at explicitly trusted selected certificates --- src/c14n/xml_base.rs | 49 +++++++++++++++++++++--------- src/xmldsig/keys.rs | 72 +++++++++++++++++++++++++++++++++++++++++--- src/xmldsig/uri.rs | 49 ++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 20 deletions(-) diff --git a/src/c14n/xml_base.rs b/src/c14n/xml_base.rs index c915a034..da7a9885 100644 --- a/src/c14n/xml_base.rs +++ b/src/c14n/xml_base.rs @@ -150,7 +150,7 @@ pub(crate) fn resolve_uri(base: &str, reference: &str) -> String { } let (ref_path, ref_suffix) = split_path_suffix(reference); let base_path_only = strip_query_fragment(base); - let merged = merge_paths(base_path_only, ref_path); + let merged = merge_paths(base_path_only, ref_path, false); let cleaned = remove_dot_segments(&merged); return format!("{cleaned}{ref_suffix}"); } @@ -193,7 +193,7 @@ pub(crate) fn resolve_uri(base: &str, reference: &str) -> String { // Relative path — merge with base path (strip query/fragment from // base_path first, since merge operates on the path component only). let clean_base_path = strip_query_fragment(base_path); - let merged = merge_paths(clean_base_path, ref_path); + let merged = merge_paths(clean_base_path, ref_path, authority.is_some()); let cleaned = remove_dot_segments(&merged); let mut result = recompose(scheme, authority, &cleaned); result.push_str(ref_suffix); @@ -280,16 +280,14 @@ fn recompose(scheme: &str, authority: Option<&str>, path: &str) -> String { /// first character to preserve leading `?`/`#` semantics (those are handled /// separately as query-only / fragment-only references). fn split_path_suffix(reference: &str) -> (&str, &str) { - // Find the earliest '?' or '#' after position 0 - let mut split_at = reference.len(); - for ch in ['?', '#'] { - if let Some(pos) = reference[1..].find(ch) { - let abs_pos = pos + 1; - if abs_pos < split_at { - split_at = abs_pos; - } - } - } + // Character indices remain valid UTF-8 slice boundaries for untrusted XML + // attribute values. The first scalar is intentionally skipped because + // leading query/fragment references are handled before this helper. + let split_at = reference + .char_indices() + .skip(1) + .find_map(|(index, ch)| matches!(ch, '?' | '#').then_some(index)) + .unwrap_or(reference.len()); (&reference[..split_at], &reference[split_at..]) } @@ -303,8 +301,12 @@ fn strip_query_fragment(s: &str) -> &str { } /// Merge a relative reference with a base path per RFC 3986 §5.2.3. -fn merge_paths(base_path: &str, reference: &str) -> String { - if base_path.is_empty() { +/// +/// An authority with an empty path contributes the leading `/`; an empty +/// schemeless base does not. Keeping that distinction explicit prevents a +/// relative XML Base from changing the reference kind. +fn merge_paths(base_path: &str, reference: &str, base_has_authority: bool) -> String { + if base_has_authority && base_path.is_empty() { format!("/{reference}") } else { // Remove everything after the last segment of base path. @@ -325,7 +327,7 @@ mod merge_tests { /// Non-hierarchical base path (no '/') should return reference as-is. #[test] fn non_hierarchical_base_does_not_add_slash() { - assert_eq!(merge_paths("foo:bar", "baz"), "baz"); + assert_eq!(merge_paths("foo:bar", "baz", false), "baz"); } } @@ -478,6 +480,13 @@ mod tests { assert_eq!(resolve_uri("a/b", "c"), "a/c"); } + #[test] + fn resolve_pathless_schemeless_base_preserves_relative_reference() { + // A query-only relative base has no authority. RFC 3986 therefore + // preserves a relative reference instead of introducing a root slash. + assert_eq!(resolve_uri("?old", "data.bin"), "data.bin"); + } + #[test] fn resolve_query_and_fragment_against_schemeless_base() { // RFC 3986 replaces only the query or fragment even when the effective @@ -531,6 +540,16 @@ mod tests { ); } + #[test] + fn resolve_unicode_reference_with_query_uses_utf8_boundaries() { + // XML attributes are Unicode strings. URI component splitting must not + // index through the first multibyte scalar as if it were one byte. + assert_eq!( + resolve_uri("https://example.test/base/", "é?x"), + "https://example.test/base/é?x" + ); + } + #[test] fn resolve_reference_with_fragment() { // Reference contains fragment — must be preserved in output diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index b07f4799..24ee0d2a 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -400,11 +400,21 @@ impl DefaultKeyResolver { } } }; - available.certificate_chain = build_x509_certificate_chain_from(&available, signing_index) - .map_err(|error| match error { - X509ChainBuildError::AmbiguousIssuer => KeyResolutionError::AmbiguousCertificate, - _ => KeyResolutionError::InvalidCertificate, - })?; + // `available` preserves trusted certificates as a prefix. Selecting + // one of those exact certificates is already a terminal trust + // decision, even when the certificate is not self-signed. + available.certificate_chain = if signing_index < self.config.trusted_certs.len() { + vec![signing_index] + } else { + build_x509_certificate_chain_from(&available, signing_index).map_err(|error| { + match error { + X509ChainBuildError::AmbiguousIssuer => { + KeyResolutionError::AmbiguousCertificate + } + _ => KeyResolutionError::InvalidCertificate, + } + })? + }; Ok(Some(available)) } @@ -933,6 +943,58 @@ mod tests { assert!(resolved.is_some()); } + #[test] + fn selector_resolved_non_self_signed_trust_anchor_terminates_the_path() { + // Trust is assigned to the exact configured certificate, not inferred + // from self-signing. A lookup-only issuer must not extend that anchor + // into a new path that requires another trust decision. + let mut issuer_params = rcgen::CertificateParams::new(Vec::new()) + .expect("empty issuer SAN list should be valid"); + issuer_params + .distinguished_name + .push(rcgen::DnType::CommonName, "lookup-only issuer"); + issuer_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + issuer_params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign]; + let issuer = rcgen::CertifiedIssuer::self_signed( + issuer_params, + rcgen::KeyPair::generate().expect("issuer key generation should succeed"), + ) + .expect("issuer certificate should be self-signable"); + + let mut anchor_params = rcgen::CertificateParams::new(Vec::new()) + .expect("empty anchor SAN list should be valid"); + anchor_params + .distinguished_name + .push(rcgen::DnType::CommonName, "direct trust anchor"); + let anchor = anchor_params + .signed_by( + &rcgen::KeyPair::generate().expect("anchor key generation should succeed"), + &issuer, + ) + .expect("issuer should sign the directly trusted certificate"); + let key_info_xml = concat!( + "", + "CN=direct trust anchor", + "" + ); + let document = roxmltree::Document::parse(key_info_xml) + .expect("static selector KeyInfo should parse as XML"); + let key_info = super::super::parse_key_info(document.root_element()) + .expect("static selector KeyInfo should satisfy XMLDSig structure"); + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + trusted_certs: vec![anchor.der().to_vec()], + lookup_certs: vec![issuer.der().to_vec()], + verify_chains: true, + ..KeyResolverConfig::default() + }); + + let resolved = resolver + .resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) + .expect("an explicitly trusted selected certificate must terminate its path"); + + assert!(resolved.is_some()); + } + #[test] fn selector_resolved_leaf_does_not_anchor_itself() { // A certificate available for selector lookup is not automatically a diff --git a/src/xmldsig/uri.rs b/src/xmldsig/uri.rs index 5b6e93d7..99788a33 100644 --- a/src/xmldsig/uri.rs +++ b/src/xmldsig/uri.rs @@ -571,6 +571,55 @@ mod tests { assert_eq!(data.into_binary().unwrap(), b"payload"); } + #[test] + fn pathless_relative_xml_base_preserves_relative_resource_identity() { + // Query-only xml:base values do not turn a relative URI into an + // absolute-path reference when resolving caller-owned resources. + let xml = r#" + + "#; + let doc = Document::parse(xml).unwrap(); + let reference = doc + .descendants() + .find(|node| node.has_tag_name("reference")) + .unwrap(); + let resources = HashMap::from([("data.bin".to_owned(), b"payload".to_vec())]); + let budget = NodeSetMaterializationBudget::default(); + let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources); + + let data = resolver + .dereference_from_with_budget(reference.attribute("URI").unwrap(), reference, &budget) + .unwrap(); + + assert_eq!(data.into_binary().unwrap(), b"payload"); + } + + #[test] + fn unicode_external_uri_resolves_without_panicking() { + // Untrusted XML may start a relative URI with a multibyte scalar; the + // resolver must produce its UTF-8 resource identity without panicking. + let xml = r#" + + "#; + let doc = Document::parse(xml).unwrap(); + let reference = doc + .descendants() + .find(|node| node.has_tag_name("reference")) + .unwrap(); + let resources = HashMap::from([( + "https://example.test/base/é?x".to_owned(), + b"payload".to_vec(), + )]); + let budget = NodeSetMaterializationBudget::default(); + let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources); + + let data = resolver + .dereference_from_with_budget(reference.attribute("URI").unwrap(), reference, &budget) + .unwrap(); + + assert_eq!(data.into_binary().unwrap(), b"payload"); + } + #[test] fn namespaced_id_attr_found_by_local_name() { // roxmltree strips prefix: `wsu:Id` → local name "Id", which is in DEFAULT_ID_ATTRS From c1d0bd7a7cf1fd5e7c4d30756a326d473223bcb7 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Thu, 6 Aug 2026 18:17:30 +0300 Subject: [PATCH 18/26] fix(xmldsig): harden fallback resolution - normalize RFC 3986 absolute references with relative XML bases - defer missing retrieval sources until key alternatives are exhausted - apply RFC 5280 name matching consistently across certificate paths - remove the redundant certificate-chain depth guard --- src/c14n/xml_base.rs | 26 ++++++++-- src/xmldsig/parse.rs | 8 +-- src/xmldsig/uri.rs | 23 +++++++++ src/xmldsig/verify.rs | 111 ++++++++++++++++++++++++++++++++++++++---- src/xmldsig/x509.rs | 78 ++++++++++++++++++++++++++--- 5 files changed, 221 insertions(+), 25 deletions(-) diff --git a/src/c14n/xml_base.rs b/src/c14n/xml_base.rs index da7a9885..7ca3221e 100644 --- a/src/c14n/xml_base.rs +++ b/src/c14n/xml_base.rs @@ -145,10 +145,17 @@ pub(crate) fn resolve_uri(base: &str, reference: &str) -> String { // Schemeless/relative base. Still perform path-merge and // dot-segment removal so that a chain of relative xml:base // values is correctly collapsed (e.g. "a/b/" + "c/" → "a/b/c/"). - if reference.starts_with("//") || reference.starts_with('/') { - return reference.to_string(); - } let (ref_path, ref_suffix) = split_path_suffix(reference); + if let Some(rest) = ref_path.strip_prefix("//") { + let authority_end = rest.find('/').unwrap_or(rest.len()); + let authority = &rest[..authority_end]; + let path = remove_dot_segments(&rest[authority_end..]); + return format!("//{authority}{path}{ref_suffix}"); + } + if ref_path.starts_with('/') { + let path = remove_dot_segments(ref_path); + return format!("{path}{ref_suffix}"); + } let base_path_only = strip_query_fragment(base); let merged = merge_paths(base_path_only, ref_path, false); let cleaned = remove_dot_segments(&merged); @@ -480,6 +487,19 @@ mod tests { assert_eq!(resolve_uri("a/b", "c"), "a/c"); } + #[test] + fn resolve_absolute_path_normalizes_against_schemeless_base() { + assert_eq!(resolve_uri("a/b", "/x/../data.bin"), "/data.bin"); + } + + #[test] + fn resolve_network_path_normalizes_against_schemeless_base() { + assert_eq!( + resolve_uri("a/b", "//cdn.example/x/../data.bin?version=1"), + "//cdn.example/data.bin?version=1" + ); + } + #[test] fn resolve_pathless_schemeless_base_preserves_relative_reference() { // A query-only relative base has no authority. RFC 3986 therefore diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index 94b4b658..88af5af1 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -1203,10 +1203,6 @@ pub(crate) fn build_x509_certificate_chain_from( let mut chain = vec![signing_idx]; loop { - if chain.len() > MAX_X509_CHAIN_DEPTH { - return Err(X509ChainBuildError::DepthExceeded); - } - let current_idx = *chain .last() .expect("chain starts with signing certificate index"); @@ -1414,7 +1410,7 @@ pub(crate) fn x509_selector_categories_match_chain( Ok(subject_match && issuer_serial_match && ski_match && digest_match) } -fn distinguished_names_equal(left: &str, right: &str) -> bool { +pub(crate) fn distinguished_names_equal(left: &str, right: &str) -> bool { fn attribute_values_equal( left: &x509_cert::attr::AttributeTypeAndValue, right: &x509_cert::attr::AttributeTypeAndValue, @@ -1694,7 +1690,7 @@ pub(crate) fn parse_x509_certificate(cert_der: &[u8]) -> Result) -> Result { +pub(crate) fn x509_name_to_rfc4514(name: &X509Name<'_>) -> Result { let name = Name::from_der(name.as_raw()).map_err(|error| { ParseError::InvalidStructure(format!( "X509Certificate distinguished name is invalid DER: {error}" diff --git a/src/xmldsig/uri.rs b/src/xmldsig/uri.rs index 99788a33..191bb75e 100644 --- a/src/xmldsig/uri.rs +++ b/src/xmldsig/uri.rs @@ -594,6 +594,29 @@ mod tests { assert_eq!(data.into_binary().unwrap(), b"payload"); } + #[test] + fn relative_xml_base_normalizes_absolute_external_path() { + // An absolute-path reference replaces a relative base path, but RFC + // 3986 dot-segment removal still defines the caller resource identity. + let xml = r#" + + "#; + let doc = Document::parse(xml).unwrap(); + let reference = doc + .descendants() + .find(|node| node.has_tag_name("reference")) + .unwrap(); + let resources = HashMap::from([("/data.bin".to_owned(), b"payload".to_vec())]); + let budget = NodeSetMaterializationBudget::default(); + let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources); + + let data = resolver + .dereference_from_with_budget(reference.attribute("URI").unwrap(), reference, &budget) + .unwrap(); + + assert_eq!(data.into_binary().unwrap(), b"payload"); + } + #[test] fn unicode_external_uri_resolves_without_panicking() { // Untrusted XML may start a relative URI with a multibyte scalar; the diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index 02d8b717..817162be 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -956,14 +956,16 @@ fn verify_signature_with_context( Some(resources) => UriReferenceResolver::new(&doc).with_external_resources(resources), None => UriReferenceResolver::new(&doc), }; - if let Some(info) = key_info.as_mut() { + let retrieval_materialization = if let Some(info) = key_info.as_mut() { materialize_retrieval_methods( info, &resolver, ctx.external_resources, ctx.allowed_retrieval_method_uri_types, - )?; - } + )? + } else { + RetrievalMaterialization::default() + }; let execution_budget = TransformExecutionBudget::default(); let pre_digest_budget = PreDigestRetentionBudget::default(); let execution = ReferenceExecutionContext { @@ -1016,6 +1018,9 @@ fn verify_signature_with_context( let Some(resolved_key) = resolve_verifying_key(ctx, key_info.as_ref(), signed_info.signature_method)? else { + if let Some(error) = retrieval_materialization.deferred_error { + return Err(error); + } return Ok(VerifyResult { status: DsigStatus::Invalid(FailureReason::KeyNotFound), signed_info_references: references.results, @@ -1074,12 +1079,17 @@ fn verify_signature_with_context( }) } +#[derive(Debug, Default)] +struct RetrievalMaterialization { + deferred_error: Option, +} + fn materialize_retrieval_methods( key_info: &mut KeyInfo, resolver: &UriReferenceResolver<'_>, external_resources: Option<&HashMap>>, allowed_uri_types: UriTypeSet, -) -> Result<(), SignatureVerificationPipelineError> { +) -> Result { let retrieval_count = key_info .sources .iter() @@ -1094,6 +1104,7 @@ fn materialize_retrieval_methods( let mut total_binary_len = existing_x509_binary_len(key_info)?; let mut seen = HashSet::new(); let mut materialized = Vec::with_capacity(key_info.sources.len()); + let mut outcome = RetrievalMaterialization::default(); for source in std::mem::take(&mut key_info.sources) { let super::parse::KeyInfoSource::RetrievalMethod { uri, @@ -1122,15 +1133,22 @@ fn materialize_retrieval_methods( if !allowed_uri_types.allows(&uri) { return Err(SignatureVerificationPipelineError::DisallowedUri { uri }); } - let certificate = external_resources - .and_then(|resources| resources.get(&uri)) - .ok_or_else(|| { + let Some(certificate) = external_resources.and_then(|resources| resources.get(&uri)) + else { + outcome.deferred_error.get_or_insert_with(|| { SignatureVerificationPipelineError::Reference( ReferenceProcessingError::Transform(super::TransformError::UnsupportedUri( uri.clone(), )), ) - })?; + }); + materialized.push(super::parse::KeyInfoSource::RetrievalMethod { + uri, + resource_type, + transforms, + }); + continue; + }; if certificate.len() > MAX_X509_DECODED_BINARY_LEN { return Err(SignatureVerificationPipelineError::InvalidStructure { reason: "raw X509 RetrievalMethod certificate exceeds maximum allowed length", @@ -1193,7 +1211,7 @@ fn materialize_retrieval_methods( } } key_info.sources = materialized; - Ok(()) + Ok(outcome) } fn select_retrieved_x509_data_root<'a, 'input>( @@ -2115,6 +2133,31 @@ mod tests { } } + struct EarlyKeyInfoResolver; + + impl KeyResolver for EarlyKeyInfoResolver { + fn resolve<'a>( + &'a self, + key_info: Option<&KeyInfo>, + _algorithm: SignatureAlgorithm, + ) -> Result>, SignatureVerificationPipelineError> + { + let sources = &key_info.expect("KeyInfo must be parsed").sources; + assert!(matches!( + sources.as_slice(), + [ + super::super::parse::KeyInfoSource::KeyName(name), + super::super::parse::KeyInfoSource::RetrievalMethod { .. }, + ] if name == "primary" + )); + Ok(Some(Box::new(AcceptingKey))) + } + + fn consumes_document_key_info(&self) -> bool { + true + } + } + fn minimal_signature_xml(reference_uri: &str, transforms_xml: &str) -> String { format!( r#" @@ -3425,6 +3468,56 @@ mod tests { assert_eq!(result.status, DsigStatus::Valid); } + #[test] + fn verify_context_does_not_eagerly_fail_unused_retrieval_fallback() { + // KeyInfo sources are alternatives in document order. Once an earlier + // source resolves, a missing later RetrievalMethod is irrelevant. + let xml = signature_with_target_reference("AQ==").replace( + "\n ", + r#" + + primary + + + "#, + ); + + let result = VerifyContext::new() + .key_resolver(&EarlyKeyInfoResolver) + .allowed_retrieval_method_uri_types(UriTypeSet::new(true, true, true)) + .verify(&xml) + .expect("an unused missing retrieval fallback must not abort verification"); + + assert_eq!(result.status, DsigStatus::Valid); + } + + #[test] + fn verify_context_reports_missing_retrieval_when_no_key_source_resolves() { + // Deferral changes ordering, not diagnostics: if no alternative source + // resolves, the first missing retrieval remains the pipeline failure. + let xml = signature_with_target_reference("AQ==").replace( + "\n ", + r#" + + + + "#, + ); + + let error = VerifyContext::new() + .key_resolver(&ConsumingKeyInfoResolver) + .allowed_retrieval_method_uri_types(UriTypeSet::new(true, true, true)) + .verify(&xml) + .expect_err("a missing sole RetrievalMethod must remain an explicit error"); + + assert!(matches!( + error, + SignatureVerificationPipelineError::Reference(ReferenceProcessingError::Transform( + crate::xmldsig::TransformError::UnsupportedUri(uri) + )) if uri == "missing.der" + )); + } + #[test] fn verify_context_preserves_signaturevalue_decode_errors_when_resolver_misses() { let xml = signature_with_target_reference("@@@"); diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index 5daf7a19..4da70524 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -12,7 +12,10 @@ use x509_parser::{ revocation_list::CertificateRevocationList, time::ASN1Time, }; -use super::X509DataInfo; +use super::{ + X509DataInfo, + parse::{distinguished_names_equal, x509_name_to_rfc4514}, +}; /// Inputs controlling X.509 certificate-chain validation. #[derive(Debug, Clone)] @@ -124,11 +127,12 @@ pub fn verify_x509_certificate_chain( // Use the path-edge verifier here too: x509-parser does not verify legacy // DSA-SHA1 roots, while our fallback must recognize them for rollover. let replace_untrusted_root = if path_der.len() > 1 - && last.subject() == last.issuer() + && certificate_names_equal(last.subject(), last.issuer()) && verify_certificate_signature(&last, &last) { let child = parse_certificate(path_der[path_der.len() - 2])?; - child.issuer() == last.subject() && verify_certificate_signature(&child, &last) + certificate_names_equal(child.issuer(), last.subject()) + && verify_certificate_signature(&child, &last) } else { false }; @@ -146,7 +150,7 @@ pub fn verify_x509_certificate_chain( let mut first_validation_error = None; for (anchor_der, _) in trusted_anchors.iter().filter(|(_, cert)| { - cert.subject() == candidate_child.issuer() + certificate_names_equal(cert.subject(), candidate_child.issuer()) && verify_certificate_signature(&candidate_child, cert) }) { let mut candidate_path = candidate_base.to_vec(); @@ -190,7 +194,9 @@ fn validate_path( let [child, issuer] = pair else { unreachable!() }; - if child.issuer() != issuer.subject() || !verify_certificate_signature(child, issuer) { + if !certificate_names_equal(child.issuer(), issuer.subject()) + || !verify_certificate_signature(child, issuer) + { return Err(X509ChainError::InvalidSignature(position)); } } @@ -230,7 +236,17 @@ pub(crate) fn certificate_signature_matches(certificate_der: &[u8], issuer_der: ) else { return false; }; - certificate.issuer() == issuer.subject() && verify_certificate_signature(&certificate, &issuer) + verify_certificate_signature(&certificate, &issuer) +} + +fn certificate_names_equal( + left: &x509_parser::x509::X509Name<'_>, + right: &x509_parser::x509::X509Name<'_>, +) -> bool { + let (Ok(left), Ok(right)) = (x509_name_to_rfc4514(left), x509_name_to_rfc4514(right)) else { + return false; + }; + distinguished_names_equal(&left, &right) } fn verify_crl_signature(crl: &CertificateRevocationList<'_>, issuer: &X509Certificate<'_>) -> bool { @@ -373,7 +389,10 @@ fn verify_crls( for (position, cert) in path.iter().enumerate().take(path.len().saturating_sub(1)) { let issuer = &path[position + 1]; - for (crl_index, crl) in crls.iter().filter(|(_, crl)| crl.issuer() == cert.issuer()) { + for (crl_index, crl) in crls + .iter() + .filter(|(_, crl)| certificate_names_equal(crl.issuer(), cert.issuer())) + { if issuer .key_usage() .map_err(|error| X509ChainError::InvalidDer { @@ -412,6 +431,51 @@ mod tests { use roxmltree::Document; use std::time::Duration; + #[test] + fn path_edge_signature_check_does_not_repeat_name_matching() { + // Path construction performs RFC 5280 name matching before asking this + // helper to disambiguate same-name candidates. Only proof of possession + // of the issuer key belongs in this second gate. + let issuer_key = rcgen::KeyPair::generate().expect("issuer key generation should succeed"); + let issuer_key_pem = issuer_key.serialize_pem(); + let mut signing_params = rcgen::CertificateParams::new(Vec::new()) + .expect("empty issuer SAN list should be valid"); + signing_params + .distinguished_name + .push(rcgen::DnType::CommonName, "signing name"); + signing_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + signing_params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign]; + let signing_issuer = rcgen::CertifiedIssuer::self_signed(signing_params, issuer_key) + .expect("issuer certificate should be self-signable"); + + let mut alternate_params = rcgen::CertificateParams::new(Vec::new()) + .expect("empty alternate SAN list should be valid"); + alternate_params + .distinguished_name + .push(rcgen::DnType::CommonName, "name already matched by caller"); + alternate_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + alternate_params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign]; + let alternate_issuer = rcgen::CertifiedIssuer::self_signed( + alternate_params, + rcgen::KeyPair::from_pem(&issuer_key_pem) + .expect("serialized issuer key should parse again"), + ) + .expect("alternate issuer certificate should be self-signable"); + + let leaf = rcgen::CertificateParams::new(Vec::new()) + .expect("empty leaf SAN list should be valid") + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &signing_issuer, + ) + .expect("issuer should sign leaf certificate"); + + assert!(certificate_signature_matches( + leaf.der(), + alternate_issuer.der() + )); + } + #[test] fn dsa_rollover_replaces_embedded_root_before_depth_validation() { let leaf = include_bytes!( From abd05ed65f59d9e982ed448af130b1f568ddf2fd Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Thu, 6 Aug 2026 20:17:15 +0300 Subject: [PATCH 19/26] fix(xmldsig): unify detached parse policy - Normalize scheme-bearing rootless URI dot segments per RFC 3986 - Apply internal-DTD policy consistently to root and detached XML - Preserve the external-entity prohibition and add regression coverage --- docs/xmldsig.md | 10 +++-- src/c14n/xml_base.rs | 32 +++++++++++++++- src/xmldsig/transforms.rs | 15 +++++++- src/xmldsig/uri.rs | 21 +++++++++++ src/xmldsig/verify.rs | 79 +++++++++++++++++++++++++++++++++++++-- 5 files changed, 146 insertions(+), 11 deletions(-) diff --git a/docs/xmldsig.md b/docs/xmldsig.md index a98dfa73..1bd78325 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -63,10 +63,12 @@ owning element's effective `xml:base` using RFC 3986 before lookup, so resource- that resolved URI. Other retrieval transform chains fail closed instead of being ignored. Internal DTD declarations are disabled by default and require -`VerifyContext::allow_internal_dtd(true)`. External entity resolution remains disabled. XSLT is -intentionally not executed because transforms operate on attacker-controlled documents; an -authenticated Manifest reference using unsupported XSLT is reported as an invalid per-reference -result without changing core `SignedInfo` validity. +`VerifyContext::allow_internal_dtd(true)`. The policy applies consistently to the signed document +and caller-supplied detached XML parsed by node-set transforms. Direct transform callers can set +the same policy with `TransformOptions::allow_internal_dtd(true)`. External entity resolution +remains disabled. XSLT is intentionally not executed because transforms operate on +attacker-controlled documents; an authenticated Manifest reference using unsupported XSLT is +reported as an invalid per-reference result without changing core `SignedInfo` validity. ## Current Scope diff --git a/src/c14n/xml_base.rs b/src/c14n/xml_base.rs index 7ca3221e..fadd3f10 100644 --- a/src/c14n/xml_base.rs +++ b/src/c14n/xml_base.rs @@ -123,7 +123,7 @@ pub(crate) fn resolve_uri(base: &str, reference: &str) -> String { if has_scheme(reference) { let (absolute, suffix) = split_path_suffix(reference); let parts = parse_base(absolute).expect("has_scheme accepted the absolute reference"); - let path = remove_dot_segments(parts.path); + let path = remove_dot_segments_from_absolute_reference(parts.path); let mut result = recompose(parts.scheme, parts.authority, &path); result.push_str(suffix); return result; @@ -343,6 +343,20 @@ mod merge_tests { /// For absolute paths (starting with `/`), `..` at the root is a no-op. /// For relative paths, unresolved leading `..` segments are preserved. fn remove_dot_segments(path: &str) -> String { + remove_dot_segments_with_unmatched_parents(path, true) +} + +/// Apply RFC 3986 section 5.2.4 to a reference that already supplied a scheme. +/// Such a reference is the final target, so unresolved leading parents are +/// discarded rather than retained for a later base-path merge. +fn remove_dot_segments_from_absolute_reference(path: &str) -> String { + remove_dot_segments_with_unmatched_parents(path, false) +} + +fn remove_dot_segments_with_unmatched_parents( + path: &str, + preserve_unmatched_parents: bool, +) -> String { let is_absolute = path.starts_with('/'); let mut segments: Vec<&str> = Vec::new(); @@ -364,7 +378,7 @@ fn remove_dot_segments(path: &str) -> String { }; if can_pop { segments.pop(); - } else if !is_absolute { + } else if !is_absolute && preserve_unmatched_parents { segments.push(".."); } } @@ -524,6 +538,20 @@ mod tests { ); } + #[test] + fn resolve_rootless_absolute_uri_removes_leading_dot_segments() { + // Once a reference supplies its own scheme, RFC 3986 section 5.2.4 + // discards unresolved leading dot segments from the final target path. + assert_eq!( + resolve_uri("https://example.test/base", "urn:../payload?version=1"), + "urn:payload?version=1" + ); + assert_eq!( + resolve_uri("https://example.test/base", "urn:./payload"), + "urn:payload" + ); + } + #[test] fn resolve_parent_beyond_root() { // Going past root with .. should stop at root diff --git a/src/xmldsig/transforms.rs b/src/xmldsig/transforms.rs index c9c3d941..f12f1632 100644 --- a/src/xmldsig/transforms.rs +++ b/src/xmldsig/transforms.rs @@ -93,6 +93,7 @@ pub enum XPathHereSemantics { #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct TransformOptions { xpath_here_semantics: XPathHereSemantics, + allow_internal_dtd: bool, } #[derive(Default)] @@ -247,9 +248,21 @@ impl TransformOptions { self } + /// Allow internal DTD declarations when a transform parses caller-supplied + /// octets as XML. External entity resolution remains disabled. + #[must_use] + pub fn allow_internal_dtd(mut self, enabled: bool) -> Self { + self.allow_internal_dtd = enabled; + self + } + pub(crate) fn here_semantics(self) -> XPathHereSemantics { self.xpath_here_semantics } + + pub(crate) fn internal_dtd_allowed(self) -> bool { + self.allow_internal_dtd + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -779,7 +792,7 @@ fn execute_transform_chain<'s, 'e, 'd>( let document = roxmltree::Document::parse_with_options( &xml, roxmltree::ParsingOptions { - allow_dtd: false, + allow_dtd: context.options.internal_dtd_allowed(), nodes_limit: XML_DOCUMENT_NODE_CEILING, entity_resolver: None, }, diff --git a/src/xmldsig/uri.rs b/src/xmldsig/uri.rs index 191bb75e..64039dfe 100644 --- a/src/xmldsig/uri.rs +++ b/src/xmldsig/uri.rs @@ -643,6 +643,27 @@ mod tests { assert_eq!(data.into_binary().unwrap(), b"payload"); } + #[test] + fn absolute_rootless_external_uri_discards_leading_parent_segment() { + let xml = r#" + + "#; + let doc = Document::parse(xml).unwrap(); + let reference = doc + .descendants() + .find(|node| node.has_tag_name("reference")) + .unwrap(); + let resources = HashMap::from([("urn:payload".to_owned(), b"payload".to_vec())]); + let budget = NodeSetMaterializationBudget::default(); + let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources); + + let data = resolver + .dereference_from_with_budget(reference.attribute("URI").unwrap(), reference, &budget) + .unwrap(); + + assert_eq!(data.into_binary().unwrap(), b"payload"); + } + #[test] fn namespaced_id_attr_found_by_local_name() { // roxmltree strips prefix: `wsu:Id` → local name "Id", which is in DEFAULT_ID_ATTRS diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index 817162be..ff205c19 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -169,7 +169,6 @@ pub struct VerifyContext<'a> { store_pre_digest: bool, transform_options: TransformOptions, external_resources: Option<&'a HashMap>>, - allow_internal_dtd: bool, } impl<'a> VerifyContext<'a> { @@ -192,7 +191,6 @@ impl<'a> VerifyContext<'a> { store_pre_digest: false, transform_options: TransformOptions::default(), external_resources: None, - allow_internal_dtd: false, } } @@ -273,7 +271,7 @@ impl<'a> VerifyContext<'a> { /// Allow bounded internal DTD declarations while keeping external entity /// resolution disabled. This is off by default. pub fn allow_internal_dtd(mut self, enabled: bool) -> Self { - self.allow_internal_dtd = enabled; + self.transform_options = self.transform_options.allow_internal_dtd(enabled); self } @@ -882,7 +880,7 @@ fn verify_signature_with_context( let doc = Document::parse_with_options( xml, roxmltree::ParsingOptions { - allow_dtd: ctx.allow_internal_dtd, + allow_dtd: ctx.transform_options.internal_dtd_allowed(), nodes_limit: XML_DOCUMENT_NODE_CEILING, entity_resolver: None, }, @@ -1907,6 +1905,79 @@ mod tests { assert!(result.all_valid()); } + #[test] + fn internal_dtd_opt_in_applies_to_detached_xml_transforms() { + // The parse policy covers every XML document in one verification + // pipeline, including caller-owned octets converted to a node-set. + let detached = b"]>ok"; + let digest = base64::engine::general_purpose::STANDARD.encode(compute_digest( + DigestAlgorithm::Sha256, + b"ok", + )); + let xml = format!( + r#" + + + + + + + + + + {digest} + + + AQ== + +"# + ); + let resources = HashMap::from([("urn:detached-dtd".to_owned(), detached.to_vec())]); + let key = AcceptingKey; + + let default_error = VerifyContext::new() + .key(&key) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&xml) + .expect_err("internal DTD parsing must remain disabled by default"); + assert!(matches!( + default_error, + SignatureVerificationPipelineError::Reference(ReferenceProcessingError::Transform( + crate::xmldsig::TransformError::XmlParse(_) + )) + )); + + let result = VerifyContext::new() + .key(&key) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .allow_internal_dtd(true) + .verify(&xml) + .expect("the explicit DTD opt-in must cover detached XML transforms"); + + assert_eq!(result.status, DsigStatus::Valid); + + let external_entity = br#" + ]>&ext;"#; + let external_entity_resources = + HashMap::from([("urn:detached-dtd".to_owned(), external_entity.to_vec())]); + let external_entity_error = VerifyContext::new() + .key(&key) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&external_entity_resources) + .allow_internal_dtd(true) + .verify(&xml) + .expect_err("the internal-DTD opt-in must not resolve external entities"); + assert!(matches!( + external_entity_error, + SignatureVerificationPipelineError::Reference(ReferenceProcessingError::Transform( + crate::xmldsig::TransformError::XmlParse(_) + )) + )); + } + #[test] fn query_only_reference_resolves_against_relative_xml_base() { // A query-only URI replaces the inherited base query without changing From 1a8d77b780965ea3bf6e16d69da8f6ed9186341f Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 7 Aug 2026 02:03:09 +0300 Subject: [PATCH 20/26] fix(xmldsig): enforce URI and X.509 invariants - Preserve authority when resolving against network-path XML bases - Require matching inner and outer signature algorithms for certificates and CRLs - Cover helper, resolver, certificate, and revocation paths --- src/c14n/xml_base.rs | 65 ++++++++++++++++++++++++++++++++++++-------- src/xmldsig/uri.rs | 23 ++++++++++++++++ src/xmldsig/x509.rs | 63 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 11 deletions(-) diff --git a/src/c14n/xml_base.rs b/src/c14n/xml_base.rs index fadd3f10..d1a96c23 100644 --- a/src/c14n/xml_base.rs +++ b/src/c14n/xml_base.rs @@ -142,24 +142,33 @@ pub(crate) fn resolve_uri(base: &str, reference: &str) -> String { let base_parts = match parse_base(base) { Some(parts) => parts, None => { - // Schemeless/relative base. Still perform path-merge and - // dot-segment removal so that a chain of relative xml:base - // values is correctly collapsed (e.g. "a/b/" + "c/" → "a/b/c/"). + // Schemeless bases include both ordinary relative paths and + // network-path references. Preserve the latter's authority while + // applying the same RFC 3986 path merge and normalization rules. let (ref_path, ref_suffix) = split_path_suffix(reference); - if let Some(rest) = ref_path.strip_prefix("//") { - let authority_end = rest.find('/').unwrap_or(rest.len()); - let authority = &rest[..authority_end]; - let path = remove_dot_segments(&rest[authority_end..]); + if let Some((authority, path)) = parse_network_path(ref_path) { + let path = remove_dot_segments(path); return format!("//{authority}{path}{ref_suffix}"); } + let (base_path_with_authority, _) = split_path_suffix(base); + let network_base = parse_network_path(base_path_with_authority); if ref_path.starts_with('/') { let path = remove_dot_segments(ref_path); - return format!("{path}{ref_suffix}"); + return match network_base { + Some((authority, _)) => format!("//{authority}{path}{ref_suffix}"), + None => format!("{path}{ref_suffix}"), + }; } - let base_path_only = strip_query_fragment(base); - let merged = merge_paths(base_path_only, ref_path, false); + let (base_path, authority) = match network_base { + Some((authority, path)) => (path, Some(authority)), + None => (base_path_with_authority, None), + }; + let merged = merge_paths(base_path, ref_path, authority.is_some()); let cleaned = remove_dot_segments(&merged); - return format!("{cleaned}{ref_suffix}"); + return match authority { + Some(authority) => format!("//{authority}{cleaned}{ref_suffix}"), + None => format!("{cleaned}{ref_suffix}"), + }; } }; let scheme = base_parts.scheme; @@ -271,6 +280,14 @@ fn parse_base(base: &str) -> Option> { }) } +/// Split a schemeless network-path reference into authority and path. +/// Query and fragment components must already have been removed. +fn parse_network_path(reference: &str) -> Option<(&str, &str)> { + let rest = reference.strip_prefix("//")?; + let authority_end = rest.find('/').unwrap_or(rest.len()); + Some((&rest[..authority_end], &rest[authority_end..])) +} + /// Recompose a URI from scheme, optional authority, and path per RFC 3986 §5.3. /// /// `authority = Some("")` → `scheme:///path` (empty authority, e.g. `file:///`). @@ -514,6 +531,32 @@ mod tests { ); } + #[test] + fn resolve_against_network_path_base_preserves_authority() { + // A network-path base has an authority even without a scheme. RFC 3986 + // resolution must not collapse it into an ordinary absolute path. + assert_eq!( + resolve_uri("//cdn.example/a/b/", "/x/../data.bin?version=1"), + "//cdn.example/data.bin?version=1" + ); + assert_eq!( + resolve_uri("//cdn.example/a/b/", "../data.bin"), + "//cdn.example/a/data.bin" + ); + assert_eq!( + resolve_uri("//cdn.example/a/b?old#fragment", "?new"), + "//cdn.example/a/b?new" + ); + assert_eq!( + resolve_uri("//cdn.example/a/b?old#fragment", "#new"), + "//cdn.example/a/b?old#new" + ); + assert_eq!( + resolve_uri("//cdn.example/a/b/", "//other.example/x/../data.bin"), + "//other.example/data.bin" + ); + } + #[test] fn resolve_pathless_schemeless_base_preserves_relative_reference() { // A query-only relative base has no authority. RFC 3986 therefore diff --git a/src/xmldsig/uri.rs b/src/xmldsig/uri.rs index 64039dfe..d5cb431a 100644 --- a/src/xmldsig/uri.rs +++ b/src/xmldsig/uri.rs @@ -617,6 +617,29 @@ mod tests { assert_eq!(data.into_binary().unwrap(), b"payload"); } + #[test] + fn network_path_xml_base_preserves_external_resource_authority() { + // A schemeless authority remains part of the resolved caller-owned + // resource identity when an absolute-path URI replaces the base path. + let xml = r#" + + "#; + let doc = Document::parse(xml).unwrap(); + let reference = doc + .descendants() + .find(|node| node.has_tag_name("reference")) + .unwrap(); + let resources = HashMap::from([("//cdn.example/data.bin".to_owned(), b"payload".to_vec())]); + let budget = NodeSetMaterializationBudget::default(); + let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources); + + let data = resolver + .dereference_from_with_budget(reference.attribute("URI").unwrap(), reference, &budget) + .unwrap(); + + assert_eq!(data.into_binary().unwrap(), b"payload"); + } + #[test] fn unicode_external_uri_resolves_without_panicking() { // Untrusted XML may start a relative URI with a multibyte scalar; the diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index 4da70524..8e7c85d9 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -211,6 +211,12 @@ fn verify_certificate_signature( certificate: &X509Certificate<'_>, issuer: &X509Certificate<'_>, ) -> bool { + // RFC 5280 sections 4.1.1.2 and 4.1.2.3 require the outer and signed + // AlgorithmIdentifier values to be identical. Enforce this independently + // of the backend so the legacy DSA path cannot bypass the invariant. + if certificate.signature_algorithm != certificate.tbs_certificate.signature { + return false; + } if certificate .verify_signature(Some(issuer.public_key())) .is_ok() @@ -250,6 +256,11 @@ fn certificate_names_equal( } fn verify_crl_signature(crl: &CertificateRevocationList<'_>, issuer: &X509Certificate<'_>) -> bool { + // RFC 5280 sections 5.1.1.2 and 5.1.2.2 impose the same equality rule on + // CRLs as certificates. + if crl.signature_algorithm != crl.tbs_cert_list.signature { + return false; + } if crl.verify_signature(issuer.public_key()).is_ok() { return true; } @@ -511,6 +522,30 @@ mod tests { .expect("the stale DSA root must be replaced by the configured anchor"); } + #[test] + fn dsa_certificate_rejects_mismatched_inner_signature_algorithm() { + // The signed TBSCertificate algorithm is a separate RFC 5280 invariant; + // a valid signature over the original bytes must not bypass a mismatch + // in the parsed metadata through the legacy DSA fallback. + let (_, mut certificate) = X509Certificate::from_der(include_bytes!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/balor.der" + )) + .expect("the tracked Merlin certificate is valid DER"); + let (_, issuer) = X509Certificate::from_der(include_bytes!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/ca.der" + )) + .expect("the tracked Merlin issuer is a DER certificate"); + assert!(verify_certificate_signature(&certificate, &issuer)); + + certificate.tbs_certificate.signature = issuer.public_key().algorithm.clone(); + + assert_ne!( + certificate.tbs_certificate.signature, + certificate.signature_algorithm + ); + assert!(!verify_certificate_signature(&certificate, &issuer)); + } + #[test] fn dsa_sha1_crl_signature_uses_the_same_fallback_as_certificates() { let xml = include_str!( @@ -534,4 +569,32 @@ mod tests { assert!(verify_crl_signature(&crl, &issuer)); } + + #[test] + fn dsa_crl_rejects_mismatched_inner_signature_algorithm() { + let xml = include_str!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt-crl.xml" + ); + let document = Document::parse(xml).expect("the tracked Merlin document is valid XML"); + let key_info_node = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) + .expect("the Merlin document contains KeyInfo"); + let key_info = parse_key_info(key_info_node).expect("the Merlin KeyInfo is valid"); + let KeyInfoSource::X509Data(info) = &key_info.sources[0] else { + panic!("expected X509Data") + }; + let (_, issuer) = X509Certificate::from_der(include_bytes!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/ca.der" + )) + .expect("the tracked Merlin issuer is a DER certificate"); + let (_, mut crl) = CertificateRevocationList::from_der(&info.crls[0]) + .expect("the tracked Merlin CRL is valid DER"); + assert!(verify_crl_signature(&crl, &issuer)); + + crl.tbs_cert_list.signature = issuer.public_key().algorithm.clone(); + + assert_ne!(crl.tbs_cert_list.signature, crl.signature_algorithm); + assert!(!verify_crl_signature(&crl, &issuer)); + } } From 9c0cd48e4cb2042ad140a3ca411c62c2d4325a4a Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 7 Aug 2026 10:35:26 +0300 Subject: [PATCH 21/26] fix(xmldsig): enforce signature-wide limits - Bound canonicalized SignedInfo independently of diagnostic retention - Share the Reference ceiling across SignedInfo and authenticated Manifests - Validate the promoted xmlsec1 program and exact version before marking it installed - Document direct same-document X509Data retrieval --- docs/xmldsig.md | 9 +-- scripts/install-xmlsec1.sh | 23 +++++-- src/hard_limits.rs | 4 +- src/xmldsig/verify.rs | 132 +++++++++++++++++++++++++++---------- tests/install_xmlsec1.rs | 54 +++++++++++++-- 5 files changed, 173 insertions(+), 49 deletions(-) diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 1bd78325..30dd56e5 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -57,10 +57,11 @@ never performs network or filesystem I/O. Individual resources are limited to 8 complete map to 32 MiB. External key retrieval has an independent policy boundary: callers must also opt in with `VerifyContext::allowed_retrieval_method_uri_types`. Allowing external signed payloads never implicitly allows external key material. `RetrievalMethod` currently accepts -untransformed external `rawX509Certificate` data and the Merlin same-document `X509Data` XPath -selection. Relative external `Reference` and `RetrievalMethod` URIs are resolved against the -owning element's effective `xml:base` using RFC 3986 before lookup, so resource-map keys must use -that resolved URI. Other retrieval transform chains fail closed instead of being ignored. +untransformed external `rawX509Certificate` data, untransformed direct same-document `X509Data`, +and the Merlin same-document `X509Data` XPath selection. Relative external `Reference` and +`RetrievalMethod` URIs are resolved against the owning element's effective `xml:base` using RFC +3986 before lookup, so resource-map keys must use that resolved URI. Other retrieval transform +chains fail closed instead of being ignored. Internal DTD declarations are disabled by default and require `VerifyContext::allow_internal_dtd(true)`. The policy applies consistently to the signed document diff --git a/scripts/install-xmlsec1.sh b/scripts/install-xmlsec1.sh index 4b02622a..6e02fa13 100755 --- a/scripts/install-xmlsec1.sh +++ b/scripts/install-xmlsec1.sh @@ -90,15 +90,28 @@ if [[ -e "$prefix" ]]; then fi mv "$staged_prefix" "$prefix" promoted_install=true -printf '%s\n' "$XMLSEC1_COMMIT" > "$marker" if [[ "$(uname -s)" == "Darwin" ]]; then - DYLD_LIBRARY_PATH="$prefix/lib${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" \ - "$prefix/bin/xmlsec1" --version + version_output="$( + DYLD_LIBRARY_PATH="$prefix/lib${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" \ + "$prefix/bin/xmlsec1" --version + )" else - LD_LIBRARY_PATH="$prefix/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ - "$prefix/bin/xmlsec1" --version + version_output="$( + LD_LIBRARY_PATH="$prefix/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ + "$prefix/bin/xmlsec1" --version + )" +fi +version_program="" +version_number="" +read -r version_program version_number _ <<< "$version_output" || true +if [[ "$version_program" != "xmlsec1" || "$version_number" != "$XMLSEC1_VERSION" ]]; then + printf 'xmlsec1 version mismatch: expected xmlsec1 %s, got %s\n' \ + "$XMLSEC1_VERSION" "${version_output:-}" >&2 + exit 1 fi +printf '%s\n' "$version_output" +printf '%s\n' "$XMLSEC1_COMMIT" > "$marker" rm -rf "$previous_install" previous_install_staged=false diff --git a/src/hard_limits.rs b/src/hard_limits.rs index b4043649..26bcb5ee 100644 --- a/src/hard_limits.rs +++ b/src/hard_limits.rs @@ -6,5 +6,5 @@ /// Maximum XML nodes allocated while parsing one verification or transform document. pub(crate) const XML_DOCUMENT_NODE_CEILING: u32 = 100_000; -/// Maximum bytes retained across one verification result's diagnostic buffers. -pub(crate) const STORED_PRE_DIGEST_BYTE_CEILING: usize = 32 * 1024 * 1024; +/// Maximum canonicalized SignedInfo plus retained diagnostics for one signature. +pub(crate) const CANONICALIZED_SIGNATURE_DATA_BYTE_CEILING: usize = 32 * 1024 * 1024; diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index ff205c19..06fdc7a8 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -16,7 +16,7 @@ use std::cell::Cell; use std::collections::{HashMap, HashSet}; use crate::c14n::canonicalize; -use crate::hard_limits::{STORED_PRE_DIGEST_BYTE_CEILING, XML_DOCUMENT_NODE_CEILING}; +use crate::hard_limits::{CANONICALIZED_SIGNATURE_DATA_BYTE_CEILING, XML_DOCUMENT_NODE_CEILING}; use super::digest::{DigestAlgorithm, compute_digest, constant_time_eq}; use super::parse::{ @@ -297,9 +297,10 @@ impl<'a> VerifyContext<'a> { /// Store pre-digest buffers for diagnostics. /// /// Retained reference buffers and canonicalized `` share a - /// non-configurable 32 MiB safety ceiling. Verification returns - /// [`ReferenceProcessingError::PreDigestDataTooLarge`] rather than retaining - /// more diagnostic data. + /// non-configurable 32 MiB safety ceiling. Canonicalized `` is + /// charged even when diagnostic retention is disabled because signature + /// verification always materializes it. Verification returns + /// [`ReferenceProcessingError::CanonicalizedDataTooLarge`] on overflow. pub fn store_pre_digest(mut self, enabled: bool) -> Self { self.store_pre_digest = enabled; self @@ -458,12 +459,12 @@ pub fn process_reference( store_pre_digest: bool, ) -> Result { let execution_budget = TransformExecutionBudget::default(); - let pre_digest_budget = PreDigestRetentionBudget::default(); + let canonicalized_data_budget = CanonicalizedDataBudget::default(); let execution = ReferenceExecutionContext { store_pre_digest, transform_options: TransformOptions::default(), transform_budget: &execution_budget, - pre_digest_budget: &pre_digest_budget, + canonicalized_data_budget: &canonicalized_data_budget, }; process_reference_with_options( reference, @@ -520,28 +521,28 @@ struct ReferenceExecutionContext<'a> { store_pre_digest: bool, transform_options: TransformOptions, transform_budget: &'a TransformExecutionBudget, - pre_digest_budget: &'a PreDigestRetentionBudget, + canonicalized_data_budget: &'a CanonicalizedDataBudget, } -struct PreDigestRetentionBudget { +struct CanonicalizedDataBudget { remaining: Cell, max_bytes: usize, } -impl Default for PreDigestRetentionBudget { +impl Default for CanonicalizedDataBudget { fn default() -> Self { Self { - remaining: Cell::new(STORED_PRE_DIGEST_BYTE_CEILING), - max_bytes: STORED_PRE_DIGEST_BYTE_CEILING, + remaining: Cell::new(CANONICALIZED_SIGNATURE_DATA_BYTE_CEILING), + max_bytes: CANONICALIZED_SIGNATURE_DATA_BYTE_CEILING, } } } -impl PreDigestRetentionBudget { +impl CanonicalizedDataBudget { fn charge(&self, bytes: usize) -> Result<(), ReferenceProcessingError> { let Some(remaining) = self.remaining.get().checked_sub(bytes) else { self.remaining.set(0); - return Err(ReferenceProcessingError::PreDigestDataTooLarge { + return Err(ReferenceProcessingError::CanonicalizedDataTooLarge { max_bytes: self.max_bytes, }); }; @@ -614,7 +615,9 @@ fn process_reference_with_options( }; let pre_digest_data = if execution.store_pre_digest { - execution.pre_digest_budget.charge(pre_digest_bytes.len())?; + execution + .canonicalized_data_budget + .charge(pre_digest_bytes.len())?; Some(pre_digest_bytes) } else { None @@ -648,12 +651,12 @@ pub fn process_all_references( store_pre_digest: bool, ) -> Result { let execution_budget = TransformExecutionBudget::default(); - let pre_digest_budget = PreDigestRetentionBudget::default(); + let canonicalized_data_budget = CanonicalizedDataBudget::default(); let execution = ReferenceExecutionContext { store_pre_digest, transform_options: TransformOptions::default(), transform_budget: &execution_budget, - pre_digest_budget: &pre_digest_budget, + canonicalized_data_budget: &canonicalized_data_budget, }; process_all_references_with_options(references, resolver, signature_node, &execution) } @@ -711,10 +714,10 @@ pub enum ReferenceProcessingError { #[error("transform failed: {0}")] Transform(#[source] super::types::TransformError), - /// Diagnostic pre-digest buffers would exceed their signature-wide cap. - #[error("stored pre-digest data exceeds signature-wide maximum of {max_bytes} bytes")] - PreDigestDataTooLarge { - /// Maximum bytes retained across all reference diagnostics. + /// Canonicalized signature data would exceed its signature-wide cap. + #[error("canonicalized signature data exceeds signature-wide maximum of {max_bytes} bytes")] + CanonicalizedDataTooLarge { + /// Maximum bytes consumed by canonicalized SignedInfo and retained diagnostics. max_bytes: usize, }, } @@ -965,12 +968,12 @@ fn verify_signature_with_context( RetrievalMaterialization::default() }; let execution_budget = TransformExecutionBudget::default(); - let pre_digest_budget = PreDigestRetentionBudget::default(); + let canonicalized_data_budget = CanonicalizedDataBudget::default(); let execution = ReferenceExecutionContext { store_pre_digest: ctx.store_pre_digest, transform_options: ctx.transform_options, transform_budget: &execution_budget, - pre_digest_budget: &pre_digest_budget, + canonicalized_data_budget: &canonicalized_data_budget, }; let references = process_all_references_with_options( &signed_info.references, @@ -1000,9 +1003,7 @@ fn verify_signature_with_context( &signed_info.c14n_method, &mut canonical_signed_info, )?; - if ctx.store_pre_digest { - pre_digest_budget.charge(canonical_signed_info.len())?; - } + canonicalized_data_budget.charge(canonical_signed_info.len())?; let signature_value = decode_signature_value(signature_children.signature_value_node)?; if signed_info.signature_method == SignatureAlgorithm::HmacSha1 { @@ -1053,11 +1054,17 @@ fn verify_signature_with_context( let manifest_references = if ctx.process_manifests { let signed_info_reference_nodes = collect_authenticated_signed_info_reference_nodes(&signed_info.references, &resolver); + let remaining_reference_capacity = MAX_REFERENCES_PER_SIGNATURE + .checked_sub(signed_info.references.len()) + .ok_or(SignatureVerificationPipelineError::InvalidStructure { + reason: "SignedInfo exceeds the per-signature Reference limit", + })?; process_manifest_references( signature_node, &resolver, ctx, &signed_info_reference_nodes, + remaining_reference_capacity, &execution, &mut xpath_parse_budget, )? @@ -1281,12 +1288,14 @@ fn process_manifest_references( resolver: &UriReferenceResolver<'_>, ctx: &VerifyContext<'_>, signed_info_reference_nodes: &HashSet, + remaining_reference_capacity: usize, execution: &ReferenceExecutionContext<'_>, xpath_parse_budget: &mut XPathSignatureParseBudget, ) -> Result, SignatureVerificationPipelineError> { let parsed = parse_manifest_references( signature_node, signed_info_reference_nodes, + remaining_reference_capacity, xpath_parse_budget, )?; let manifest_references = parsed.references; @@ -1377,6 +1386,7 @@ fn manifest_reference_invalid_result( fn parse_manifest_references( signature_node: Node<'_, '_>, signed_info_reference_nodes: &HashSet, + remaining_reference_capacity: usize, xpath_parse_budget: &mut XPathSignatureParseBudget, ) -> Result { let mut references = Vec::new(); @@ -1425,7 +1435,7 @@ fn parse_manifest_references( reason: "Manifest must contain only ds:Reference element children", }); } - if references.len() + invalid.len() == MAX_REFERENCES_PER_SIGNATURE { + if references.len() + invalid.len() >= remaining_reference_capacity { return Err(SignatureVerificationPipelineError::InvalidStructure { reason: "signed Manifests exceed the per-signature Reference limit", }); @@ -2956,6 +2966,7 @@ mod tests { let error = match parse_manifest_references( signature, &authenticated, + MAX_REFERENCES_PER_SIGNATURE, &mut XPathSignatureParseBudget::default(), ) { Ok(_) => panic!("unsupported references must consume the same aggregate limit"), @@ -2969,6 +2980,39 @@ mod tests { )); } + #[test] + fn manifest_reference_limit_includes_signed_info_references() { + // The per-signature ceiling is shared by core and authenticated + // Manifest references; enabling Manifest processing must not reset it. + let xml = signature_with_manifest_xml(true); + let reference_start = xml + .find(r##""##) + .expect("fixture SignedInfo must reference the Manifest"); + let reference_end = xml[reference_start..] + .find("") + .map(|offset| reference_start + offset + "".len()) + .expect("fixture SignedInfo Reference must be closed"); + let repeated = xml[reference_start..reference_end].repeat(MAX_REFERENCES_PER_SIGNATURE); + let xml = format!( + "{}{repeated}{}", + &xml[..reference_start], + &xml[reference_end..] + ); + + let error = VerifyContext::new() + .key(&AcceptingKey) + .process_manifests(true) + .verify(&xml) + .expect_err("one Manifest Reference must exceed the exhausted signature-wide limit"); + + assert!(matches!( + error, + SignatureVerificationPipelineError::InvalidStructure { + reason: "signed Manifests exceed the per-signature Reference limit" + } + )); + } + #[test] fn retrieval_method_materializes_single_x509_data_subtree() { for uri in [ @@ -3739,12 +3783,12 @@ mod tests { let resources = HashMap::from([("urn:repeated".to_owned(), payload)]); let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources); let transform_budget = TransformExecutionBudget::default(); - let pre_digest_budget = PreDigestRetentionBudget::with_limit(32); + let canonicalized_data_budget = CanonicalizedDataBudget::with_limit(32); let execution = ReferenceExecutionContext { store_pre_digest: true, transform_options: TransformOptions::default(), transform_budget: &transform_budget, - pre_digest_budget: &pre_digest_budget, + canonicalized_data_budget: &canonicalized_data_budget, }; let error = process_all_references_with_options( @@ -3758,7 +3802,29 @@ mod tests { ); assert!(matches!( error, - ReferenceProcessingError::PreDigestDataTooLarge { max_bytes: 32 } + ReferenceProcessingError::CanonicalizedDataTooLarge { max_bytes: 32 } + )); + } + + #[test] + fn canonical_signed_info_is_bounded_without_diagnostic_retention() { + // SignedInfo is always materialized for crypto verification, so its + // canonical bytes must consume the ceiling even under default options. + let xml = signature_with_target_reference("AQ=="); + let marker = " \"$dest$XMLSEC1_PREFIX/bin/xmlsec1\"\n chmod +x \"$dest$XMLSEC1_PREFIX/bin/xmlsec1\"\nfi\n", + "make", + "#!/bin/sh\nfor arg in \"$@\"; do\n case \"$arg\" in DESTDIR=*) dest=${arg#DESTDIR=} ;; esac\ndone\nif [ -n \"${dest:-}\" ]; then\n mkdir -p \"$dest$XMLSEC1_PREFIX/bin\"\n printf '#!/bin/sh\\nprintf \"%%s\\\\n\" \"${XMLSEC1_SMOKE_OUTPUT-xmlsec1 1.3.13 (openssl)}\"\\nexit \"${XMLSEC1_SMOKE_EXIT:-0}\"\\n' > \"$dest$XMLSEC1_PREFIX/bin/xmlsec1\"\n chmod +x \"$dest$XMLSEC1_PREFIX/bin/xmlsec1\"\nfi\n", ); root.tool( "mv", @@ -97,6 +97,7 @@ impl InstallHarness { mv_fail_on: Option, reported_commit: Option<&str>, smoke_exit: Option, + smoke_output: Option<&str>, ) -> std::process::ExitStatus { let inherited_path = std::env::var_os("PATH").expect("test process must have PATH"); let path = std::env::join_paths( @@ -122,6 +123,9 @@ impl InstallHarness { if let Some(smoke_exit) = smoke_exit { command.env("XMLSEC1_SMOKE_EXIT", smoke_exit.to_string()); } + if let Some(smoke_output) = smoke_output { + command.env("XMLSEC1_SMOKE_OUTPUT", smoke_output); + } command.status().expect("installation script must run") } } @@ -132,7 +136,7 @@ fn failed_install_replacement_restores_previous_xmlsec() { // leave the previously working installation intact rather than letting // EXIT cleanup delete its backup. let harness = InstallHarness::new(); - let status = harness.run(Some(2), None, None); + let status = harness.run(Some(2), None, None, None); assert!( !status.success(), @@ -150,7 +154,12 @@ fn installer_rejects_source_revision_mismatch() { // Artifact compression is not source identity. The installer must reject // a fetch whose resolved Git object differs from the pinned commit. let harness = InstallHarness::new(); - let status = harness.run(None, Some("0000000000000000000000000000000000000000"), None); + let status = harness.run( + None, + Some("0000000000000000000000000000000000000000"), + None, + None, + ); assert!( !status.success(), @@ -168,7 +177,7 @@ fn failed_first_install_removes_promoted_prefix() { // A failed smoke test must not leave an executable plus source marker that // a later invocation could mistake for a validated installation. let harness = InstallHarness::without_previous_install(); - let status = harness.run(None, None, Some(17)); + let status = harness.run(None, None, Some(17), None); assert!(!status.success(), "injected smoke failure must propagate"); assert!( @@ -176,3 +185,38 @@ fn failed_first_install_removes_promoted_prefix() { "failed first installation must remove its promoted prefix" ); } + +#[test] +fn malformed_version_output_restores_previous_installation() { + // Exit status alone is not source identity: a successful binary with an + // unexpected version must not replace the previously validated install. + let harness = InstallHarness::new(); + for output in ["", "xmlsec1", "xmlsec1 1.3.12", "other 1.3.13"] { + let status = harness.run(None, None, None, Some(output)); + + assert!( + !status.success(), + "unexpected version output {output:?} must fail closed" + ); + assert_eq!( + std::fs::read_to_string(harness.prefix.join("sentinel")) + .expect("version mismatch must restore the previous installation"), + "previous installation" + ); + assert!(!harness.prefix.join(".xmlsec-source-commit").exists()); + } +} + +#[test] +fn exact_version_output_commits_the_new_installation() { + let harness = InstallHarness::new(); + let status = harness.run(None, None, None, Some("xmlsec1 1.3.13 (openssl)")); + + assert!(status.success(), "the pinned version must pass validation"); + assert!(!harness.prefix.join("sentinel").exists()); + assert_eq!( + std::fs::read_to_string(harness.prefix.join(".xmlsec-source-commit")) + .expect("successful validation must write the source marker"), + "5fdd47dc35753438bdc38b6e96c1a3805c67a483\n" + ); +} From ddb8153c1a359bd3df71085c1a1b1b16c0e8d6e7 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 7 Aug 2026 11:22:06 +0300 Subject: [PATCH 22/26] fix(xmldsig): harden bounded verification - Normalize RFC 3986 interior empty path segments correctly - Apply X.509 distinguished-name equivalence throughout path topology - Stop SignedInfo canonicalization at the remaining signature budget - Add regressions for URI and certificate-chain edge cases --- src/c14n/mod.rs | 24 ++++++++++++++++++++++ src/c14n/xml_base.rs | 23 ++++++++++++++++------ src/xmldsig/keys.rs | 10 +++++----- src/xmldsig/parse.rs | 46 +++++++++++++++++++++++++++++++++++++++---- src/xmldsig/verify.rs | 22 ++++++++++++++++++--- 5 files changed, 107 insertions(+), 18 deletions(-) diff --git a/src/c14n/mod.rs b/src/c14n/mod.rs index 75fc60d2..871e014b 100644 --- a/src/c14n/mod.rs +++ b/src/c14n/mod.rs @@ -240,6 +240,30 @@ pub fn canonicalize( ) } +#[cfg(any(feature = "xmldsig", test))] +/// Canonicalize through the closure visibility API while refusing to append +/// beyond `max_output_bytes`; the serializer stops before the excess write. +pub(crate) fn canonicalize_bounded( + doc: &Document, + node_set: Option<&dyn Fn(Node) -> bool>, + algo: &C14nAlgorithm, + max_output_bytes: usize, + output: &mut Vec, +) -> Result<(), C14nError> { + let visibility = node_set.map(|predicate| ClosureVisibility { predicate }); + canonicalize_with_visibility_and_position_bounded( + doc, + visibility + .as_ref() + .map(|visibility| visibility as &dyn NodeVisibility), + algo, + None, + max_output_bytes, + output, + )?; + Ok(()) +} + pub(crate) fn canonicalize_with_visibility( doc: &Document, visibility: Option<&dyn NodeVisibility>, diff --git a/src/c14n/xml_base.rs b/src/c14n/xml_base.rs index d1a96c23..c0c2206f 100644 --- a/src/c14n/xml_base.rs +++ b/src/c14n/xml_base.rs @@ -387,12 +387,9 @@ fn remove_dot_segments_with_unmatched_parents( // - For absolute paths, do not traverse above root (the // leading "" segment from the initial '/' is preserved). // - For relative paths, preserve unmatched ".." segments. - let can_pop = match segments.last() { - Some(&"") => false, // root segment of absolute path - Some(&"..") => false, // already an unmatched ".." - Some(_) => true, - None => false, - }; + let root_segments = usize::from(is_absolute); + let can_pop = + segments.len() > root_segments && !matches!(segments.last(), Some(&"..")); if can_pop { segments.pop(); } else if !is_absolute && preserve_unmatched_parents { @@ -442,6 +439,20 @@ mod tests { ); } + #[test] + fn resolve_absolute_reference_consumes_interior_empty_segment() { + // RFC 3986 treats the empty segment introduced by the second slash as + // an ordinary path segment. The following parent segment removes it; + // only the leading empty segment represents the absolute-path root. + assert_eq!( + resolve_uri( + "https://base.example/ignored/", + "https://example.test/a//../b" + ), + "https://example.test/a/b" + ); + } + #[test] fn resolve_empty_reference() { assert_eq!(resolve_uri("http://a.com/b/c", ""), "http://a.com/b/c"); diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 24ee0d2a..7c031a2a 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -17,7 +17,7 @@ use super::{ X509ChainOptions, X509DataInfo, parse::{ EC_P256_OID, EC_P384_OID, ParseError, X509ChainBuildError, - build_x509_certificate_chain_from, parse_x509_certificate, + build_x509_certificate_chain_from, distinguished_names_equal, parse_x509_certificate, x509_certificate_matches_any_selector, x509_data_has_lookup_identifiers, x509_selector_categories_match_chain, }, @@ -388,10 +388,10 @@ impl DefaultKeyResolver { let leaves = matches .iter() .filter(|(_, candidate)| { - candidate.subject_dn != candidate.issuer_dn - && !matches - .iter() - .any(|(_, other)| other.issuer_dn == candidate.subject_dn) + !distinguished_names_equal(&candidate.subject_dn, &candidate.issuer_dn) + && !matches.iter().any(|(_, other)| { + distinguished_names_equal(&other.issuer_dn, &candidate.subject_dn) + }) }) .collect::>(); match leaves.as_slice() { diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index 88af5af1..0f89a9a2 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -1207,7 +1207,7 @@ pub(crate) fn build_x509_certificate_chain_from( .last() .expect("chain starts with signing certificate index"); let current = &info.parsed_certificates[current_idx]; - if current.subject_dn == current.issuer_dn { + if distinguished_names_equal(¤t.subject_dn, ¤t.issuer_dn) { break; } @@ -1215,7 +1215,10 @@ pub(crate) fn build_x509_certificate_chain_from( .parsed_certificates .iter() .enumerate() - .filter(|(idx, cert)| *idx != current_idx && cert.subject_dn == current.issuer_dn) + .filter(|(idx, cert)| { + *idx != current_idx + && distinguished_names_equal(&cert.subject_dn, ¤t.issuer_dn) + }) .map(|(idx, _)| idx) .collect::>(); @@ -1288,11 +1291,11 @@ fn select_x509_signing_certificate(info: &X509DataInfo) -> Result>(); @@ -2706,6 +2709,41 @@ BA== assert_eq!(x509_info.certificate_chain, vec![2, 1, 0]); } + #[test] + fn chain_builder_matches_x509_equivalent_distinguished_names() { + // RFC 5280 name chaining uses X.501 matching rather than the lexical + // RFC 4514 rendering. Case differences in DirectoryString values must + // not disconnect an otherwise valid configured path. + let certificates = [ + fixture_cert_base64("../../tests/fixtures/keys/rsa/rsa-2048-cert.pem"), + fixture_cert_base64("../../tests/fixtures/keys/ca2cert.pem"), + fixture_cert_base64("../../tests/fixtures/keys/cacert.pem"), + ] + .map(|encoded| { + base64::engine::general_purpose::STANDARD + .decode(encoded) + .unwrap() + }) + .to_vec(); + let mut parsed_certificates = certificates + .iter() + .map(|certificate| parse_x509_certificate(certificate).unwrap()) + .collect::>(); + parsed_certificates[0].issuer_dn = parsed_certificates[1].subject_dn.to_ascii_lowercase(); + parsed_certificates[1].issuer_dn = parsed_certificates[2].subject_dn.to_ascii_lowercase(); + let info = X509DataInfo { + certificates, + parsed_certificates, + ..X509DataInfo::default() + }; + + assert_eq!(select_x509_signing_certificate(&info).unwrap(), 0); + assert_eq!( + build_x509_certificate_chain_from(&info, 0).unwrap(), + vec![0, 1, 2] + ); + } + #[test] fn parse_key_info_uses_issuer_serial_to_select_x509_signing_certificate() { let root = fixture_cert_base64("../../tests/fixtures/keys/cacert.pem"); diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index 06fdc7a8..d3af00b9 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -15,7 +15,7 @@ use roxmltree::{Document, Node, NodeId}; use std::cell::Cell; use std::collections::{HashMap, HashSet}; -use crate::c14n::canonicalize; +use crate::c14n::{canonicalize_bounded, is_output_limit_error}; use crate::hard_limits::{CANONICALIZED_SIGNATURE_DATA_BYTE_CEILING, XML_DOCUMENT_NODE_CEILING}; use super::digest::{DigestAlgorithm, compute_digest, constant_time_eq}; @@ -539,6 +539,10 @@ impl Default for CanonicalizedDataBudget { } impl CanonicalizedDataBudget { + fn remaining(&self) -> usize { + self.remaining.get() + } + fn charge(&self, bytes: usize) -> Result<(), ReferenceProcessingError> { let Some(remaining) = self.remaining.get().checked_sub(bytes) else { self.remaining.set(0); @@ -997,12 +1001,24 @@ fn verify_signature_with_context( .map(|node: Node<'_, '_>| node.id()) .collect(); let mut canonical_signed_info = Vec::new(); - canonicalize( + canonicalize_bounded( &doc, Some(&|node| signed_info_subtree.contains(&node.id())), &signed_info.c14n_method, + canonicalized_data_budget.remaining(), &mut canonical_signed_info, - )?; + ) + .map_err(|error| { + if is_output_limit_error(&error) { + SignatureVerificationPipelineError::Reference( + ReferenceProcessingError::CanonicalizedDataTooLarge { + max_bytes: canonicalized_data_budget.max_bytes, + }, + ) + } else { + SignatureVerificationPipelineError::Canonicalization(error) + } + })?; canonicalized_data_budget.charge(canonical_signed_info.len())?; let signature_value = decode_signature_value(signature_children.signature_value_node)?; From 83f35c66c3cdebfc34daad02bcab414ffcc07adf Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 7 Aug 2026 13:03:26 +0300 Subject: [PATCH 23/26] fix(xmldsig): defer malformed retrievals Preserve ordered KeyInfo fallback semantics when mapped raw-X509 bytes are malformed, while retaining the parse error if no key source resolves. --- src/xmldsig/verify.rs | 68 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index d3af00b9..b8fd8a35 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -1176,8 +1176,20 @@ fn materialize_retrieval_methods( }); } add_retrieval_binary_usage(&mut total_binary_len, certificate.len())?; - let parsed = parse_x509_certificate(certificate) - .map_err(SignatureVerificationPipelineError::ParseKeyInfo)?; + let parsed = match parse_x509_certificate(certificate) { + Ok(parsed) => parsed, + Err(error) => { + outcome + .deferred_error + .get_or_insert(SignatureVerificationPipelineError::ParseKeyInfo(error)); + materialized.push(super::parse::KeyInfoSource::RetrievalMethod { + uri, + resource_type, + transforms, + }); + continue; + } + }; materialized.push(super::parse::KeyInfoSource::X509Data( super::parse::X509DataInfo { certificates: vec![certificate.clone()], @@ -3622,6 +3634,31 @@ mod tests { assert_eq!(result.status, DsigStatus::Valid); } + #[test] + fn verify_context_does_not_eagerly_parse_unused_retrieval_fallback() { + // Materialization must preserve ordered fallback semantics even when + // caller-supplied bytes exist but are not a certificate. + let xml = signature_with_target_reference("AQ==").replace( + "\n ", + r#" + + primary + + + "#, + ); + let resources = HashMap::from([("malformed.der".to_string(), b"not DER".to_vec())]); + + let result = VerifyContext::new() + .key_resolver(&EarlyKeyInfoResolver) + .allowed_retrieval_method_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&xml) + .expect("an unused malformed retrieval fallback must not abort verification"); + + assert_eq!(result.status, DsigStatus::Valid); + } + #[test] fn verify_context_reports_missing_retrieval_when_no_key_source_resolves() { // Deferral changes ordering, not diagnostics: if no alternative source @@ -3649,6 +3686,33 @@ mod tests { )); } + #[test] + fn verify_context_reports_malformed_retrieval_when_no_key_source_resolves() { + // Deferral must retain the parse error when the malformed certificate + // is the only candidate rather than degrading it to KeyNotFound. + let xml = signature_with_target_reference("AQ==").replace( + "\n ", + r#" + + + + "#, + ); + let resources = HashMap::from([("malformed.der".to_string(), b"not DER".to_vec())]); + + let error = VerifyContext::new() + .key_resolver(&ConsumingKeyInfoResolver) + .allowed_retrieval_method_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&xml) + .expect_err("a malformed sole RetrievalMethod must remain a parse error"); + + assert!(matches!( + error, + SignatureVerificationPipelineError::ParseKeyInfo(_) + )); + } + #[test] fn verify_context_preserves_signaturevalue_decode_errors_when_resolver_misses() { let xml = signature_with_target_reference("@@@"); From 7b56c3851091fd1fff449177441d64ef1a454cc0 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 7 Aug 2026 14:08:42 +0300 Subject: [PATCH 24/26] fix(security): unify policy and provider - validate legacy algorithms independently of key source - route cryptographic operations through explicit providers - enumerate X.509 paths through configured trust anchors - centralize operation policy and resource enforcement --- src/hard_limits.rs | 10 + src/lib.rs | 6 +- src/policy.rs | 324 ++++++++++ src/provider.rs | 806 +++++++++++++++++++++++++ src/xmldsig/digest.rs | 20 +- src/xmldsig/keys.rs | 515 ++++++++++++---- src/xmldsig/mod.rs | 2 +- src/xmldsig/parse.rs | 70 +++ src/xmldsig/sign.rs | 132 +++- src/xmldsig/verify.rs | 168 ++++-- src/xmlenc/decrypt.rs | 555 +++++++++-------- src/xmlenc/encrypt.rs | 420 ++++++------- src/xmlenc/mod.rs | 5 +- src/xmlenc/types.rs | 34 +- tests/donor_full_verification_suite.rs | 40 +- tests/donor_negative_vectors.rs | 22 +- tests/merlin_interop.rs | 66 +- 17 files changed, 2453 insertions(+), 742 deletions(-) create mode 100644 src/policy.rs create mode 100644 src/provider.rs diff --git a/src/hard_limits.rs b/src/hard_limits.rs index 26bcb5ee..2d1879ce 100644 --- a/src/hard_limits.rs +++ b/src/hard_limits.rs @@ -8,3 +8,13 @@ pub(crate) const XML_DOCUMENT_NODE_CEILING: u32 = 100_000; /// Maximum canonicalized SignedInfo plus retained diagnostics for one signature. pub(crate) const CANONICALIZED_SIGNATURE_DATA_BYTE_CEILING: usize = 32 * 1024 * 1024; + +pub(crate) const EXTERNAL_RESOURCE_BYTE_CEILING: usize = 8 * 1024 * 1024; +pub(crate) const EXTERNAL_RESOURCE_TOTAL_BYTE_CEILING: usize = 32 * 1024 * 1024; +pub(crate) const ENCRYPTION_CIPHER_VALUE_BASE64_BYTE_CEILING: usize = 16 * 1024 * 1024; +pub(crate) const ENCRYPTION_PLAINTEXT_BYTE_CEILING: usize = + (ENCRYPTION_CIPHER_VALUE_BASE64_BYTE_CEILING / 4 * 3) - 32; +pub(crate) const ENCRYPTION_DOCUMENT_BYTE_CEILING: usize = + ENCRYPTION_CIPHER_VALUE_BASE64_BYTE_CEILING; +pub(crate) const ENCRYPTION_RECIPIENT_CEILING: usize = 64; +pub(crate) const ENCRYPTION_METADATA_BYTE_CEILING: usize = 4 * 1024; diff --git a/src/lib.rs b/src/lib.rs index 0c44774a..324c35b6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,9 +32,13 @@ pub mod c14n; pub mod error; -#[cfg(feature = "xmldsig")] +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] mod hard_limits; #[cfg(any(feature = "xmldsig", feature = "xmlenc"))] +pub mod policy; +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] +pub mod provider; +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] mod xml; #[cfg(feature = "xmldsig")] diff --git a/src/policy.rs b/src/policy.rs new file mode 100644 index 00000000..b1d5b816 --- /dev/null +++ b/src/policy.rs @@ -0,0 +1,324 @@ +//! Immutable security policy snapshots shared by XML Security operations. +//! +//! Policy contains trusted, reusable decisions. Caller-owned keys, selected +//! document targets, tenant identity, and external resource bytes remain in +//! operation request contexts and are deliberately not stored here. + +use std::{collections::HashSet, time::SystemTime}; + +#[cfg(feature = "xmldsig")] +use crate::xmldsig::{DigestAlgorithm, SignatureAlgorithm, UriTypeSet, XPathHereSemantics}; +#[cfg(feature = "xmlenc")] +use crate::xmlenc::{ + DataEncryptionAlgorithm, KeyTransportAlgorithm, KeyWrapAlgorithm, OaepDigestAlgorithm, +}; + +/// A typed rejection produced by an operation policy. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum PolicyViolation { + /// An algorithm is outside the operation allowlist. + #[error("{operation} policy rejects algorithm {algorithm}")] + Algorithm { + /// Operation evaluating the algorithm. + operation: &'static str, + /// Stable algorithm URI or diagnostic name. + algorithm: String, + }, + /// An input exceeds a configured resource ceiling. + #[error("{resource} exceeds policy maximum {maximum}: got {actual}")] + ResourceLimit { + /// Resource whose consumption was rejected. + resource: &'static str, + /// Effective policy ceiling. + maximum: usize, + /// Observed consumption. + actual: usize, + }, + /// The selected key source or trust mode is disallowed. + #[error("key/trust policy rejected the operation: {reason}")] + KeyTrust { + /// Non-secret reason suitable for diagnostics. + reason: &'static str, + }, + /// XML parser behavior is disallowed. + #[error("XML input policy rejected the operation: {reason}")] + XmlInput { + /// Non-secret reason suitable for diagnostics. + reason: &'static str, + }, +} + +/// Resource ceilings shared by parsing, transforms, and cryptographic output. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResourcePolicy { + /// Maximum XML nodes in one parsed document. + pub max_xml_nodes: usize, + /// Maximum references in one signature or manifest. + pub max_references: usize, + /// Maximum transforms in one reference. + pub max_transforms_per_reference: usize, + /// Maximum canonical bytes retained across one signature operation. + pub max_canonicalized_bytes: usize, + /// Maximum decoded external resource bytes. + pub max_external_resource_bytes: usize, + /// Maximum aggregate external resource bytes. + pub max_external_resource_total_bytes: usize, + /// Maximum XMLEnc plaintext bytes. + pub max_encryption_plaintext_bytes: usize, + /// Maximum caller-owned XML bytes accepted by XMLEnc document operations. + pub max_encryption_document_bytes: usize, + /// Maximum independently wrapped recipients. + pub max_encryption_recipients: usize, + /// Maximum caller-controlled XMLEnc metadata bytes per field. + pub max_encryption_metadata_bytes: usize, +} + +impl Default for ResourcePolicy { + fn default() -> Self { + Self { + max_xml_nodes: crate::hard_limits::XML_DOCUMENT_NODE_CEILING as usize, + max_references: 64, + max_transforms_per_reference: 64, + max_canonicalized_bytes: crate::hard_limits::CANONICALIZED_SIGNATURE_DATA_BYTE_CEILING, + max_external_resource_bytes: crate::hard_limits::EXTERNAL_RESOURCE_BYTE_CEILING, + max_external_resource_total_bytes: + crate::hard_limits::EXTERNAL_RESOURCE_TOTAL_BYTE_CEILING, + max_encryption_plaintext_bytes: crate::hard_limits::ENCRYPTION_PLAINTEXT_BYTE_CEILING, + max_encryption_document_bytes: crate::hard_limits::ENCRYPTION_DOCUMENT_BYTE_CEILING, + max_encryption_recipients: crate::hard_limits::ENCRYPTION_RECIPIENT_CEILING, + max_encryption_metadata_bytes: crate::hard_limits::ENCRYPTION_METADATA_BYTE_CEILING, + } + } +} + +impl ResourcePolicy { + /// Validate policy values against non-configurable implementation ceilings. + pub fn validate(&self) -> Result<(), PolicyViolation> { + self.within( + "XML nodes", + self.max_xml_nodes, + crate::hard_limits::XML_DOCUMENT_NODE_CEILING as usize, + )?; + self.within( + "canonicalized bytes", + self.max_canonicalized_bytes, + crate::hard_limits::CANONICALIZED_SIGNATURE_DATA_BYTE_CEILING, + )?; + self.within("signature references", self.max_references, 64)?; + self.within( + "reference transforms", + self.max_transforms_per_reference, + 64, + )?; + self.within( + "encryption document", + self.max_encryption_document_bytes, + crate::hard_limits::ENCRYPTION_DOCUMENT_BYTE_CEILING, + )?; + Ok(()) + } + + fn within( + &self, + resource: &'static str, + selected: usize, + ceiling: usize, + ) -> Result<(), PolicyViolation> { + if selected == 0 || selected > ceiling { + return Err(PolicyViolation::ResourceLimit { + resource, + maximum: ceiling, + actual: selected, + }); + } + Ok(()) + } +} + +/// XML parsing decisions shared by all operation policies. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct XmlInputPolicy { + /// Permit bounded internal DTD declarations. External resolution stays off. + pub allow_internal_dtd: bool, +} + +/// X.509 and key-resolution decisions for verification. +#[cfg(feature = "xmldsig")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeyTrustPolicy { + /// Require embedded or selected certificates to chain to a configured anchor. + pub verify_x509_chains: bool, + /// Maximum validated path depth. + pub max_x509_chain_depth: usize, + /// Maximum signature-valid candidate paths considered before validation. + pub max_x509_candidate_paths: usize, + /// Permit legacy RSA-SHA1 verification after key resolution. + pub allow_legacy_rsa_sha1: bool, + /// Authenticate and enforce embedded CRLs during path validation. + pub check_crls: bool, + /// Verification time override; `None` selects the system clock. + pub verification_time: Option, +} + +#[cfg(feature = "xmldsig")] +impl Default for KeyTrustPolicy { + fn default() -> Self { + Self { + verify_x509_chains: false, + max_x509_chain_depth: 9, + max_x509_candidate_paths: 64, + allow_legacy_rsa_sha1: false, + check_crls: false, + verification_time: None, + } + } +} + +#[cfg(feature = "xmldsig")] +impl KeyTrustPolicy { + fn validate(&self) -> Result<(), PolicyViolation> { + ResourcePolicy::default().within("X.509 chain depth", self.max_x509_chain_depth, 9)?; + ResourcePolicy::default().within("X.509 candidate paths", self.max_x509_candidate_paths, 64) + } +} + +/// Immutable policy snapshot for XMLDSig verification. +#[cfg(feature = "xmldsig")] +#[derive(Debug, Clone, Default)] +pub struct VerificationPolicy { + /// Allowed signature methods; `None` accepts every implemented method. + pub signature_algorithms: Option>, + /// Allowed reference digest methods; `None` accepts every implemented method. + pub digest_algorithms: Option>, + /// Key and certificate trust rules. + pub key_trust: KeyTrustPolicy, + /// Allowed Reference URI classes. + pub reference_uri_types: UriTypeSet, + /// Allowed RetrievalMethod URI classes. + pub retrieval_uri_types: UriTypeSet, + /// Allowed transform URIs; `None` accepts every implemented transform. + pub transforms: Option>, + /// Whether authenticated Manifest references are processed. + pub process_manifests: bool, + /// XML parser rules. + pub xml: XmlInputPolicy, + /// Node selected for the XPath `here()` extension function. + pub xpath_here_semantics: XPathHereSemantics, + /// Resource ceilings. + pub resources: ResourcePolicy, +} + +#[cfg(feature = "xmldsig")] +#[cfg(feature = "xmldsig")] +impl VerificationPolicy { + /// Validate the complete snapshot against implementation hard ceilings. + pub fn validate(&self) -> Result<(), PolicyViolation> { + self.resources.validate()?; + self.key_trust.validate() + } + + /// Enforce the signature algorithm after key resolution. + pub fn check_signature_algorithm( + &self, + algorithm: SignatureAlgorithm, + ) -> Result<(), PolicyViolation> { + if algorithm == SignatureAlgorithm::RsaSha1 && !self.key_trust.allow_legacy_rsa_sha1 { + return Err(PolicyViolation::Algorithm { + operation: "verification", + algorithm: algorithm.uri().to_string(), + }); + } + if self + .signature_algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(&algorithm)) + { + return Err(PolicyViolation::Algorithm { + operation: "verification", + algorithm: algorithm.uri().to_string(), + }); + } + Ok(()) + } +} + +/// Immutable policy snapshot for XMLDSig signing. +#[cfg(feature = "xmldsig")] +#[derive(Debug, Clone, Default)] +pub struct SigningPolicy { + /// Allowed signing methods; `None` uses the implemented secure defaults. + pub signature_algorithms: Option>, + /// Allowed reference digest methods; `None` uses the implemented secure defaults. + pub digest_algorithms: Option>, + /// Allowed transform URIs; `None` accepts every implemented transform. + pub transforms: Option>, + /// XML parser rules. + pub xml: XmlInputPolicy, + /// Node selected for the XPath `here()` extension function. + pub xpath_here_semantics: XPathHereSemantics, + /// Resource ceilings. + pub resources: ResourcePolicy, +} + +#[cfg(feature = "xmldsig")] +/// Immutable policy snapshot for XMLEnc encryption. +#[cfg(feature = "xmlenc")] +#[derive(Debug, Clone, Default)] +pub struct EncryptionPolicy { + /// Allowed content-encryption algorithms. + pub data_algorithms: Option>, + /// Allowed RSA key-transport algorithms. + pub key_transport_algorithms: Option>, + /// Allowed symmetric key-wrap algorithms. + pub key_wrap_algorithms: Option>, + /// Allowed OAEP digest algorithms. + pub oaep_digests: Option>, + /// XML parser rules. + pub xml: XmlInputPolicy, + /// Resource ceilings. + pub resources: ResourcePolicy, +} + +#[cfg(feature = "xmlenc")] +/// Immutable policy snapshot for XMLEnc decryption. +#[cfg(feature = "xmlenc")] +pub type DecryptionPolicy = EncryptionPolicy; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resource_policy_cannot_exceed_implementation_ceiling() { + let policy = ResourcePolicy { + max_xml_nodes: 100_001, + ..ResourcePolicy::default() + }; + assert!(matches!( + policy.validate(), + Err(PolicyViolation::ResourceLimit { + resource: "XML nodes", + maximum: 100_000, + actual: 100_001, + }) + )); + } + + #[cfg(feature = "xmldsig")] + #[test] + fn rsa_sha1_requires_legacy_verification_policy() { + let mut policy = VerificationPolicy::default(); + assert!( + policy + .check_signature_algorithm(SignatureAlgorithm::RsaSha1) + .is_err() + ); + policy.key_trust.allow_legacy_rsa_sha1 = true; + assert!( + policy + .check_signature_algorithm(SignatureAlgorithm::RsaSha1) + .is_ok() + ); + } +} diff --git a/src/provider.rs b/src/provider.rs new file mode 100644 index 00000000..d3c2c500 --- /dev/null +++ b/src/provider.rs @@ -0,0 +1,806 @@ +//! Provider-neutral cryptographic operations. +//! +//! XML parsing and protocol orchestration depend on this contract rather than +//! concrete cryptographic crates. Secret-bearing signing/decryption keys remain +//! opaque behind the operation-specific key traits exposed by `xmldsig` and +//! `xmlenc`; this provider owns stateless primitives and randomness. + +#[cfg(feature = "xmlenc")] +use getrandom::rand_core::TryCryptoRng; +use getrandom::{SysRng, rand_core::TryRng}; + +#[cfg(feature = "xmldsig")] +use crate::xmldsig::DigestAlgorithm; +#[cfg(feature = "xmlenc")] +use crate::xmlenc::RsaOaepParameters; +#[cfg(feature = "xmlenc")] +use crate::xmlenc::{DataEncryptionAlgorithm, KeyWrapAlgorithm}; + +/// A cryptographic operation advertised by a provider. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum ProviderOperation { + /// Message digest computation. + Digest, + /// Public-key signature generation. + Sign, + /// Public-key signature verification. + Verify, + /// Authenticated or padded symmetric encryption. + Encrypt, + /// Authenticated or padded symmetric decryption. + Decrypt, + /// Symmetric key wrapping. + KeyWrap, + /// Symmetric key unwrapping. + KeyUnwrap, + /// Public-key key transport. + KeyTransport, + /// Key agreement. + KeyAgreement, + /// Key derivation. + Kdf, + /// Cryptographically secure random bytes. + Random, +} + +/// Provider capability query, including optional algorithm granularity. +#[derive(Debug, Clone, Copy)] +pub struct CapabilityQuery<'a> { + /// Operation the caller intends to execute. + pub operation: ProviderOperation, + /// Standard algorithm URI when one exists. + pub algorithm: Option<&'a str>, +} + +/// Failure returned by a cryptographic provider. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum ProviderError { + /// The selected provider does not implement the operation/parameters. + #[error("provider does not support {operation:?} with algorithm {algorithm:?}")] + Unsupported { + /// Requested operation. + operation: ProviderOperation, + /// Requested algorithm URI or name. + algorithm: Option, + }, + /// A key has the wrong size for the selected algorithm. + #[error("invalid key size: expected {expected} bytes, got {actual}")] + InvalidKeySize { + /// Required key length. + expected: usize, + /// Supplied key length. + actual: usize, + }, + /// Input framing or padding is invalid. + #[error("invalid cryptographic input: {0}")] + InvalidInput(&'static str), + /// Authenticated decryption or key-wrap integrity validation failed. + #[error("cryptographic authentication failed")] + AuthenticationFailed, + /// Operating-system randomness was unavailable. + #[error("operating-system random number generation failed: {0}")] + Random(String), +} + +/// Stateless provider operations used by the XML Security pipelines. +pub trait CryptoProvider: Send + Sync { + /// Stable provider name for diagnostics and capability reporting. + fn name(&self) -> &'static str; + + /// Return whether this build supports the requested operation and parameters. + fn supports(&self, query: CapabilityQuery<'_>) -> bool; + + /// Fill caller-owned output with cryptographically secure random bytes. + fn fill_random(&self, output: &mut [u8]) -> Result<(), ProviderError>; + + /// Compute a message digest. + #[cfg(feature = "xmldsig")] + fn digest(&self, algorithm: DigestAlgorithm, data: &[u8]) -> Result, ProviderError>; + + /// Sign bytes with an opaque key handle. + #[cfg(feature = "xmldsig")] + fn sign( + &self, + key: &dyn crate::xmldsig::SigningKey, + algorithm: crate::xmldsig::SignatureAlgorithm, + data: &[u8], + ) -> Result, crate::xmldsig::SigningKeyError>; + + /// Verify bytes with an opaque key handle. + #[cfg(feature = "xmldsig")] + fn verify( + &self, + key: &dyn crate::xmldsig::VerifyingKey, + algorithm: crate::xmldsig::SignatureAlgorithm, + data: &[u8], + signature: &[u8], + ) -> Result; + + /// Encrypt XMLEnc content bytes, including standard framing. + #[cfg(feature = "xmlenc")] + fn encrypt_data( + &self, + algorithm: DataEncryptionAlgorithm, + key: &[u8], + plaintext: &[u8], + ) -> Result, ProviderError>; + + /// Decrypt XMLEnc content bytes, including framing validation. + #[cfg(feature = "xmlenc")] + fn decrypt_data( + &self, + algorithm: DataEncryptionAlgorithm, + key: &[u8], + ciphertext: &[u8], + ) -> Result, ProviderError>; + + /// Wrap a content key with RFC 3394 AES Key Wrap. + #[cfg(feature = "xmlenc")] + fn wrap_key( + &self, + algorithm: KeyWrapAlgorithm, + kek: &[u8], + key: &[u8], + ) -> Result, ProviderError>; + + /// Unwrap a content key with RFC 3394 AES Key Wrap. + #[cfg(feature = "xmlenc")] + fn unwrap_key( + &self, + algorithm: KeyWrapAlgorithm, + kek: &[u8], + wrapped: &[u8], + ) -> Result, ProviderError>; + + /// Wrap key bytes using an opaque RSA public-key operation. + #[cfg(feature = "xmlenc")] + fn transport_key( + &self, + key: &rsa::RsaPublicKey, + parameters: &RsaOaepParameters, + plaintext: &[u8], + ) -> Result, ProviderError>; + + /// Recover key bytes using an opaque RSA private-key operation. + #[cfg(feature = "xmlenc")] + fn recover_key( + &self, + key: &rsa::RsaPrivateKey, + parameters: &RsaOaepParameters, + ciphertext: &[u8], + ) -> Result, ProviderError>; +} + +/// Pure-Rust provider backed by RustCrypto crates. +#[derive(Debug, Clone, Copy, Default)] +pub struct RustCryptoProvider; + +/// Process-wide immutable default provider. It contains no mutable state or keys. +pub static RUST_CRYPTO_PROVIDER: RustCryptoProvider = RustCryptoProvider; + +/// Borrow the pure-Rust default provider. +#[must_use] +pub fn default_provider() -> &'static dyn CryptoProvider { + &RUST_CRYPTO_PROVIDER +} + +/// Adapter used when a RustCrypto primitive requires a fallible RNG object. +#[cfg(feature = "xmlenc")] +pub(crate) struct ProviderRng<'a>(pub(crate) &'a dyn CryptoProvider); + +#[cfg(feature = "xmlenc")] +impl TryRng for ProviderRng<'_> { + type Error = ProviderError; + + fn try_next_u32(&mut self) -> Result { + let mut bytes = [0_u8; 4]; + self.try_fill_bytes(&mut bytes)?; + Ok(u32::from_le_bytes(bytes)) + } + + fn try_next_u64(&mut self) -> Result { + let mut bytes = [0_u8; 8]; + self.try_fill_bytes(&mut bytes)?; + Ok(u64::from_le_bytes(bytes)) + } + + fn try_fill_bytes(&mut self, output: &mut [u8]) -> Result<(), Self::Error> { + self.0.fill_random(output) + } +} + +#[cfg(feature = "xmlenc")] +impl TryCryptoRng for ProviderRng<'_> {} + +impl CryptoProvider for RustCryptoProvider { + fn name(&self) -> &'static str { + "rustcrypto" + } + + fn supports(&self, query: CapabilityQuery<'_>) -> bool { + match query.operation { + ProviderOperation::Digest => query.algorithm.is_none_or(|algorithm| { + matches!( + algorithm, + "http://www.w3.org/2000/09/xmldsig#sha1" + | "http://www.w3.org/2001/04/xmlenc#sha256" + | "http://www.w3.org/2001/04/xmldsig-more#sha384" + | "http://www.w3.org/2001/04/xmlenc#sha512" + ) + }), + ProviderOperation::Sign | ProviderOperation::Verify => { + query.algorithm.is_none_or(is_supported_signature_uri) + } + ProviderOperation::Encrypt | ProviderOperation::Decrypt => { + query.algorithm.is_none_or(is_supported_data_encryption_uri) + } + ProviderOperation::KeyWrap | ProviderOperation::KeyUnwrap => { + query.algorithm.is_none_or(is_supported_key_wrap_uri) + } + ProviderOperation::KeyTransport => query.algorithm.is_none_or(|algorithm| { + matches!( + algorithm, + "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p" + | "http://www.w3.org/2009/xmlenc11#rsa-oaep" + ) + }), + ProviderOperation::Random => true, + ProviderOperation::KeyAgreement | ProviderOperation::Kdf => false, + } + } + + fn fill_random(&self, output: &mut [u8]) -> Result<(), ProviderError> { + SysRng + .try_fill_bytes(output) + .map_err(|error| ProviderError::Random(error.to_string())) + } + + #[cfg(feature = "xmldsig")] + fn digest(&self, algorithm: DigestAlgorithm, data: &[u8]) -> Result, ProviderError> { + use sha1::Sha1; + use sha2::{Digest, Sha256, Sha384, Sha512}; + Ok(match algorithm { + DigestAlgorithm::Sha1 => Sha1::digest(data).to_vec(), + DigestAlgorithm::Sha256 => Sha256::digest(data).to_vec(), + DigestAlgorithm::Sha384 => Sha384::digest(data).to_vec(), + DigestAlgorithm::Sha512 => Sha512::digest(data).to_vec(), + }) + } + + #[cfg(feature = "xmldsig")] + fn sign( + &self, + key: &dyn crate::xmldsig::SigningKey, + algorithm: crate::xmldsig::SignatureAlgorithm, + data: &[u8], + ) -> Result, crate::xmldsig::SigningKeyError> { + self.require(ProviderOperation::Sign, Some(algorithm.uri()))?; + key.sign(algorithm, data) + } + + #[cfg(feature = "xmldsig")] + fn verify( + &self, + key: &dyn crate::xmldsig::VerifyingKey, + algorithm: crate::xmldsig::SignatureAlgorithm, + data: &[u8], + signature: &[u8], + ) -> Result { + self.require(ProviderOperation::Verify, Some(algorithm.uri()))?; + key.verify(algorithm, data, signature) + } + + #[cfg(feature = "xmlenc")] + fn encrypt_data( + &self, + algorithm: DataEncryptionAlgorithm, + key: &[u8], + plaintext: &[u8], + ) -> Result, ProviderError> { + rustcrypto::encrypt_data(self, algorithm, key, plaintext) + } + + #[cfg(feature = "xmlenc")] + fn decrypt_data( + &self, + algorithm: DataEncryptionAlgorithm, + key: &[u8], + ciphertext: &[u8], + ) -> Result, ProviderError> { + rustcrypto::decrypt_data(algorithm, key, ciphertext) + } + + #[cfg(feature = "xmlenc")] + fn wrap_key( + &self, + algorithm: KeyWrapAlgorithm, + kek: &[u8], + key: &[u8], + ) -> Result, ProviderError> { + rustcrypto::wrap_key(algorithm, kek, key) + } + + #[cfg(feature = "xmlenc")] + fn unwrap_key( + &self, + algorithm: KeyWrapAlgorithm, + kek: &[u8], + wrapped: &[u8], + ) -> Result, ProviderError> { + rustcrypto::unwrap_key(algorithm, kek, wrapped) + } + + #[cfg(feature = "xmlenc")] + fn transport_key( + &self, + key: &rsa::RsaPublicKey, + parameters: &RsaOaepParameters, + plaintext: &[u8], + ) -> Result, ProviderError> { + self.require( + ProviderOperation::KeyTransport, + Some(parameters.algorithm.uri()), + )?; + rustcrypto::transport_key(self, key, parameters, plaintext) + } + + #[cfg(feature = "xmlenc")] + fn recover_key( + &self, + key: &rsa::RsaPrivateKey, + parameters: &RsaOaepParameters, + ciphertext: &[u8], + ) -> Result, ProviderError> { + self.require( + ProviderOperation::KeyTransport, + Some(parameters.algorithm.uri()), + )?; + rustcrypto::recover_key(self, key, parameters, ciphertext) + } +} + +impl RustCryptoProvider { + fn require( + &self, + operation: ProviderOperation, + algorithm: Option<&str>, + ) -> Result<(), ProviderError> { + if self.supports(CapabilityQuery { + operation, + algorithm, + }) { + Ok(()) + } else { + Err(ProviderError::Unsupported { + operation, + algorithm: algorithm.map(str::to_owned), + }) + } + } +} + +fn is_supported_signature_uri(algorithm: &str) -> bool { + matches!( + algorithm, + "http://www.w3.org/2000/09/xmldsig#dsa-sha1" + | "http://www.w3.org/2000/09/xmldsig#hmac-sha1" + | "http://www.w3.org/2000/09/xmldsig#rsa-sha1" + | "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" + | "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384" + | "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512" + | "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256" + | "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384" + ) +} + +fn is_supported_data_encryption_uri(algorithm: &str) -> bool { + matches!( + algorithm, + "http://www.w3.org/2001/04/xmlenc#aes128-cbc" + | "http://www.w3.org/2001/04/xmlenc#aes256-cbc" + | "http://www.w3.org/2009/xmlenc11#aes128-gcm" + | "http://www.w3.org/2009/xmlenc11#aes256-gcm" + ) +} + +fn is_supported_key_wrap_uri(algorithm: &str) -> bool { + matches!( + algorithm, + "http://www.w3.org/2001/04/xmlenc#kw-aes128" | "http://www.w3.org/2001/04/xmlenc#kw-aes256" + ) +} + +#[cfg(feature = "xmlenc")] +mod rustcrypto { + use aes::{ + Aes128, Aes256, + cipher::{BlockModeDecrypt, BlockModeEncrypt, KeyIvInit, block_padding::NoPadding}, + }; + use aes_gcm::{ + Aes128Gcm, Aes256Gcm, Nonce, + aead::{AeadInOut, KeyInit}, + }; + use aes_kw::{KwAes128, KwAes256}; + use cbc::{Decryptor, Encryptor}; + use rsa::{Oaep, traits::PaddingScheme}; + use sha1::Sha1; + use sha2::{Sha256, Sha384, Sha512}; + + use super::{CryptoProvider, ProviderError}; + use crate::xmlenc::{ + DataEncryptionAlgorithm, KeyTransportAlgorithm, KeyWrapAlgorithm, OaepDigestAlgorithm, + RsaOaepParameters, + }; + + pub(super) fn encrypt_data( + provider: &dyn CryptoProvider, + algorithm: DataEncryptionAlgorithm, + key: &[u8], + plaintext: &[u8], + ) -> Result, ProviderError> { + check_key(algorithm.key_len(), key)?; + match algorithm { + DataEncryptionAlgorithm::Aes128Cbc => encrypt_cbc::(provider, key, plaintext), + DataEncryptionAlgorithm::Aes256Cbc => encrypt_cbc::(provider, key, plaintext), + DataEncryptionAlgorithm::Aes128Gcm => { + encrypt_gcm::(provider, key, plaintext) + } + DataEncryptionAlgorithm::Aes256Gcm => { + encrypt_gcm::(provider, key, plaintext) + } + } + } + + pub(super) fn decrypt_data( + algorithm: DataEncryptionAlgorithm, + key: &[u8], + ciphertext: &[u8], + ) -> Result, ProviderError> { + check_key(algorithm.key_len(), key)?; + match algorithm { + DataEncryptionAlgorithm::Aes128Cbc => decrypt_cbc::(key, ciphertext), + DataEncryptionAlgorithm::Aes256Cbc => decrypt_cbc::(key, ciphertext), + DataEncryptionAlgorithm::Aes128Gcm => decrypt_gcm::(key, ciphertext), + DataEncryptionAlgorithm::Aes256Gcm => decrypt_gcm::(key, ciphertext), + } + } + + fn check_key(expected: usize, key: &[u8]) -> Result<(), ProviderError> { + if key.len() == expected { + Ok(()) + } else { + Err(ProviderError::InvalidKeySize { + expected, + actual: key.len(), + }) + } + } + + fn encrypt_cbc( + provider: &dyn CryptoProvider, + key: &[u8], + plaintext: &[u8], + ) -> Result, ProviderError> + where + C: aes::cipher::BlockCipherEncrypt + aes::cipher::KeyInit, + { + let mut iv = [0_u8; 16]; + provider.fill_random(&mut iv)?; + let pad_len = 16 - (plaintext.len() % 16); + let mut padded = vec![0_u8; plaintext.len() + pad_len]; + padded[..plaintext.len()].copy_from_slice(plaintext); + if pad_len > 1 { + let last = padded.len() - 1; + provider.fill_random(&mut padded[plaintext.len()..last])?; + } + *padded.last_mut().expect("padding is non-empty") = pad_len as u8; + Encryptor::::new_from_slices(key, &iv) + .map_err(|_| ProviderError::InvalidKeySize { + expected: key.len(), + actual: key.len(), + })? + .encrypt_padded::(&mut padded, plaintext.len() + pad_len) + .map_err(|_| ProviderError::InvalidInput("AES-CBC padding"))?; + let mut output = Vec::with_capacity(16 + padded.len()); + output.extend_from_slice(&iv); + output.extend_from_slice(&padded); + Ok(output) + } + + fn decrypt_cbc(key: &[u8], ciphertext: &[u8]) -> Result, ProviderError> + where + C: aes::cipher::BlockCipherDecrypt + aes::cipher::KeyInit, + { + if ciphertext.len() < 32 || !(ciphertext.len() - 16).is_multiple_of(16) { + return Err(ProviderError::InvalidInput("AES-CBC framing")); + } + let (iv, body) = ciphertext.split_at(16); + let mut plaintext = body.to_vec(); + Decryptor::::new_from_slices(key, iv) + .map_err(|_| ProviderError::InvalidKeySize { + expected: key.len(), + actual: key.len(), + })? + .decrypt_padded::(&mut plaintext) + .map_err(|_| ProviderError::InvalidInput("AES-CBC ciphertext"))?; + let pad_len = usize::from( + *plaintext + .last() + .ok_or(ProviderError::InvalidInput("AES-CBC plaintext"))?, + ); + if !(1..=16).contains(&pad_len) || pad_len > plaintext.len() { + return Err(ProviderError::InvalidInput("XMLEnc CBC padding")); + } + plaintext.truncate(plaintext.len() - pad_len); + Ok(plaintext) + } + + fn encrypt_gcm( + provider: &dyn CryptoProvider, + key: &[u8], + plaintext: &[u8], + ) -> Result, ProviderError> + where + C: AeadInOut + KeyInit, + { + let mut nonce = [0_u8; 12]; + provider.fill_random(&mut nonce)?; + let cipher = C::new_from_slice(key).map_err(|_| ProviderError::InvalidKeySize { + expected: key.len(), + actual: key.len(), + })?; + let mut output = plaintext.to_vec(); + let nonce = Nonce::try_from(nonce.as_slice()) + .map_err(|_| ProviderError::InvalidInput("AES-GCM nonce"))?; + cipher + .encrypt_in_place(&nonce, &[], &mut output) + .map_err(|_| ProviderError::AuthenticationFailed)?; + let mut framed = Vec::with_capacity(12 + output.len()); + framed.extend_from_slice(&nonce); + framed.extend_from_slice(&output); + Ok(framed) + } + + fn decrypt_gcm(key: &[u8], ciphertext: &[u8]) -> Result, ProviderError> + where + C: AeadInOut + KeyInit, + { + if ciphertext.len() < 28 { + return Err(ProviderError::InvalidInput("AES-GCM framing")); + } + let (nonce, body) = ciphertext.split_at(12); + let cipher = C::new_from_slice(key).map_err(|_| ProviderError::InvalidKeySize { + expected: key.len(), + actual: key.len(), + })?; + let mut plaintext = body.to_vec(); + let nonce = + Nonce::try_from(nonce).map_err(|_| ProviderError::InvalidInput("AES-GCM nonce"))?; + cipher + .decrypt_in_place(&nonce, &[], &mut plaintext) + .map_err(|_| ProviderError::AuthenticationFailed)?; + Ok(plaintext) + } + + pub(super) fn wrap_key( + algorithm: KeyWrapAlgorithm, + kek: &[u8], + key: &[u8], + ) -> Result, ProviderError> { + check_key(algorithm.key_len(), kek)?; + let mut output = vec![0_u8; key.len() + 8]; + match algorithm { + KeyWrapAlgorithm::AesKw128 => KwAes128::new_from_slice(kek) + .map_err(|_| ProviderError::InvalidKeySize { + expected: 16, + actual: kek.len(), + })? + .wrap_key(key, &mut output), + KeyWrapAlgorithm::AesKw256 => KwAes256::new_from_slice(kek) + .map_err(|_| ProviderError::InvalidKeySize { + expected: 32, + actual: kek.len(), + })? + .wrap_key(key, &mut output), + } + .map_err(|_| ProviderError::InvalidInput("AES key wrap"))?; + Ok(output) + } + + pub(super) fn unwrap_key( + algorithm: KeyWrapAlgorithm, + kek: &[u8], + wrapped: &[u8], + ) -> Result, ProviderError> { + check_key(algorithm.key_len(), kek)?; + if wrapped.len() < 16 || !wrapped.len().is_multiple_of(8) { + return Err(ProviderError::InvalidInput("AES key wrap framing")); + } + let mut output = vec![0_u8; wrapped.len() - 8]; + let key = match algorithm { + KeyWrapAlgorithm::AesKw128 => KwAes128::new_from_slice(kek) + .map_err(|_| ProviderError::InvalidKeySize { + expected: 16, + actual: kek.len(), + })? + .unwrap_key(wrapped, &mut output), + KeyWrapAlgorithm::AesKw256 => KwAes256::new_from_slice(kek) + .map_err(|_| ProviderError::InvalidKeySize { + expected: 32, + actual: kek.len(), + })? + .unwrap_key(wrapped, &mut output), + } + .map_err(|_| ProviderError::AuthenticationFailed)?; + Ok(key.to_vec()) + } + + pub(super) fn transport_key( + provider: &dyn CryptoProvider, + key: &rsa::RsaPublicKey, + parameters: &RsaOaepParameters, + plaintext: &[u8], + ) -> Result, ProviderError> { + if parameters.algorithm == KeyTransportAlgorithm::RsaOaepMgf1p + && parameters.mgf_digest != OaepDigestAlgorithm::Sha1 + { + return Err(ProviderError::InvalidInput( + "legacy RSA-OAEP requires MGF1-SHA1", + )); + } + let mut rng = super::ProviderRng(provider); + macro_rules! encrypt_with { + ($digest:ty, $mgf:ty) => { + Oaep::<$digest, $mgf>::new_with_mgf_hash_and_label(parameters.label.clone()) + .encrypt(&mut rng, key, plaintext) + }; + } + let result = match (parameters.digest, parameters.mgf_digest) { + (OaepDigestAlgorithm::Sha1, OaepDigestAlgorithm::Sha1) => { + encrypt_with!(Sha1, Sha1) + } + (OaepDigestAlgorithm::Sha1, OaepDigestAlgorithm::Sha256) => { + encrypt_with!(Sha1, Sha256) + } + (OaepDigestAlgorithm::Sha1, OaepDigestAlgorithm::Sha384) => { + encrypt_with!(Sha1, Sha384) + } + (OaepDigestAlgorithm::Sha1, OaepDigestAlgorithm::Sha512) => { + encrypt_with!(Sha1, Sha512) + } + (OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha1) => { + encrypt_with!(Sha256, Sha1) + } + (OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha256) => { + encrypt_with!(Sha256, Sha256) + } + (OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha384) => { + encrypt_with!(Sha256, Sha384) + } + (OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha512) => { + encrypt_with!(Sha256, Sha512) + } + (OaepDigestAlgorithm::Sha384, OaepDigestAlgorithm::Sha1) => { + encrypt_with!(Sha384, Sha1) + } + (OaepDigestAlgorithm::Sha384, OaepDigestAlgorithm::Sha256) => { + encrypt_with!(Sha384, Sha256) + } + (OaepDigestAlgorithm::Sha384, OaepDigestAlgorithm::Sha384) => { + encrypt_with!(Sha384, Sha384) + } + (OaepDigestAlgorithm::Sha384, OaepDigestAlgorithm::Sha512) => { + encrypt_with!(Sha384, Sha512) + } + (OaepDigestAlgorithm::Sha512, OaepDigestAlgorithm::Sha1) => { + encrypt_with!(Sha512, Sha1) + } + (OaepDigestAlgorithm::Sha512, OaepDigestAlgorithm::Sha256) => { + encrypt_with!(Sha512, Sha256) + } + (OaepDigestAlgorithm::Sha512, OaepDigestAlgorithm::Sha384) => { + encrypt_with!(Sha512, Sha384) + } + (OaepDigestAlgorithm::Sha512, OaepDigestAlgorithm::Sha512) => { + encrypt_with!(Sha512, Sha512) + } + }; + result.map_err(map_rsa_error) + } + + pub(super) fn recover_key( + provider: &dyn CryptoProvider, + key: &rsa::RsaPrivateKey, + parameters: &RsaOaepParameters, + ciphertext: &[u8], + ) -> Result, ProviderError> { + let mut rng = super::ProviderRng(provider); + macro_rules! decrypt_with { + ($digest:ty, $mgf:ty) => { + Oaep::<$digest, $mgf>::new_with_mgf_hash_and_label(parameters.label.clone()) + .decrypt(Some(&mut rng), key, ciphertext) + }; + } + let result = match (parameters.digest, parameters.mgf_digest) { + (OaepDigestAlgorithm::Sha1, OaepDigestAlgorithm::Sha1) => { + decrypt_with!(Sha1, Sha1) + } + (OaepDigestAlgorithm::Sha1, OaepDigestAlgorithm::Sha256) => { + decrypt_with!(Sha1, Sha256) + } + (OaepDigestAlgorithm::Sha1, OaepDigestAlgorithm::Sha384) => { + decrypt_with!(Sha1, Sha384) + } + (OaepDigestAlgorithm::Sha1, OaepDigestAlgorithm::Sha512) => { + decrypt_with!(Sha1, Sha512) + } + (OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha1) => { + decrypt_with!(Sha256, Sha1) + } + (OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha256) => { + decrypt_with!(Sha256, Sha256) + } + (OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha384) => { + decrypt_with!(Sha256, Sha384) + } + (OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha512) => { + decrypt_with!(Sha256, Sha512) + } + (OaepDigestAlgorithm::Sha384, OaepDigestAlgorithm::Sha1) => { + decrypt_with!(Sha384, Sha1) + } + (OaepDigestAlgorithm::Sha384, OaepDigestAlgorithm::Sha256) => { + decrypt_with!(Sha384, Sha256) + } + (OaepDigestAlgorithm::Sha384, OaepDigestAlgorithm::Sha384) => { + decrypt_with!(Sha384, Sha384) + } + (OaepDigestAlgorithm::Sha384, OaepDigestAlgorithm::Sha512) => { + decrypt_with!(Sha384, Sha512) + } + (OaepDigestAlgorithm::Sha512, OaepDigestAlgorithm::Sha1) => { + decrypt_with!(Sha512, Sha1) + } + (OaepDigestAlgorithm::Sha512, OaepDigestAlgorithm::Sha256) => { + decrypt_with!(Sha512, Sha256) + } + (OaepDigestAlgorithm::Sha512, OaepDigestAlgorithm::Sha384) => { + decrypt_with!(Sha512, Sha384) + } + (OaepDigestAlgorithm::Sha512, OaepDigestAlgorithm::Sha512) => { + decrypt_with!(Sha512, Sha512) + } + }; + result.map_err(map_rsa_error) + } + + fn map_rsa_error(error: rsa::Error) -> ProviderError { + match error { + rsa::Error::Rng => ProviderError::Random("RSA-OAEP randomness failed".into()), + _ => ProviderError::AuthenticationFailed, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn capability_query_is_explicit_about_unimplemented_operations() { + assert!(RUST_CRYPTO_PROVIDER.supports(CapabilityQuery { + operation: ProviderOperation::Digest, + algorithm: None + })); + assert!(!RUST_CRYPTO_PROVIDER.supports(CapabilityQuery { + operation: ProviderOperation::KeyAgreement, + algorithm: None + })); + assert!(!RUST_CRYPTO_PROVIDER.supports(CapabilityQuery { + operation: ProviderOperation::Verify, + algorithm: Some("urn:unsupported:signature"), + })); + } +} diff --git a/src/xmldsig/digest.rs b/src/xmldsig/digest.rs index a6021428..3d404620 100644 --- a/src/xmldsig/digest.rs +++ b/src/xmldsig/digest.rs @@ -5,8 +5,6 @@ //! //! All digest computation uses RustCrypto hash implementations. -use sha1::Sha1; -use sha2::{Digest, Sha256, Sha384, Sha512}; use subtle::ConstantTimeEq; /// Digest algorithms supported by XMLDSig. @@ -81,12 +79,18 @@ impl DigestAlgorithm { /// /// Returns the raw digest bytes (not base64-encoded). pub fn compute_digest(algorithm: DigestAlgorithm, data: &[u8]) -> Vec { - match algorithm { - DigestAlgorithm::Sha1 => Sha1::digest(data).to_vec(), - DigestAlgorithm::Sha256 => Sha256::digest(data).to_vec(), - DigestAlgorithm::Sha384 => Sha384::digest(data).to_vec(), - DigestAlgorithm::Sha512 => Sha512::digest(data).to_vec(), - } + compute_digest_with_provider(crate::provider::default_provider(), algorithm, data) +} + +/// Compute a digest with an explicitly selected provider. +pub fn compute_digest_with_provider( + provider: &dyn crate::provider::CryptoProvider, + algorithm: DigestAlgorithm, + data: &[u8], +) -> Vec { + provider + .digest(algorithm, data) + .expect("default provider advertises every XMLDSig digest") } /// Constant-time comparison of two byte slices. diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 7c031a2a..62ae9e61 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -17,9 +17,9 @@ use super::{ X509ChainOptions, X509DataInfo, parse::{ EC_P256_OID, EC_P384_OID, ParseError, X509ChainBuildError, - build_x509_certificate_chain_from, distinguished_names_equal, parse_x509_certificate, - x509_certificate_matches_any_selector, x509_data_has_lookup_identifiers, - x509_selector_categories_match_chain, + build_x509_certificate_paths_to_trusted_prefix, distinguished_names_equal, + parse_x509_certificate, x509_certificate_matches_any_selector, + x509_data_has_lookup_identifiers, x509_selector_categories_match_chain, }, verify_dsa_signature_spki, verify_ecdsa_signature_spki, verify_rsa_signature_spki, verify_x509_certificate_chain, @@ -88,31 +88,6 @@ impl VerifyingKey for HmacSha1VerificationKey { } } -struct LegacyRsaSha1VerificationKey { - public_key_bytes: Vec, -} - -impl VerifyingKey for LegacyRsaSha1VerificationKey { - fn verify( - &self, - algorithm: SignatureAlgorithm, - signed_data: &[u8], - signature_value: &[u8], - ) -> Result { - if algorithm != SignatureAlgorithm::RsaSha1 { - return Err(KeyResolutionError::AlgorithmMismatch.into()); - } - verify_rsa_signature_spki_with_minimum( - algorithm, - &self.public_key_bytes, - signed_data, - signature_value, - 1024, - ) - .map_err(DsigError::Crypto) - } -} - /// A public verification key available to key resolvers. #[derive(Debug, Clone, PartialEq, Eq)] pub struct VerificationKey { @@ -146,8 +121,14 @@ impl VerifyingKey for VerificationKey { SignatureAlgorithm::HmacSha1 => { return Err(KeyResolutionError::AlgorithmMismatch.into()); } - SignatureAlgorithm::RsaSha1 - | SignatureAlgorithm::RsaSha256 + SignatureAlgorithm::RsaSha1 => verify_rsa_signature_spki_with_minimum( + algorithm, + &self.public_key_bytes, + signed_data, + signature_value, + 1024, + ), + SignatureAlgorithm::RsaSha256 | SignatureAlgorithm::RsaSha384 | SignatureAlgorithm::RsaSha512 => verify_rsa_signature_spki( algorithm, @@ -203,7 +184,7 @@ pub enum KeyResolutionError { /// The configuration owns all key material and has no global registry. Chain /// verification is opt-in so callers that pin an embedded certificate can use /// the documented TOFU model without constructing a certificate path. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct KeyResolverConfig { /// DER-encoded certificates available to X.509 selectors and as untrusted /// path intermediates. They establish trust only by chaining to an entry in @@ -213,31 +194,12 @@ pub struct KeyResolverConfig { pub trusted_certs: Vec>, /// Verification keys addressable by `` content. pub named_keys: HashMap, - /// Whether embedded X.509 certificate chains must terminate at a trust anchor. - pub verify_chains: bool, - /// Whether embedded CRLs are authenticated and enforced during chain validation. - pub check_crls: bool, - /// Allow verify-only RSA-SHA1 keys down to 1024 bits for legacy corpora. - pub allow_legacy_rsa_sha1: bool, - /// Certificate verification time override; `None` selects the system clock. - pub verification_time: Option, - /// Maximum certificates in a validated path, including the trust anchor. - pub max_chain_depth: usize, -} - -impl Default for KeyResolverConfig { - fn default() -> Self { - Self { - lookup_certs: Vec::new(), - trusted_certs: Vec::new(), - named_keys: HashMap::new(), - verify_chains: false, - check_crls: false, - allow_legacy_rsa_sha1: false, - verification_time: None, - max_chain_depth: 9, - } - } + /// Trust defaults used only by direct [`KeyResolver::resolve`] calls. + /// + /// [`super::VerifyContext`] composes these defaults fail-closed with its + /// operation policy through `resolve_with_policy`; resolver-local defaults + /// cannot weaken a verification pipeline policy. + pub trust: crate::policy::KeyTrustPolicy, } /// Configuration-driven resolver for embedded certificates, DER keys, and key names. @@ -263,6 +225,7 @@ impl DefaultKeyResolver { &self, info: &X509DataInfo, algorithm: SignatureAlgorithm, + trust: &crate::policy::KeyTrustPolicy, ) -> Result, KeyResolutionError> { let certificate_der = if let Some(&signing_index) = info.certificate_chain.first() { let certificate_der = info @@ -270,16 +233,17 @@ impl DefaultKeyResolver { .get(signing_index) .ok_or(KeyResolutionError::InvalidCertificate)? .clone(); - if self.config.verify_chains { - self.verify_x509_policy(info)?; + if trust.verify_x509_chains { + let selected = self.prepare_embedded_x509(info, signing_index, trust)?; + self.verify_x509_policy(&selected, trust)?; } certificate_der } else { - let Some(selected) = self.resolve_configured_x509(info)? else { + let Some(selected) = self.resolve_configured_x509(info, trust)? else { return Ok(None); }; - if self.config.verify_chains { - self.verify_x509_policy(&selected)?; + if trust.verify_x509_chains { + self.verify_x509_policy(&selected, trust)?; } selected .certificate_chain @@ -304,23 +268,100 @@ impl DefaultKeyResolver { })) } - fn verify_x509_policy(&self, info: &X509DataInfo) -> Result<(), KeyResolutionError> { + fn verify_x509_policy( + &self, + info: &X509DataInfo, + trust: &crate::policy::KeyTrustPolicy, + ) -> Result<(), KeyResolutionError> { let options = X509ChainOptions { trusted_certs: &self.config.trusted_certs, - verification_time: self - .config - .verification_time - .unwrap_or_else(SystemTime::now), - max_chain_depth: self.config.max_chain_depth, - check_crls: self.config.check_crls, + verification_time: trust.verification_time.unwrap_or_else(SystemTime::now), + max_chain_depth: trust.max_x509_chain_depth, + check_crls: trust.check_crls, }; verify_x509_certificate_chain(info, &options)?; Ok(()) } + fn prepare_embedded_x509( + &self, + info: &X509DataInfo, + signing_index: usize, + trust: &crate::policy::KeyTrustPolicy, + ) -> Result { + let signing_der = info + .certificates + .get(signing_index) + .ok_or(KeyResolutionError::InvalidCertificate)?; + let mut available = X509DataInfo { + crls: info.crls.clone(), + ..X509DataInfo::default() + }; + for certificate in self + .config + .trusted_certs + .iter() + .chain(&self.config.lookup_certs) + .chain(&info.certificates) + { + if available + .certificates + .iter() + .any(|known| known == certificate) + { + continue; + } + available.parsed_certificates.push( + parse_x509_certificate(certificate) + .map_err(|_| KeyResolutionError::InvalidCertificate)?, + ); + available.certificates.push(certificate.clone()); + } + let signing_index = available + .certificates + .iter() + .position(|certificate| certificate == signing_der) + .ok_or(KeyResolutionError::InvalidCertificate)?; + self.select_valid_x509_path(&mut available, signing_index, trust)?; + Ok(available) + } + + fn select_valid_x509_path( + &self, + available: &mut X509DataInfo, + signing_index: usize, + trust: &crate::policy::KeyTrustPolicy, + ) -> Result<(), KeyResolutionError> { + let candidates = build_x509_certificate_paths_to_trusted_prefix( + available, + signing_index, + self.config.trusted_certs.len(), + trust.max_x509_chain_depth, + trust.max_x509_candidate_paths, + ) + .map_err(|error| match error { + X509ChainBuildError::AmbiguousIssuer => KeyResolutionError::AmbiguousCertificate, + _ => KeyResolutionError::InvalidCertificate, + })?; + let mut first_error = None; + for candidate in candidates { + available.certificate_chain = candidate; + match self.verify_x509_policy(available, trust) { + Ok(()) => return Ok(()), + Err(error) => { + first_error.get_or_insert(error); + } + } + } + Err(first_error.unwrap_or(KeyResolutionError::Chain( + super::X509ChainError::UntrustedRoot, + ))) + } + fn resolve_configured_x509( &self, info: &X509DataInfo, + trust: &crate::policy::KeyTrustPolicy, ) -> Result, KeyResolutionError> { if !x509_data_has_lookup_identifiers(info) { return Ok(None); @@ -403,18 +444,13 @@ impl DefaultKeyResolver { // `available` preserves trusted certificates as a prefix. Selecting // one of those exact certificates is already a terminal trust // decision, even when the certificate is not self-signed. - available.certificate_chain = if signing_index < self.config.trusted_certs.len() { - vec![signing_index] - } else { - build_x509_certificate_chain_from(&available, signing_index).map_err(|error| { - match error { - X509ChainBuildError::AmbiguousIssuer => { - KeyResolutionError::AmbiguousCertificate - } - _ => KeyResolutionError::InvalidCertificate, - } - })? - }; + available.certificate_chain = + if signing_index < self.config.trusted_certs.len() || !trust.verify_x509_chains { + vec![signing_index] + } else { + self.select_valid_x509_path(&mut available, signing_index, trust)?; + available.certificate_chain.clone() + }; Ok(Some(available)) } @@ -468,13 +504,12 @@ impl DefaultKeyResolver { name: None, })) } -} -impl KeyResolver for DefaultKeyResolver { - fn resolve<'a>( + fn resolve_with_trust<'a>( &'a self, key_info: Option<&KeyInfo>, algorithm: SignatureAlgorithm, + trust: &crate::policy::KeyTrustPolicy, ) -> Result>, DsigError> { let Some(key_info) = key_info else { return Ok(None); @@ -482,7 +517,7 @@ impl KeyResolver for DefaultKeyResolver { let mut deferred_key_value_error = None; for source in &key_info.sources { let resolved = match source { - KeyInfoSource::X509Data(info) => self.resolve_x509(info, algorithm)?, + KeyInfoSource::X509Data(info) => self.resolve_x509(info, algorithm, trust)?, KeyInfoSource::DerEncodedKeyValue(public_key_bytes) => { validate_spki_algorithm(public_key_bytes, algorithm)?; Some(VerificationKey { @@ -517,11 +552,6 @@ impl KeyResolver for DefaultKeyResolver { KeyInfoSource::RetrievalMethod { .. } => None, }; if let Some(key) = resolved { - if self.config.allow_legacy_rsa_sha1 && algorithm == SignatureAlgorithm::RsaSha1 { - return Ok(Some(Box::new(LegacyRsaSha1VerificationKey { - public_key_bytes: key.public_key_bytes, - }))); - } return Ok(Some(Box::new(key))); } } @@ -530,6 +560,47 @@ impl KeyResolver for DefaultKeyResolver { } Ok(None) } +} + +impl KeyResolver for DefaultKeyResolver { + fn resolve<'a>( + &'a self, + key_info: Option<&KeyInfo>, + algorithm: SignatureAlgorithm, + ) -> Result>, DsigError> { + self.resolve_with_trust(key_info, algorithm, &self.config.trust) + } + + fn resolve_with_policy<'a>( + &'a self, + key_info: Option<&KeyInfo>, + algorithm: SignatureAlgorithm, + policy: &crate::policy::VerificationPolicy, + ) -> Result>, DsigError> { + // Resolver defaults and operation policy compose fail-closed. X.509 + // validation requirements can only become stricter, while the legacy + // algorithm opt-in remains exclusively context-owned and is enforced + // before key resolution. + let trust = crate::policy::KeyTrustPolicy { + verify_x509_chains: policy.key_trust.verify_x509_chains + || self.config.trust.verify_x509_chains, + max_x509_chain_depth: policy + .key_trust + .max_x509_chain_depth + .min(self.config.trust.max_x509_chain_depth), + max_x509_candidate_paths: policy + .key_trust + .max_x509_candidate_paths + .min(self.config.trust.max_x509_candidate_paths), + allow_legacy_rsa_sha1: policy.key_trust.allow_legacy_rsa_sha1, + check_crls: policy.key_trust.check_crls || self.config.trust.check_crls, + verification_time: policy + .key_trust + .verification_time + .or(self.config.trust.verification_time), + }; + self.resolve_with_trust(key_info, algorithm, &trust) + } fn consumes_document_key_info(&self) -> bool { true @@ -653,6 +724,20 @@ mod tests { use super::*; + fn chain_policy() -> crate::policy::KeyTrustPolicy { + crate::policy::KeyTrustPolicy { + verify_x509_chains: true, + ..crate::policy::KeyTrustPolicy::default() + } + } + + fn chain_policy_at(verification_time: SystemTime) -> crate::policy::KeyTrustPolicy { + crate::policy::KeyTrustPolicy { + verification_time: Some(verification_time), + ..chain_policy() + } + } + const SIGNED_SAML: &str = include_str!("../../tests/fixtures/saml/response_signed_by_idp_ecdsa.xml"); const SAML_PUBLIC_KEY: &str = @@ -737,6 +822,35 @@ mod tests { pem.contents } + fn generated_certificate_params(common_name: &str, is_ca: bool) -> rcgen::CertificateParams { + let mut params = rcgen::CertificateParams::new(Vec::new()) + .expect("empty SAN list should produce valid certificate parameters"); + params + .distinguished_name + .push(rcgen::DnType::CommonName, common_name); + if is_ca { + params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign]; + } + params + } + + fn x509_info(certificates: Vec>, signing_index: usize) -> X509DataInfo { + let parsed_certificates = certificates + .iter() + .map(|certificate| { + parse_x509_certificate(certificate) + .expect("generated certificate should have supported metadata") + }) + .collect(); + X509DataInfo { + certificates, + parsed_certificates, + certificate_chain: vec![signing_index], + ..X509DataInfo::default() + } + } + #[test] fn defaults_match_key_resolution_policy() { // Defaults must remain compatible with xmlsec1's depth and opt-in trust policy. @@ -745,11 +859,10 @@ mod tests { assert!(config.trusted_certs.is_empty()); assert!(config.lookup_certs.is_empty()); assert!(config.named_keys.is_empty()); - assert!(!config.verify_chains); - assert!(!config.check_crls); - assert!(!config.allow_legacy_rsa_sha1); - assert_eq!(config.verification_time, None); - assert_eq!(config.max_chain_depth, 9); + assert!(!config.trust.verify_x509_chains); + assert!(!config.trust.check_crls); + assert_eq!(config.trust.verification_time, None); + assert_eq!(config.trust.max_x509_chain_depth, 9); } #[test] @@ -886,8 +999,7 @@ mod tests { certificate_der(include_str!("../../tests/fixtures/keys/ca2cert.pem")), certificate_der(include_str!("../../tests/fixtures/keys/cacert.pem")), ], - verify_chains: true, - verification_time: Some(SystemTime::UNIX_EPOCH), + trust: chain_policy_at(SystemTime::UNIX_EPOCH), ..KeyResolverConfig::default() }); let error = super::super::VerifyContext::new() @@ -932,7 +1044,7 @@ mod tests { .expect("static selector KeyInfo should satisfy XMLDSig structure"); let resolver = DefaultKeyResolver::new(KeyResolverConfig { trusted_certs: vec![certificate_der], - verify_chains: true, + trust: chain_policy(), ..KeyResolverConfig::default() }); @@ -984,7 +1096,7 @@ mod tests { let resolver = DefaultKeyResolver::new(KeyResolverConfig { trusted_certs: vec![anchor.der().to_vec()], lookup_certs: vec![issuer.der().to_vec()], - verify_chains: true, + trust: chain_policy(), ..KeyResolverConfig::default() }); @@ -995,6 +1107,49 @@ mod tests { assert!(resolved.is_some()); } + #[test] + fn selector_resolved_leaf_stops_at_non_self_signed_trust_anchor() { + // A configured anchor terminates trust even when a lookup certificate + // could continue the issuer-name chain beyond it. + let external_issuer = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("external issuer", true), + rcgen::KeyPair::generate().expect("external issuer key generation should succeed"), + ) + .expect("external issuer should be self-signable"); + let anchor = rcgen::CertifiedIssuer::signed_by( + generated_certificate_params("non-self-signed anchor", true), + rcgen::KeyPair::generate().expect("anchor key generation should succeed"), + &external_issuer, + ) + .expect("external issuer should sign the anchor"); + let leaf = generated_certificate_params("anchor leaf", false) + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &anchor, + ) + .expect("anchor should sign the leaf"); + let leaf_metadata = parse_x509_certificate(leaf.der()) + .expect("generated leaf should have supported metadata"); + let key_info = KeyInfo { + sources: vec![KeyInfoSource::X509Data(X509DataInfo { + subject_names: vec![leaf_metadata.subject_dn], + ..X509DataInfo::default() + })], + }; + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + trusted_certs: vec![anchor.der().to_vec()], + lookup_certs: vec![leaf.der().to_vec(), external_issuer.der().to_vec()], + trust: chain_policy(), + ..KeyResolverConfig::default() + }); + + let resolved = resolver + .resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) + .expect("path construction must stop at the configured anchor"); + + assert!(resolved.is_some()); + } + #[test] fn selector_resolved_leaf_does_not_anchor_itself() { // A certificate available for selector lookup is not automatically a @@ -1002,8 +1157,7 @@ mod tests { let certificate_der = certificate_der(RSA_4096_CERTIFICATE); let resolver = DefaultKeyResolver::new(KeyResolverConfig { lookup_certs: vec![certificate_der], - verify_chains: true, - verification_time: Some(fixture_certificate_time()), + trust: chain_policy_at(fixture_certificate_time()), ..KeyResolverConfig::default() }); let error = super::super::VerifyContext::new() @@ -1028,8 +1182,7 @@ mod tests { let resolver = DefaultKeyResolver::new(KeyResolverConfig { lookup_certs: vec![leaf], trusted_certs: vec![issuer], - verify_chains: true, - verification_time: Some(fixture_certificate_time()), + trust: chain_policy_at(fixture_certificate_time()), ..KeyResolverConfig::default() }); let result = super::super::VerifyContext::new() @@ -1094,7 +1247,7 @@ mod tests { let resolver = DefaultKeyResolver::new(KeyResolverConfig { lookup_certs: vec![leaf.der().to_vec(), intermediate.der().to_vec()], trusted_certs: vec![root.der().to_vec()], - verify_chains: true, + trust: chain_policy(), ..KeyResolverConfig::default() }); @@ -1105,6 +1258,105 @@ mod tests { assert!(resolved.is_some()); } + #[test] + fn embedded_leaf_uses_configured_lookup_intermediate() { + // lookup_certs are untrusted path-building material for every X509Data + // source, including an embedded leaf and raw-certificate retrieval. + let root = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("embedded root", true), + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("root should be self-signable"); + let intermediate = rcgen::CertifiedIssuer::signed_by( + generated_certificate_params("embedded intermediate", true), + rcgen::KeyPair::generate().expect("intermediate key generation should succeed"), + &root, + ) + .expect("root should sign the intermediate"); + let leaf = generated_certificate_params("embedded leaf", false) + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &intermediate, + ) + .expect("intermediate should sign the leaf"); + let key_info = KeyInfo { + sources: vec![KeyInfoSource::X509Data(x509_info( + vec![leaf.der().to_vec()], + 0, + ))], + }; + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + lookup_certs: vec![intermediate.der().to_vec()], + trusted_certs: vec![root.der().to_vec()], + trust: chain_policy(), + ..KeyResolverConfig::default() + }); + + let resolved = resolver + .resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) + .expect("embedded leaf should chain through the configured lookup intermediate"); + + assert!(resolved.is_some()); + } + + #[test] + fn selector_resolved_leaf_chooses_unique_valid_same_key_path() { + // Cross-signing can produce issuer certificates with the same subject + // and public key. Trust policy, not the immediate signature edge, must + // select the sole path that reaches a configured anchor. + let trusted_root = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("trusted cross-sign root", true), + rcgen::KeyPair::generate().expect("trusted root key generation should succeed"), + ) + .expect("trusted root should be self-signable"); + let untrusted_root = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("untrusted cross-sign root", true), + rcgen::KeyPair::generate().expect("untrusted root key generation should succeed"), + ) + .expect("untrusted root should be self-signable"); + let shared_params = generated_certificate_params("shared cross-sign issuer", true); + let shared_key = + rcgen::KeyPair::generate().expect("shared issuer key generation should succeed"); + let trusted_intermediate = shared_params + .signed_by(&shared_key, &trusted_root) + .expect("trusted root should cross-sign the shared issuer key"); + let untrusted_intermediate = shared_params + .signed_by(&shared_key, &untrusted_root) + .expect("untrusted root should cross-sign the shared issuer key"); + let shared_issuer = rcgen::Issuer::from_params(&shared_params, &shared_key); + let leaf = generated_certificate_params("cross-signed leaf", false) + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &shared_issuer, + ) + .expect("shared issuer key should sign the leaf"); + let leaf_metadata = parse_x509_certificate(leaf.der()) + .expect("generated leaf should have supported metadata"); + let key_info = KeyInfo { + sources: vec![KeyInfoSource::X509Data(X509DataInfo { + subject_names: vec![leaf_metadata.subject_dn], + ..X509DataInfo::default() + })], + }; + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + trusted_certs: vec![trusted_root.der().to_vec()], + lookup_certs: vec![ + leaf.der().to_vec(), + untrusted_intermediate.der().to_vec(), + trusted_intermediate.der().to_vec(), + untrusted_root.der().to_vec(), + ], + trust: chain_policy(), + ..KeyResolverConfig::default() + }); + + let resolved = resolver + .resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) + .expect("the sole path to a configured anchor should be selected"); + + assert!(resolved.is_some()); + } + #[test] fn selector_resolved_leaf_disambiguates_same_subject_issuers_by_signature() { // Certificate renewal may leave multiple configured intermediates with @@ -1168,7 +1420,7 @@ mod tests { signing_intermediate.der().to_vec(), ], trusted_certs: vec![root.der().to_vec()], - verify_chains: true, + trust: chain_policy(), ..KeyResolverConfig::default() }); @@ -1197,12 +1449,13 @@ mod tests { certificate_der(include_str!("../../tests/fixtures/keys/ca2cert.pem")), certificate_der(include_str!("../../tests/fixtures/keys/cacert.pem")), ], - verify_chains: true, - check_crls: true, - verification_time: Some( - SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_773_964_800), - ), - max_chain_depth: 3, + trust: crate::policy::KeyTrustPolicy { + check_crls: true, + max_x509_chain_depth: 3, + ..chain_policy_at( + SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_773_964_800), + ) + }, ..KeyResolverConfig::default() }); @@ -1403,27 +1656,39 @@ mod tests { #[test] fn rsa_key_value_rejects_legacy_weak_modulus() { - // Embedded keys must obey the same 2048-bit minimum as certificate and DER keys. - let resolver = DefaultKeyResolver::default(); + // The secure policy rejects legacy RSA-SHA1 independently of whether + // the capable key came from RSAKeyValue, DER, X.509, or KeyName. + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + trust: crate::policy::KeyTrustPolicy { + allow_legacy_rsa_sha1: true, + ..crate::policy::KeyTrustPolicy::default() + }, + ..KeyResolverConfig::default() + }); let error = super::super::VerifyContext::new() .key_resolver(&resolver) .verify(LEGACY_RSA_KEY_VALUE_SIGNATURE) - .expect_err("1024-bit RSAKeyValue must fail closed"); + .expect_err("context policy must override permissive resolver defaults"); assert!(matches!( error, - DsigError::Crypto(super::super::SignatureVerificationError::InvalidKeyDer) + DsigError::Policy(crate::policy::PolicyViolation::Algorithm { + operation: "verification", + .. + }) )); } #[test] - fn legacy_rsa_sha1_policy_applies_to_every_resolved_key_source() { + fn generic_key_resolution_keeps_legacy_capability_source_independent() { let certificate = include_bytes!("../../tests/fixtures/xmldsig/phaos-xmldsig-three/certs/rsa-cert.der") .to_vec(); let (_, parsed_certificate) = X509Certificate::from_der(&certificate) .expect("the Phaos fixture is a DER certificate"); let public_key = parsed_certificate.public_key().raw.to_vec(); + let rsa_public_key = rsa::RsaPublicKey::from_public_key_der(&public_key) + .expect("the Phaos certificate contains an RSA public key"); let certificate_metadata = parse_x509_certificate(&certificate) .expect("the Phaos fixture has supported X.509 metadata"); let named_key = VerificationKey { @@ -1437,7 +1702,13 @@ mod tests { sources: vec![KeyInfoSource::KeyName("legacy".into())], }, KeyInfo { - sources: vec![KeyInfoSource::DerEncodedKeyValue(public_key)], + sources: vec![KeyInfoSource::DerEncodedKeyValue(public_key.clone())], + }, + KeyInfo { + sources: vec![KeyInfoSource::KeyValue(KeyValueInfo::Rsa { + modulus: rsa_public_key.n().to_be_bytes_trimmed_vartime().to_vec(), + exponent: rsa_public_key.e().to_be_bytes_trimmed_vartime().to_vec(), + })], }, KeyInfo { sources: vec![KeyInfoSource::X509Data(X509DataInfo { @@ -1448,18 +1719,16 @@ mod tests { })], }, ]; - let mut config = KeyResolverConfig { - allow_legacy_rsa_sha1: true, + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + named_keys: HashMap::from([("legacy".into(), named_key.clone())]), ..KeyResolverConfig::default() - }; - config.named_keys.insert("legacy".into(), named_key); - let resolver = DefaultKeyResolver::new(config); + }); for key_info in &key_infos { let key = resolver .resolve(Some(key_info), SignatureAlgorithm::RsaSha1) .expect("the key source is valid") - .expect("each source must resolve under the legacy policy"); + .expect("key resolution remains independent from operation policy"); assert!( !key.verify(SignatureAlgorithm::RsaSha1, b"data", &[0; 128]) .expect("the legacy RSA key is structurally valid") @@ -1766,7 +2035,7 @@ mod tests { fn chain_verification_rejects_untrusted_embedded_certificate() { // Enabling chain policy must fail closed when no trust anchor is configured. let resolver = DefaultKeyResolver::new(KeyResolverConfig { - verify_chains: true, + trust: chain_policy(), ..KeyResolverConfig::default() }); let error = super::super::VerifyContext::new() diff --git a/src/xmldsig/mod.rs b/src/xmldsig/mod.rs index be6f22d3..5d5bd1d7 100644 --- a/src/xmldsig/mod.rs +++ b/src/xmldsig/mod.rs @@ -68,7 +68,7 @@ pub mod x509; mod xpath; pub use builder::{ReferenceBuilder, SignatureBuilder, SignatureBuilderError}; -pub use digest::{DigestAlgorithm, compute_digest, constant_time_eq}; +pub use digest::{DigestAlgorithm, compute_digest, compute_digest_with_provider, constant_time_eq}; pub use keys::{ DefaultKeyResolver, HmacSha1VerificationKey, KeyResolutionError, KeyResolverConfig, VerificationKey, diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index 0f89a9a2..32428309 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -1254,6 +1254,76 @@ pub(crate) fn build_x509_certificate_chain_from( Ok(chain) } +/// Enumerate signature-valid certificate paths that terminate at a certificate +/// in the trusted prefix. Trust and certificate policy are intentionally not +/// assigned here; callers must fully validate every returned candidate. +pub(crate) fn build_x509_certificate_paths_to_trusted_prefix( + info: &X509DataInfo, + signing_idx: usize, + trusted_prefix_len: usize, + max_depth: usize, + max_candidate_paths: usize, +) -> Result>, X509ChainBuildError> { + if signing_idx >= info.parsed_certificates.len() + || info.parsed_certificates.len() != info.certificates.len() + || trusted_prefix_len > info.certificates.len() + { + return Err(X509ChainBuildError::InconsistentMetadata); + } + + let mut pending = vec![vec![signing_idx]]; + let mut completed = Vec::new(); + let mut depth_exceeded = false; + while let Some(path) = pending.pop() { + let current_idx = *path + .last() + .expect("candidate path starts with signing certificate index"); + if current_idx < trusted_prefix_len { + completed.push(path); + if completed.len() > max_candidate_paths { + return Err(X509ChainBuildError::AmbiguousIssuer); + } + continue; + } + if path.len() == max_depth { + depth_exceeded = true; + continue; + } + + let current = &info.parsed_certificates[current_idx]; + if distinguished_names_equal(¤t.subject_dn, ¤t.issuer_dn) { + continue; + } + let issuers = info + .parsed_certificates + .iter() + .enumerate() + .filter(|(issuer_idx, issuer)| { + !path.contains(issuer_idx) + && distinguished_names_equal(&issuer.subject_dn, ¤t.issuer_dn) + && certificate_signature_matches( + &info.certificates[current_idx], + &info.certificates[*issuer_idx], + ) + }) + .map(|(issuer_idx, _)| issuer_idx) + .collect::>(); + if pending.len().saturating_add(issuers.len()) > max_candidate_paths { + return Err(X509ChainBuildError::AmbiguousIssuer); + } + for issuer_idx in issuers { + let mut candidate = path.clone(); + candidate.push(issuer_idx); + pending.push(candidate); + } + } + + if completed.is_empty() && depth_exceeded { + return Err(X509ChainBuildError::DepthExceeded); + } + Ok(completed) +} + fn select_x509_signing_certificate(info: &X509DataInfo) -> Result { let has_lookup_identifiers = x509_data_has_lookup_identifiers(info); let mut candidates = Vec::new(); diff --git a/src/xmldsig/sign.rs b/src/xmldsig/sign.rs index 8564db48..8377a1ef 100644 --- a/src/xmldsig/sign.rs +++ b/src/xmldsig/sign.rs @@ -23,7 +23,7 @@ use x509_parser::prelude::FromDer; use crate::c14n::canonicalize; use super::builder::{SignatureBuilder, SignatureBuilderError}; -use super::digest::{DigestAlgorithm, compute_digest}; +use super::digest::DigestAlgorithm; use super::mutation::{ XmlMutationError, append_signature_to_root, fill_key_info, fill_signature_value, fill_signed_info_digest_values, @@ -56,6 +56,10 @@ pub struct ComputedReferenceDigest { /// Errors returned by the XMLDSig signing digest pass. #[derive(Debug, thiserror::Error)] pub enum SigningDigestError { + /// The compiled signing policy rejected an operation input. + #[error("signing policy violation: {0}")] + Policy(#[from] crate::policy::PolicyViolation), + /// The input XML document is not well-formed. #[error("XML parse error: {0}")] XmlParse(#[from] roxmltree::Error), @@ -97,6 +101,10 @@ pub enum SigningDigestError { /// Errors returned by the full XMLDSig signing pipeline. #[derive(Debug, thiserror::Error)] pub enum SigningError { + /// The compiled signing policy rejected an operation input. + #[error("signing policy violation: {0}")] + Policy(#[from] crate::policy::PolicyViolation), + /// Reference digest computation failed. #[error("signing digest pass failed: {0}")] Digest(#[from] SigningDigestError), @@ -130,6 +138,10 @@ pub enum SigningError { #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum SigningKeyError { + /// The selected provider cannot execute the requested operation. + #[error("cryptographic provider error: {0}")] + Provider(#[from] crate::provider::ProviderError), + /// PEM input could not be parsed. #[error("invalid PEM private key")] InvalidKeyPem, @@ -472,7 +484,8 @@ impl SigningKey for EcdsaP384SigningKey { pub struct SignContext<'a> { signing_key: &'a dyn SigningKey, key_info_writer: Option<&'a dyn KeyInfoWriter>, - transform_options: TransformOptions, + policy: crate::policy::SigningPolicy, + provider: &'a dyn crate::provider::CryptoProvider, } impl<'a> SignContext<'a> { @@ -481,10 +494,25 @@ impl<'a> SignContext<'a> { Self { signing_key, key_info_writer: None, - transform_options: TransformOptions::default(), + policy: crate::policy::SigningPolicy::default(), + provider: crate::provider::default_provider(), } } + /// Replace the complete immutable signing policy snapshot. + #[must_use] + pub fn policy(mut self, policy: crate::policy::SigningPolicy) -> Self { + self.policy = policy; + self + } + + /// Select the cryptographic provider for digest and randomness operations. + #[must_use] + pub fn provider(mut self, provider: &'a dyn crate::provider::CryptoProvider) -> Self { + self.provider = provider; + self + } + /// Configure signing to populate the direct `/` placeholder. #[must_use] pub fn key_info_writer(mut self, writer: &'a dyn KeyInfoWriter) -> Self { @@ -499,7 +527,7 @@ impl<'a> SignContext<'a> { /// signatures compatible with libxmlsec1's `` interpretation. #[must_use] pub fn xpath_here_semantics(mut self, semantics: XPathHereSemantics) -> Self { - self.transform_options = self.transform_options.xpath_here_semantics(semantics); + self.policy.xpath_here_semantics = semantics; self } @@ -510,9 +538,41 @@ impl<'a> SignContext<'a> { /// canonicalizes ``, signs those canonical bytes, and fills the /// base64 ``. pub fn sign_template(&self, xml: &str) -> Result { - let with_digests = fill_reference_digest_values_with_options(xml, self.transform_options)?; + self.policy.resources.validate()?; + let transform_options = TransformOptions::default() + .allow_internal_dtd(self.policy.xml.allow_internal_dtd) + .xpath_here_semantics(self.policy.xpath_here_semantics); + let with_digests = fill_reference_digest_values_with_options( + xml, + transform_options, + Some(&self.policy), + self.provider, + )?; let (algorithm, canonical_signed_info) = canonicalize_signed_info(&with_digests)?; - let signature_value = self.signing_key.sign(algorithm, &canonical_signed_info)?; + if canonical_signed_info.len() > self.policy.resources.max_canonicalized_bytes { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: "canonicalized SignedInfo bytes", + maximum: self.policy.resources.max_canonicalized_bytes, + actual: canonical_signed_info.len(), + } + .into()); + } + if !algorithm.signing_allowed() + || self + .policy + .signature_algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(&algorithm)) + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "signing", + algorithm: algorithm.uri().to_string(), + } + .into()); + } + let signature_value = + self.provider + .sign(self.signing_key, algorithm, &canonical_signed_info)?; let signature_b64 = base64::engine::general_purpose::STANDARD.encode(signature_value); let signed = fill_signature_value(&with_digests, &signature_b64)?; if let Some(writer) = self.key_info_writer { @@ -551,17 +611,44 @@ struct SigningReference { pub fn compute_reference_digest_values( xml: &str, ) -> Result, SigningDigestError> { - compute_reference_digest_values_with_options(xml, TransformOptions::default()) + compute_reference_digest_values_with_options( + xml, + TransformOptions::default(), + None, + crate::provider::default_provider(), + ) } fn compute_reference_digest_values_with_options( xml: &str, transform_options: TransformOptions, + policy: Option<&crate::policy::SigningPolicy>, + provider: &dyn crate::provider::CryptoProvider, ) -> Result, SigningDigestError> { let doc = Document::parse(xml)?; let signature = find_signing_signature_node(&doc)?; let signed_info = find_required_child(signature, "SignedInfo")?; let references = parse_signing_references(signed_info)?; + if let Some(policy) = policy { + if references.len() > policy.resources.max_references { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: "signature references", + maximum: policy.resources.max_references, + actual: references.len(), + } + .into()); + } + for reference in &references { + if reference.transforms.len() > policy.resources.max_transforms_per_reference { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: "reference transforms", + maximum: policy.resources.max_transforms_per_reference, + actual: reference.transforms.len(), + } + .into()); + } + } + } let resolver = UriReferenceResolver::new(&doc); let execution_budget = TransformExecutionBudget::default(); @@ -569,6 +656,18 @@ fn compute_reference_digest_values_with_options( .into_iter() .enumerate() .map(|(index, reference)| { + if policy.is_some_and(|policy| { + policy + .digest_algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(&reference.digest_method)) + }) { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "signing", + algorithm: reference.digest_method.uri().to_string(), + } + .into()); + } let initial_data = resolver.dereference_with_budget( &reference.uri, execution_budget.node_set_materialization(), @@ -580,7 +679,8 @@ fn compute_reference_digest_values_with_options( transform_options, &execution_budget, )?; - let digest = compute_digest(reference.digest_method, &pre_digest); + let digest = + super::compute_digest_with_provider(provider, reference.digest_method, &pre_digest); let digest_value = base64::engine::general_purpose::STANDARD.encode(digest); Ok(ComputedReferenceDigest { index, @@ -599,16 +699,24 @@ fn compute_reference_digest_values_with_options( /// and writes the base64 digest into the matching `` in document /// order. pub fn fill_reference_digest_values(xml: &str) -> Result { - fill_reference_digest_values_with_options(xml, TransformOptions::default()) + fill_reference_digest_values_with_options( + xml, + TransformOptions::default(), + None, + crate::provider::default_provider(), + ) } fn fill_reference_digest_values_with_options( xml: &str, transform_options: TransformOptions, + policy: Option<&crate::policy::SigningPolicy>, + provider: &dyn crate::provider::CryptoProvider, ) -> Result { - let digest_values = compute_reference_digest_values_with_options(xml, transform_options)? - .into_iter() - .map(|digest| digest.digest_value); + let digest_values = + compute_reference_digest_values_with_options(xml, transform_options, policy, provider)? + .into_iter() + .map(|digest| digest.digest_value); Ok(fill_signed_info_digest_values(xml, digest_values)?) } diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index b8fd8a35..f81e8a6c 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -18,7 +18,9 @@ use std::collections::{HashMap, HashSet}; use crate::c14n::{canonicalize_bounded, is_output_limit_error}; use crate::hard_limits::{CANONICALIZED_SIGNATURE_DATA_BYTE_CEILING, XML_DOCUMENT_NODE_CEILING}; -use super::digest::{DigestAlgorithm, compute_digest, constant_time_eq}; +#[cfg(test)] +use super::digest::compute_digest; +use super::digest::{DigestAlgorithm, constant_time_eq}; use super::parse::{ KeyInfo, MAX_REFERENCES_PER_SIGNATURE, MAX_X509_DATA_TOTAL_BINARY_LEN, MAX_X509_DECODED_BINARY_LEN, ParseError, Reference, RetrievalMethodTransforms, @@ -42,8 +44,6 @@ use super::whitespace::{is_xml_whitespace_only, normalize_xml_base64_bytes}; const MAX_SIGNATURE_VALUE_LEN: usize = 8192; const MAX_SIGNATURE_VALUE_TEXT_LEN: usize = 65_536; -const MAX_EXTERNAL_RESOURCE_LEN: usize = 8 * 1024 * 1024; -const MAX_EXTERNAL_RESOURCE_TOTAL_LEN: usize = 32 * 1024 * 1024; const MAX_RETRIEVAL_METHOD_COUNT: usize = 64; /// Cryptographic verifier used by [`VerifyContext`]. /// @@ -76,6 +76,20 @@ pub trait KeyResolver { algorithm: SignatureAlgorithm, ) -> Result>, DsigError>; + /// Resolve under the operation's immutable policy snapshot. + /// + /// Implementations that make trust or key-source decisions must override + /// this method. The default preserves source-only custom resolvers whose + /// behavior is independent of policy. + fn resolve_with_policy<'a>( + &'a self, + key_info: Option<&KeyInfo>, + algorithm: SignatureAlgorithm, + _policy: &crate::policy::VerificationPolicy, + ) -> Result>, DsigError> { + self.resolve(key_info, algorithm) + } + /// Return `true` when this resolver consumes document `` material. /// /// The verification pipeline uses this to decide whether malformed @@ -162,12 +176,9 @@ impl Default for UriTypeSet { pub struct VerifyContext<'a> { key: Option<&'a dyn VerifyingKey>, key_resolver: Option<&'a dyn KeyResolver>, - process_manifests: bool, - allowed_uri_types: UriTypeSet, - allowed_retrieval_method_uri_types: UriTypeSet, - allowed_transforms: Option>, + policy: crate::policy::VerificationPolicy, + provider: &'a dyn crate::provider::CryptoProvider, store_pre_digest: bool, - transform_options: TransformOptions, external_resources: Option<&'a HashMap>>, } @@ -184,12 +195,9 @@ impl<'a> VerifyContext<'a> { Self { key: None, key_resolver: None, - process_manifests: false, - allowed_uri_types: UriTypeSet::default(), - allowed_retrieval_method_uri_types: UriTypeSet::default(), - allowed_transforms: None, + policy: crate::policy::VerificationPolicy::default(), + provider: crate::provider::default_provider(), store_pre_digest: false, - transform_options: TransformOptions::default(), external_resources: None, } } @@ -206,6 +214,18 @@ impl<'a> VerifyContext<'a> { self } + /// Replace the complete immutable verification policy snapshot. + pub fn policy(mut self, policy: crate::policy::VerificationPolicy) -> Self { + self.policy = policy; + self + } + + /// Select the cryptographic provider for this verification operation. + pub fn provider(mut self, provider: &'a dyn crate::provider::CryptoProvider) -> Self { + self.provider = provider; + self + } + /// Enable or disable `` processing. /// /// When enabled, references in `` elements that are direct @@ -237,13 +257,13 @@ impl<'a> VerifyContext<'a> { /// Structural/parse errors in Manifest content abort `verify()` and are /// returned as `Err(...)`. pub fn process_manifests(mut self, enabled: bool) -> Self { - self.process_manifests = enabled; + self.policy.process_manifests = enabled; self } /// Restrict allowed reference URI classes. pub fn allowed_uri_types(mut self, types: UriTypeSet) -> Self { - self.allowed_uri_types = types; + self.policy.reference_uri_types = types; self } @@ -254,7 +274,7 @@ impl<'a> VerifyContext<'a> { /// Same-document retrieval is enabled by default; external retrieval requires /// an explicit opt-in and still uses only caller-supplied resources. pub fn allowed_retrieval_method_uri_types(mut self, types: UriTypeSet) -> Self { - self.allowed_retrieval_method_uri_types = types; + self.policy.retrieval_uri_types = types; self } @@ -271,7 +291,7 @@ impl<'a> VerifyContext<'a> { /// Allow bounded internal DTD declarations while keeping external entity /// resolution disabled. This is off by default. pub fn allow_internal_dtd(mut self, enabled: bool) -> Self { - self.transform_options = self.transform_options.allow_internal_dtd(enabled); + self.policy.xml.allow_internal_dtd = enabled; self } @@ -290,7 +310,7 @@ impl<'a> VerifyContext<'a> { I: IntoIterator, S: Into, { - self.allowed_transforms = Some(transforms.into_iter().map(Into::into).collect()); + self.policy.transforms = Some(transforms.into_iter().map(Into::into).collect()); self } @@ -312,12 +332,18 @@ impl<'a> VerifyContext<'a> { /// Use [`XPathHereSemantics::XmlSecLegacy`] only for documents known to /// have been generated with libxmlsec1's `` interpretation. pub fn xpath_here_semantics(mut self, semantics: XPathHereSemantics) -> Self { - self.transform_options = self.transform_options.xpath_here_semantics(semantics); + self.policy.xpath_here_semantics = semantics; self } fn allowed_transform_uris(&self) -> Option<&HashSet> { - self.allowed_transforms.as_ref() + self.policy.transforms.as_ref() + } + + fn transform_options(&self) -> TransformOptions { + TransformOptions::default() + .allow_internal_dtd(self.policy.xml.allow_internal_dtd) + .xpath_here_semantics(self.policy.xpath_here_semantics) } /// Verify one XMLDSig signature using this context. @@ -465,6 +491,7 @@ pub fn process_reference( transform_options: TransformOptions::default(), transform_budget: &execution_budget, canonicalized_data_budget: &canonicalized_data_budget, + provider: crate::provider::default_provider(), }; process_reference_with_options( reference, @@ -522,6 +549,7 @@ struct ReferenceExecutionContext<'a> { transform_options: TransformOptions, transform_budget: &'a TransformExecutionBudget, canonicalized_data_budget: &'a CanonicalizedDataBudget, + provider: &'a dyn crate::provider::CryptoProvider, } struct CanonicalizedDataBudget { @@ -554,7 +582,6 @@ impl CanonicalizedDataBudget { Ok(()) } - #[cfg(test)] fn with_limit(max_bytes: usize) -> Self { Self { remaining: Cell::new(max_bytes), @@ -607,7 +634,11 @@ fn process_reference_with_options( .map_err(ReferenceProcessingError::Transform)?; // 3. Compute digest - let computed_digest = compute_digest(reference.digest_method, &pre_digest_bytes); + let computed_digest = super::compute_digest_with_provider( + execution.provider, + reference.digest_method, + &pre_digest_bytes, + ); // 4. Compare with stored DigestValue (constant-time) let status = if constant_time_eq(&computed_digest, &reference.digest_value) { @@ -661,6 +692,7 @@ pub fn process_all_references( transform_options: TransformOptions::default(), transform_budget: &execution_budget, canonicalized_data_budget: &canonicalized_data_budget, + provider: crate::provider::default_provider(), }; process_all_references_with_options(references, resolver, signature_node, &execution) } @@ -759,6 +791,14 @@ pub struct VerifyResult { #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum DsigError { + /// The compiled verification policy rejected an operation input. + #[error("verification policy violation: {0}")] + Policy(#[from] crate::policy::PolicyViolation), + + /// The selected provider cannot execute the requested operation. + #[error("cryptographic provider error: {0}")] + Provider(#[from] crate::provider::ProviderError), + /// XML parsing failed. #[error("XML parse error: {0}")] XmlParse(#[from] roxmltree::Error), @@ -884,11 +924,13 @@ fn verify_signature_with_context( xml: &str, ctx: &VerifyContext<'_>, ) -> Result { + ctx.policy.validate()?; let doc = Document::parse_with_options( xml, roxmltree::ParsingOptions { - allow_dtd: ctx.transform_options.internal_dtd_allowed(), - nodes_limit: XML_DOCUMENT_NODE_CEILING, + allow_dtd: ctx.policy.xml.allow_internal_dtd, + nodes_limit: u32::try_from(ctx.policy.resources.max_xml_nodes) + .unwrap_or(XML_DOCUMENT_NODE_CEILING), entity_resolver: None, }, )?; @@ -931,19 +973,56 @@ fn verify_signature_with_context( let mut xpath_parse_budget = XPathSignatureParseBudget::default(); let signed_info = parse_signed_info_with_xpath_budget(signed_info_node, &mut xpath_parse_budget)?; + if signed_info.references.len() > ctx.policy.resources.max_references { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: "signature references", + maximum: ctx.policy.resources.max_references, + actual: signed_info.references.len(), + } + .into()); + } + for reference in &signed_info.references { + if reference.transforms.len() > ctx.policy.resources.max_transforms_per_reference { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: "reference transforms", + maximum: ctx.policy.resources.max_transforms_per_reference, + actual: reference.transforms.len(), + } + .into()); + } + } + ctx.policy + .check_signature_algorithm(signed_info.signature_method)?; + for reference in &signed_info.references { + if ctx + .policy + .digest_algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(&reference.digest_method)) + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "verification", + algorithm: reference.digest_method.uri().to_string(), + } + .into()); + } + } enforce_reference_policies( &signed_info.references, - ctx.allowed_uri_types, + ctx.policy.reference_uri_types, ctx.allowed_transform_uris(), )?; if let Some(resources) = ctx.external_resources { let mut total = 0usize; for bytes in resources.values() { - if bytes.len() > MAX_EXTERNAL_RESOURCE_LEN { - return Err(SignatureVerificationPipelineError::InvalidStructure { - reason: "external resource exceeds maximum allowed length", - }); + if bytes.len() > ctx.policy.resources.max_external_resource_bytes { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: "external resource bytes", + maximum: ctx.policy.resources.max_external_resource_bytes, + actual: bytes.len(), + } + .into()); } total = total.checked_add(bytes.len()).ok_or( SignatureVerificationPipelineError::InvalidStructure { @@ -951,10 +1030,13 @@ fn verify_signature_with_context( }, )?; } - if total > MAX_EXTERNAL_RESOURCE_TOTAL_LEN { - return Err(SignatureVerificationPipelineError::InvalidStructure { - reason: "external resources exceed maximum aggregate length", - }); + if total > ctx.policy.resources.max_external_resource_total_bytes { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: "aggregate external resource bytes", + maximum: ctx.policy.resources.max_external_resource_total_bytes, + actual: total, + } + .into()); } } let resolver = match ctx.external_resources { @@ -966,18 +1048,20 @@ fn verify_signature_with_context( info, &resolver, ctx.external_resources, - ctx.allowed_retrieval_method_uri_types, + ctx.policy.retrieval_uri_types, )? } else { RetrievalMaterialization::default() }; let execution_budget = TransformExecutionBudget::default(); - let canonicalized_data_budget = CanonicalizedDataBudget::default(); + let canonicalized_data_budget = + CanonicalizedDataBudget::with_limit(ctx.policy.resources.max_canonicalized_bytes); let execution = ReferenceExecutionContext { store_pre_digest: ctx.store_pre_digest, - transform_options: ctx.transform_options, + transform_options: ctx.transform_options(), transform_budget: &execution_budget, canonicalized_data_budget: &canonicalized_data_budget, + provider: ctx.provider, }; let references = process_all_references_with_options( &signed_info.references, @@ -1048,7 +1132,8 @@ fn verify_signature_with_context( }); }; let verifier = resolved_key.as_ref(); - let signature_valid = verifier.verify( + let signature_valid = ctx.provider.verify( + verifier, signed_info.signature_method, &canonical_signed_info, &signature_value, @@ -1067,7 +1152,7 @@ fn verify_signature_with_context( }); } - let manifest_references = if ctx.process_manifests { + let manifest_references = if ctx.policy.process_manifests { let signed_info_reference_nodes = collect_authenticated_signed_info_reference_nodes(&signed_info.references, &resolver); let remaining_reference_capacity = MAX_REFERENCES_PER_SIGNATURE @@ -1335,7 +1420,7 @@ fn process_manifest_references( for (index, reference, reference_node_id) in &manifest_references { match enforce_reference_policies( std::slice::from_ref(reference), - ctx.allowed_uri_types, + ctx.policy.reference_uri_types, ctx.allowed_transform_uris(), ) { Ok(()) => {} @@ -1567,7 +1652,7 @@ fn resolve_verifying_key<'k>( return Ok(Some(ResolvedVerifyingKey::Borrowed(key))); } if let Some(resolver) = ctx.key_resolver { - let resolved = resolver.resolve(key_info, algorithm)?; + let resolved = resolver.resolve_with_policy(key_info, algorithm, &ctx.policy)?; return Ok(resolved.map(ResolvedVerifyingKey::Owned)); } Ok(None) @@ -3869,6 +3954,7 @@ mod tests { transform_options: TransformOptions::default(), transform_budget: &transform_budget, canonicalized_data_budget: &canonicalized_data_budget, + provider: crate::provider::default_provider(), }; let error = process_all_references_with_options( @@ -4233,6 +4319,7 @@ mod tests { transform_options: TransformOptions::default(), transform_budget: &budget, canonicalized_data_budget: &canonicalized_data_budget, + provider: crate::provider::default_provider(), }; let error = process_all_references_with_options( @@ -4271,6 +4358,7 @@ mod tests { transform_options: TransformOptions::default(), transform_budget: &budget, canonicalized_data_budget: &canonicalized_data_budget, + provider: crate::provider::default_provider(), }; let error = process_all_references_with_options( diff --git a/src/xmlenc/decrypt.rs b/src/xmlenc/decrypt.rs index 7deb52d3..22406161 100644 --- a/src/xmlenc/decrypt.rs +++ b/src/xmlenc/decrypt.rs @@ -2,29 +2,16 @@ use std::fmt; -use aes::{ - Aes128, Aes256, - cipher::{BlockModeDecrypt, KeyIvInit, block_padding::NoPadding}, -}; -use aes_gcm::{ - Aes128Gcm, Aes256Gcm, Nonce, - aead::{AeadInOut, KeyInit}, -}; -use aes_kw::{KwAes128, KwAes256}; use base64::{Engine as _, engine::general_purpose::STANDARD}; -use cbc::Decryptor; -use getrandom::SysRng; use roxmltree::{Document, ParsingOptions}; -use rsa::{Oaep, RsaPrivateKey, traits::PaddingScheme}; -use sha1::Sha1; -use sha2::{Sha256, Sha384, Sha512}; +use rsa::RsaPrivateKey; use super::parse::parse_encrypted_data_node; use super::types::XMLENC_NS; use super::{ DataEncryptionAlgorithm, DecryptedContent, EncryptedData, EncryptedDataType, EncryptedKey, - KeyTransportAlgorithm, KeyWrapAlgorithm, XmlEncError, has_single_element_with_boundary_trivia, - parse_encrypted_data, + KeyTransportAlgorithm, KeyWrapAlgorithm, OaepDigestAlgorithm, RsaOaepParameters, XmlEncError, + has_single_element_with_boundary_trivia, parse_encrypted_data, }; /// Supplies a content-encryption key for parsed XMLEnc data. @@ -32,6 +19,7 @@ pub trait DecryptionKeyResolver { /// Resolve the symmetric key for `algorithm`, optionally unwrapping `encrypted_key`. fn resolve_key( &self, + provider: &dyn crate::provider::CryptoProvider, algorithm: DataEncryptionAlgorithm, encrypted_key: Option<&EncryptedKey>, ) -> Result, XmlEncError>; @@ -49,6 +37,118 @@ pub struct DocumentDecryptionOptions<'a> { pub allow_dtd: bool, } +/// Immutable XMLEnc decryption operation context. +pub struct DecryptContext<'a> { + resolver: &'a dyn DecryptionKeyResolver, + policy: crate::policy::DecryptionPolicy, + provider: &'a dyn crate::provider::CryptoProvider, +} + +impl<'a> DecryptContext<'a> { + /// Create a context with compatibility defaults and the RustCrypto provider. + pub fn new(resolver: &'a dyn DecryptionKeyResolver) -> Self { + Self { + resolver, + policy: crate::policy::DecryptionPolicy::default(), + provider: crate::provider::default_provider(), + } + } + + /// Replace the complete immutable decryption policy snapshot. + pub fn policy(mut self, policy: crate::policy::DecryptionPolicy) -> Self { + self.policy = policy; + self + } + + /// Select the cryptographic provider for this decryption operation. + pub fn provider(mut self, provider: &'a dyn crate::provider::CryptoProvider) -> Self { + self.provider = provider; + self + } + + /// Parse and decrypt a standalone `EncryptedData` XML fragment. + pub fn decrypt(&self, xml: &str) -> Result { + let encrypted = parse_encrypted_data(xml)?; + self.decrypt_data(&encrypted) + } + + /// Decrypt an already parsed `EncryptedData` value. + pub fn decrypt_data(&self, encrypted: &EncryptedData) -> Result { + self.policy.resources.validate()?; + let algorithm = DataEncryptionAlgorithm::from_uri(&encrypted.encryption_method.algorithm)?; + if self + .policy + .data_algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(&algorithm)) + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "decryption", + algorithm: encrypted.encryption_method.algorithm.clone(), + } + .into()); + } + for encrypted_key in &encrypted.encrypted_keys { + let uri = &encrypted_key.encryption_method.algorithm; + if let Ok(transport) = KeyTransportAlgorithm::from_uri(uri) { + if self + .policy + .key_transport_algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(&transport)) + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "decryption", + algorithm: uri.clone(), + } + .into()); + } + } else if let Ok(wrap) = KeyWrapAlgorithm::from_uri(uri) + && self + .policy + .key_wrap_algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(&wrap)) + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "decryption", + algorithm: uri.clone(), + } + .into()); + } + } + let key = resolve_content_key( + self.provider, + algorithm, + &encrypted.encrypted_keys, + self.resolver, + )?; + validate_key_len(algorithm, &key)?; + let ciphertext = STANDARD + .decode(&encrypted.cipher_data.value) + .map_err(|error| XmlEncError::Base64(error.to_string()))?; + let plaintext = self + .provider + .decrypt_data(algorithm, &key, &ciphertext) + .map_err(|error| map_data_decryption_error(algorithm, ciphertext.len(), error))?; + match encrypted.encrypted_type.as_ref() { + Some(EncryptedDataType::Element | EncryptedDataType::Content) => { + Ok(DecryptedContent::Xml(String::from_utf8(plaintext)?)) + } + Some(EncryptedDataType::Other(_)) | None => Ok(DecryptedContent::Bytes(plaintext)), + } + } + + /// Decrypt and replace one selected `EncryptedData` in a caller-owned document. + pub fn decrypt_document( + &self, + xml: &str, + encrypted_data_id: Option<&str>, + ) -> Result { + decrypt_document_with_context(xml, encrypted_data_id, self) + } +} + /// Resolver for direct, pre-shared AES content keys. #[derive(Clone)] pub struct SymmetricKeyDecryptor { @@ -74,6 +174,7 @@ impl SymmetricKeyDecryptor { impl DecryptionKeyResolver for SymmetricKeyDecryptor { fn resolve_key( &self, + _provider: &dyn crate::provider::CryptoProvider, algorithm: DataEncryptionAlgorithm, _encrypted_key: Option<&EncryptedKey>, ) -> Result, XmlEncError> { @@ -113,6 +214,7 @@ impl KekDecryptor { impl DecryptionKeyResolver for KekDecryptor { fn resolve_key( &self, + provider: &dyn crate::provider::CryptoProvider, algorithm: DataEncryptionAlgorithm, encrypted_key: Option<&EncryptedKey>, ) -> Result, XmlEncError> { @@ -122,25 +224,24 @@ impl DecryptionKeyResolver for KekDecryptor { .map_err(|error| XmlEncError::Base64(error.to_string()))?; let wrap_algorithm = KeyWrapAlgorithm::from_uri(&encrypted_key.encryption_method.algorithm)?; - if self.kek.len() != wrap_algorithm.key_len() { - return Err(XmlEncError::InvalidKekSize { - algorithm: wrap_algorithm, - expected: wrap_algorithm.key_len(), - actual: self.kek.len(), - }); - } - let mut output = vec![0_u8; wrapped.len().saturating_sub(8)]; - let key = match wrap_algorithm { - KeyWrapAlgorithm::AesKw128 => KwAes128::new_from_slice(&self.kek) - .map_err(|_| invalid_kek_size(wrap_algorithm, self.kek.len()))? - .unwrap_key(&wrapped, &mut output), - KeyWrapAlgorithm::AesKw256 => KwAes256::new_from_slice(&self.kek) - .map_err(|_| invalid_kek_size(wrap_algorithm, self.kek.len()))? - .unwrap_key(&wrapped, &mut output), - } - .map_err(|_| XmlEncError::KeyWrapIntegrity)?; - validate_key_len(algorithm, key)?; - Ok(key.to_vec()) + let key = provider + .unwrap_key(wrap_algorithm, &self.kek, &wrapped) + .map_err(|error| match error { + crate::provider::ProviderError::InvalidKeySize { expected, actual } => { + XmlEncError::InvalidKekSize { + algorithm: wrap_algorithm, + expected, + actual, + } + } + crate::provider::ProviderError::AuthenticationFailed + | crate::provider::ProviderError::InvalidInput("AES key wrap framing") => { + XmlEncError::KeyWrapIntegrity + } + error => XmlEncError::Provider(error), + })?; + validate_key_len(algorithm, &key)?; + Ok(key) } } @@ -154,6 +255,7 @@ impl PrivateKeyDecryptor { impl DecryptionKeyResolver for PrivateKeyDecryptor { fn resolve_key( &self, + provider: &dyn crate::provider::CryptoProvider, algorithm: DataEncryptionAlgorithm, encrypted_key: Option<&EncryptedKey>, ) -> Result, XmlEncError> { @@ -170,11 +272,13 @@ impl DecryptionKeyResolver for PrivateKeyDecryptor { KeyTransportAlgorithm::from_uri(&encrypted_key.encryption_method.algorithm)?; let key = match transport { KeyTransportAlgorithm::RsaOaepMgf1p => self.decrypt_oaep_mgf1p( + provider, encrypted_key.encryption_method.oaep_digest.as_deref(), label, &wrapped, ), KeyTransportAlgorithm::RsaOaep11 => self.decrypt_oaep11( + provider, encrypted_key.encryption_method.oaep_digest.as_deref(), encrypted_key.encryption_method.mgf_algorithm.as_deref(), label, @@ -189,112 +293,79 @@ impl DecryptionKeyResolver for PrivateKeyDecryptor { impl PrivateKeyDecryptor { fn decrypt_oaep_mgf1p( &self, + provider: &dyn crate::provider::CryptoProvider, digest: Option<&str>, label: Vec, wrapped: &[u8], ) -> Result, XmlEncError> { - // Passing SysRng through PaddingScheme keeps private-key blinding while - // preserving operating-system RNG failures as typed errors. - match digest.unwrap_or("http://www.w3.org/2000/09/xmldsig#sha1") { - "http://www.w3.org/2000/09/xmldsig#sha1" => Oaep::::new_with_label(label) - .decrypt(Some(&mut SysRng), &self.key, wrapped) - .map_err(rsa_error), - "http://www.w3.org/2001/04/xmlenc#sha256" => { - Oaep::::new_with_mgf_hash_and_label(label) - .decrypt(Some(&mut SysRng), &self.key, wrapped) - .map_err(rsa_error) - } - "http://www.w3.org/2001/04/xmlenc#sha384" - | "http://www.w3.org/2001/04/xmldsig-more#sha384" => { - Oaep::::new_with_mgf_hash_and_label(label) - .decrypt(Some(&mut SysRng), &self.key, wrapped) - .map_err(rsa_error) - } - "http://www.w3.org/2001/04/xmlenc#sha512" => { - Oaep::::new_with_mgf_hash_and_label(label) - .decrypt(Some(&mut SysRng), &self.key, wrapped) - .map_err(rsa_error) - } - unsupported => Err(XmlEncError::UnsupportedAlgorithm(unsupported.to_owned())), - } + let parameters = RsaOaepParameters { + algorithm: KeyTransportAlgorithm::RsaOaepMgf1p, + digest: parse_oaep_digest(digest)?, + mgf_digest: OaepDigestAlgorithm::Sha1, + label, + }; + recover_rsa_oaep(provider, &self.key, ¶meters, wrapped) } fn decrypt_oaep11( &self, + provider: &dyn crate::provider::CryptoProvider, digest: Option<&str>, mgf: Option<&str>, label: Vec, wrapped: &[u8], ) -> Result, XmlEncError> { - const SHA1: &str = "http://www.w3.org/2000/09/xmldsig#sha1"; - const SHA256: &str = "http://www.w3.org/2001/04/xmlenc#sha256"; - const SHA384: &str = "http://www.w3.org/2001/04/xmlenc#sha384"; - const SHA384_COMPAT: &str = "http://www.w3.org/2001/04/xmldsig-more#sha384"; - const SHA512: &str = "http://www.w3.org/2001/04/xmlenc#sha512"; - const MGF1_SHA1: &str = "http://www.w3.org/2009/xmlenc11#mgf1sha1"; - const MGF1_SHA256: &str = "http://www.w3.org/2009/xmlenc11#mgf1sha256"; - const MGF1_SHA384: &str = "http://www.w3.org/2009/xmlenc11#mgf1sha384"; - const MGF1_SHA512: &str = "http://www.w3.org/2009/xmlenc11#mgf1sha512"; - - macro_rules! decrypt_with { - ($digest:ty, $mgf:ty) => { - Oaep::<$digest, $mgf>::new_with_mgf_hash_and_label(label) - .decrypt(Some(&mut SysRng), &self.key, wrapped) - .map_err(rsa_error) - }; - } - - let digest = match digest.unwrap_or(SHA1) { - SHA384_COMPAT => SHA384, - digest => digest, + let parameters = RsaOaepParameters { + algorithm: KeyTransportAlgorithm::RsaOaep11, + digest: parse_oaep_digest(digest)?, + mgf_digest: parse_oaep_mgf_digest(mgf)?, + label, }; - match (digest, mgf.unwrap_or(MGF1_SHA1)) { - (SHA1, MGF1_SHA1) => decrypt_with!(Sha1, Sha1), - (SHA1, MGF1_SHA256) => decrypt_with!(Sha1, Sha256), - (SHA1, MGF1_SHA384) => decrypt_with!(Sha1, Sha384), - (SHA1, MGF1_SHA512) => decrypt_with!(Sha1, Sha512), - (SHA256, MGF1_SHA1) => decrypt_with!(Sha256, Sha1), - (SHA256, MGF1_SHA256) => decrypt_with!(Sha256, Sha256), - (SHA256, MGF1_SHA384) => decrypt_with!(Sha256, Sha384), - (SHA256, MGF1_SHA512) => decrypt_with!(Sha256, Sha512), - (SHA384, MGF1_SHA1) => decrypt_with!(Sha384, Sha1), - (SHA384, MGF1_SHA256) => decrypt_with!(Sha384, Sha256), - (SHA384, MGF1_SHA384) => decrypt_with!(Sha384, Sha384), - (SHA384, MGF1_SHA512) => decrypt_with!(Sha384, Sha512), - (SHA512, MGF1_SHA1) => decrypt_with!(Sha512, Sha1), - (SHA512, MGF1_SHA256) => decrypt_with!(Sha512, Sha256), - (SHA512, MGF1_SHA384) => decrypt_with!(Sha512, Sha384), - (SHA512, MGF1_SHA512) => decrypt_with!(Sha512, Sha512), - (unsupported, MGF1_SHA1 | MGF1_SHA256 | MGF1_SHA384 | MGF1_SHA512) => { - Err(XmlEncError::UnsupportedAlgorithm(unsupported.to_owned())) - } - (_, unsupported) => Err(XmlEncError::UnsupportedAlgorithm(unsupported.to_owned())), - } + recover_rsa_oaep(provider, &self.key, ¶meters, wrapped) } } -fn rsa_error(error: rsa::Error) -> XmlEncError { - match error { - rsa::Error::Rng => XmlEncError::Rng("RSA-OAEP blinding failed".into()), - error => XmlEncError::Rsa(error.to_string()), +fn parse_oaep_digest(uri: Option<&str>) -> Result { + match uri.unwrap_or("http://www.w3.org/2000/09/xmldsig#sha1") { + "http://www.w3.org/2000/09/xmldsig#sha1" => Ok(OaepDigestAlgorithm::Sha1), + "http://www.w3.org/2001/04/xmlenc#sha256" => Ok(OaepDigestAlgorithm::Sha256), + "http://www.w3.org/2001/04/xmlenc#sha384" + | "http://www.w3.org/2001/04/xmldsig-more#sha384" => Ok(OaepDigestAlgorithm::Sha384), + "http://www.w3.org/2001/04/xmlenc#sha512" => Ok(OaepDigestAlgorithm::Sha512), + unsupported => Err(XmlEncError::UnsupportedAlgorithm(unsupported.to_owned())), } } -fn invalid_kek_size(algorithm: KeyWrapAlgorithm, actual: usize) -> XmlEncError { - XmlEncError::InvalidKekSize { - algorithm, - expected: algorithm.key_len(), - actual, +fn parse_oaep_mgf_digest(uri: Option<&str>) -> Result { + match uri.unwrap_or("http://www.w3.org/2009/xmlenc11#mgf1sha1") { + "http://www.w3.org/2009/xmlenc11#mgf1sha1" => Ok(OaepDigestAlgorithm::Sha1), + "http://www.w3.org/2009/xmlenc11#mgf1sha256" => Ok(OaepDigestAlgorithm::Sha256), + "http://www.w3.org/2009/xmlenc11#mgf1sha384" => Ok(OaepDigestAlgorithm::Sha384), + "http://www.w3.org/2009/xmlenc11#mgf1sha512" => Ok(OaepDigestAlgorithm::Sha512), + unsupported => Err(XmlEncError::UnsupportedAlgorithm(unsupported.to_owned())), } } +fn recover_rsa_oaep( + provider: &dyn crate::provider::CryptoProvider, + key: &RsaPrivateKey, + parameters: &RsaOaepParameters, + wrapped: &[u8], +) -> Result, XmlEncError> { + provider + .recover_key(key, parameters, wrapped) + .map_err(|error| match error { + crate::provider::ProviderError::Random(message) => XmlEncError::Rng(message), + error => XmlEncError::Rsa(error.to_string()), + }) +} + /// Parse and decrypt a standalone `EncryptedData` XML fragment. pub fn decrypt( xml: &str, resolver: &dyn DecryptionKeyResolver, ) -> Result { - let encrypted = parse_encrypted_data(xml)?; - decrypt_data(&encrypted, resolver) + DecryptContext::new(resolver).decrypt(xml) } /// Decrypt and replace one `EncryptedData` element in a caller-owned XML document. @@ -323,18 +394,28 @@ pub fn decrypt_document_with_options( xml: &str, options: DocumentDecryptionOptions<'_>, resolver: &dyn DecryptionKeyResolver, +) -> Result { + let mut policy = crate::policy::DecryptionPolicy::default(); + policy.xml.allow_internal_dtd = options.allow_dtd; + DecryptContext::new(resolver) + .policy(policy) + .decrypt_document(xml, options.encrypted_data_id) +} + +fn decrypt_document_with_context( + xml: &str, + encrypted_data_id: Option<&str>, + context: &DecryptContext<'_>, ) -> Result { let parsing_options = || ParsingOptions { - allow_dtd: options.allow_dtd, + allow_dtd: context.policy.xml.allow_internal_dtd, entity_resolver: None, ..ParsingOptions::default() }; let document = Document::parse_with_options(xml, parsing_options())?; let mut matches = document.descendants().filter(|node| { node.has_tag_name((XMLENC_NS, "EncryptedData")) - && options - .encrypted_data_id - .is_none_or(|id| node.attribute("Id") == Some(id)) + && encrypted_data_id.is_none_or(|id| node.attribute("Id") == Some(id)) }); let selected = matches.next().ok_or(XmlEncError::EncryptedDataNotFound)?; if matches.next().is_some() { @@ -343,7 +424,7 @@ pub fn decrypt_document_with_options( let range = selected.range(); let encrypted = parse_encrypted_data_node(selected)?; - let DecryptedContent::Xml(plaintext) = decrypt_data(&encrypted, resolver)? else { + let DecryptedContent::Xml(plaintext) = context.decrypt_data(&encrypted)? else { return Err(XmlEncError::ReplacementRequiresXml); }; @@ -353,7 +434,7 @@ pub fn decrypt_document_with_options( range.end, &plaintext, encrypted.encrypted_type.as_ref(), - options.allow_dtd, + context.policy.xml.allow_internal_dtd, )?; let mut output = String::with_capacity(xml.len() - range.len() + plaintext.len()); @@ -429,27 +510,16 @@ pub fn decrypt_data( encrypted: &EncryptedData, resolver: &dyn DecryptionKeyResolver, ) -> Result { - let algorithm = DataEncryptionAlgorithm::from_uri(&encrypted.encryption_method.algorithm)?; - let key = resolve_content_key(algorithm, &encrypted.encrypted_keys, resolver)?; - validate_key_len(algorithm, &key)?; - let ciphertext = STANDARD - .decode(&encrypted.cipher_data.value) - .map_err(|error| XmlEncError::Base64(error.to_string()))?; - let plaintext = decrypt_content(algorithm, &key, &ciphertext)?; - match encrypted.encrypted_type.as_ref() { - Some(EncryptedDataType::Element | EncryptedDataType::Content) => { - Ok(DecryptedContent::Xml(String::from_utf8(plaintext)?)) - } - Some(EncryptedDataType::Other(_)) | None => Ok(DecryptedContent::Bytes(plaintext)), - } + DecryptContext::new(resolver).decrypt_data(encrypted) } fn resolve_content_key( + provider: &dyn crate::provider::CryptoProvider, algorithm: DataEncryptionAlgorithm, encrypted_keys: &[EncryptedKey], resolver: &dyn DecryptionKeyResolver, ) -> Result, XmlEncError> { - match resolver.resolve_key(algorithm, None) { + match resolver.resolve_key(provider, algorithm, None) { Ok(key) => return Ok(key), Err(XmlEncError::KeyNotFound) => {} Err(error) => return Err(error), @@ -457,7 +527,7 @@ fn resolve_content_key( let mut last_error = None; for encrypted_key in encrypted_keys { - match resolver.resolve_key(algorithm, Some(encrypted_key)) { + match resolver.resolve_key(provider, algorithm, Some(encrypted_key)) { Ok(key) => return Ok(key), Err(error) => last_error = Some(error), } @@ -477,100 +547,47 @@ fn validate_key_len(algorithm: DataEncryptionAlgorithm, key: &[u8]) -> Result<() } } -fn decrypt_content( +fn map_data_decryption_error( algorithm: DataEncryptionAlgorithm, - key: &[u8], - ciphertext: &[u8], -) -> Result, XmlEncError> { - match algorithm { - DataEncryptionAlgorithm::Aes128Gcm => decrypt_gcm::(key, ciphertext), - DataEncryptionAlgorithm::Aes256Gcm => decrypt_gcm::(key, ciphertext), - DataEncryptionAlgorithm::Aes128Cbc => decrypt_cbc_128(key, ciphertext), - DataEncryptionAlgorithm::Aes256Cbc => decrypt_cbc_256(key, ciphertext), - } -} - -fn decrypt_gcm(key: &[u8], ciphertext: &[u8]) -> Result, XmlEncError> -where - C: AeadInOut + KeyInit, -{ - const NONCE_LEN: usize = 12; - const TAG_LEN: usize = 16; - if ciphertext.len() < NONCE_LEN + TAG_LEN { - return Err(XmlEncError::DataTooShort { + ciphertext_len: usize, + error: crate::provider::ProviderError, +) -> XmlEncError { + use crate::provider::ProviderError; + + match (algorithm, error) { + ( + DataEncryptionAlgorithm::Aes128Gcm | DataEncryptionAlgorithm::Aes256Gcm, + ProviderError::AuthenticationFailed, + ) => XmlEncError::AeadAuthenticationFailed, + ( + DataEncryptionAlgorithm::Aes128Gcm | DataEncryptionAlgorithm::Aes256Gcm, + ProviderError::InvalidInput("AES-GCM framing"), + ) => XmlEncError::DataTooShort { algorithm: "AES-GCM", - minimum: NONCE_LEN + TAG_LEN, - actual: ciphertext.len(), - }); - } - let (nonce, encrypted) = ciphertext.split_at(NONCE_LEN); - let cipher = C::new_from_slice(key).map_err(|_| XmlEncError::AeadAuthenticationFailed)?; - let mut output = encrypted.to_vec(); - let nonce = Nonce::try_from(nonce).map_err(|_| XmlEncError::AeadAuthenticationFailed)?; - cipher - .decrypt_in_place(&nonce, b"", &mut output) - .map_err(|_| XmlEncError::AeadAuthenticationFailed)?; - Ok(output) -} - -fn cbc_input(ciphertext: &[u8]) -> Result<(&[u8], &[u8]), XmlEncError> { - const BLOCK: usize = 16; - if ciphertext.len() < BLOCK * 2 { - return Err(XmlEncError::DataTooShort { + minimum: 28, + actual: ciphertext_len, + }, + ( + DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc, + ProviderError::InvalidInput("AES-CBC framing"), + ) if ciphertext_len < 32 => XmlEncError::DataTooShort { algorithm: "AES-CBC", - minimum: BLOCK * 2, - actual: ciphertext.len(), - }); - } - let (iv, encrypted) = ciphertext.split_at(BLOCK); - if encrypted.len() % BLOCK != 0 { - return Err(XmlEncError::InvalidCbcCiphertextLength(encrypted.len())); - } - Ok((iv, encrypted)) -} - -fn remove_cbc_padding(plaintext: &[u8]) -> Result, XmlEncError> { - const BLOCK: usize = 16; - let pad_len = *plaintext.last().ok_or(XmlEncError::DataTooShort { - algorithm: "AES-CBC", - minimum: 1, - actual: 0, - })?; - if pad_len == 0 || usize::from(pad_len) > BLOCK { - return Err(XmlEncError::InvalidPadding { - pad_len, - block_size: BLOCK, - }); + minimum: 32, + actual: ciphertext_len, + }, + ( + DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc, + ProviderError::InvalidInput("AES-CBC framing"), + ) => XmlEncError::InvalidCbcCiphertextLength(ciphertext_len.saturating_sub(16)), + ( + DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc, + ProviderError::InvalidInput("XMLEnc CBC padding"), + ) => XmlEncError::InvalidPadding { + pad_len: 0, + block_size: 16, + }, + (_, error) => XmlEncError::Provider(error), } - Ok(plaintext[..plaintext.len() - usize::from(pad_len)].to_vec()) -} - -fn decrypt_cbc_128(key: &[u8], ciphertext: &[u8]) -> Result, XmlEncError> { - let (iv, encrypted) = cbc_input(ciphertext)?; - let mut output = encrypted.to_vec(); - let plaintext = Decryptor::::new_from_slices(key, iv) - .map_err(|_| XmlEncError::InvalidKeySize { - algorithm: DataEncryptionAlgorithm::Aes128Cbc, - expected: 16, - actual: key.len(), - })? - .decrypt_padded::(&mut output) - .map_err(|_| XmlEncError::InvalidCbcCiphertextLength(encrypted.len()))?; - remove_cbc_padding(plaintext) -} - -fn decrypt_cbc_256(key: &[u8], ciphertext: &[u8]) -> Result, XmlEncError> { - let (iv, encrypted) = cbc_input(ciphertext)?; - let mut output = encrypted.to_vec(); - let plaintext = Decryptor::::new_from_slices(key, iv) - .map_err(|_| XmlEncError::InvalidKeySize { - algorithm: DataEncryptionAlgorithm::Aes256Cbc, - expected: 32, - actual: key.len(), - })? - .decrypt_padded::(&mut output) - .map_err(|_| XmlEncError::InvalidCbcCiphertextLength(encrypted.len()))?; - remove_cbc_padding(plaintext) } #[cfg(test)] @@ -582,7 +599,9 @@ mod tests { use aes_kw::KwAes128; use base64::{Engine as _, engine::general_purpose::STANDARD}; use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng}; - use rsa::{RsaPublicKey, pkcs8::DecodePrivateKey}; + use rsa::{Oaep, RsaPublicKey, pkcs8::DecodePrivateKey}; + use sha1::Sha1; + use sha2::{Sha256, Sha384}; use super::*; @@ -594,6 +613,7 @@ mod tests { impl DecryptionKeyResolver for RecipientKeyResolver { fn resolve_key( &self, + _provider: &dyn crate::provider::CryptoProvider, _algorithm: DataEncryptionAlgorithm, encrypted_key: Option<&EncryptedKey>, ) -> Result, XmlEncError> { @@ -636,30 +656,6 @@ mod tests { )); } - #[test] - fn handles_xmlenc_cbc_padding_boundaries() { - // XMLEnc permits random padding bytes and uses only the final byte as - // the length, including the one-byte and full-block boundaries. - assert_eq!( - remove_cbc_padding(b"plaintext\x01").expect("one-byte padding must be valid"), - b"plaintext" - ); - let mut full_block = [0x5a_u8; 16]; - full_block[15] = 16; - assert_eq!( - remove_cbc_padding(&full_block).expect("full-block padding must be valid"), - Vec::::new() - ); - assert!(matches!( - remove_cbc_padding(&[0]), - Err(XmlEncError::InvalidPadding { pad_len: 0, .. }) - )); - assert!(matches!( - remove_cbc_padding(&[17]), - Err(XmlEncError::InvalidPadding { pad_len: 17, .. }) - )); - } - #[test] fn direct_symmetric_key_ignores_embedded_key_hints() { // A caller-supplied content key is authoritative for this resolver; @@ -685,7 +681,11 @@ mod tests { assert_eq!( SymmetricKeyDecryptor::new(key) - .resolve_key(DataEncryptionAlgorithm::Aes128Gcm, Some(&unrelated)) + .resolve_key( + crate::provider::default_provider(), + DataEncryptionAlgorithm::Aes128Gcm, + Some(&unrelated) + ) .expect("direct key must ignore unrelated embedded hints"), key ); @@ -753,7 +753,11 @@ mod tests { carried_key_name: None, }; let resolved = KekDecryptor::new(kek) - .resolve_key(DataEncryptionAlgorithm::Aes128Gcm, Some(&encrypted_key)) + .resolve_key( + crate::provider::default_provider(), + DataEncryptionAlgorithm::Aes128Gcm, + Some(&encrypted_key), + ) .expect("wrapped session key must resolve"); assert_eq!(resolved, session_key); } @@ -762,11 +766,14 @@ mod tests { fn rejects_truncated_gcm_and_invalid_wrapped_key() { // Framing and key-wrap integrity failures must occur before content is exposed. assert!(matches!( - decrypt_content(DataEncryptionAlgorithm::Aes128Gcm, &[0_u8; 16], &[0_u8; 27]), - Err(XmlEncError::DataTooShort { - algorithm: "AES-GCM", - .. - }) + crate::provider::default_provider().decrypt_data( + DataEncryptionAlgorithm::Aes128Gcm, + &[0_u8; 16], + &[0_u8; 27], + ), + Err(crate::provider::ProviderError::InvalidInput( + "AES-GCM framing" + )) )); let encrypted_key = EncryptedKey { id: None, @@ -786,13 +793,19 @@ mod tests { carried_key_name: None, }; assert!(matches!( - KekDecryptor::new([0_u8; 16]) - .resolve_key(DataEncryptionAlgorithm::Aes128Gcm, Some(&encrypted_key)), + KekDecryptor::new([0_u8; 16]).resolve_key( + crate::provider::default_provider(), + DataEncryptionAlgorithm::Aes128Gcm, + Some(&encrypted_key) + ), Err(XmlEncError::KeyWrapIntegrity) )); assert!(matches!( - KekDecryptor::new([0_u8; 32]) - .resolve_key(DataEncryptionAlgorithm::Aes128Gcm, Some(&encrypted_key)), + KekDecryptor::new([0_u8; 32]).resolve_key( + crate::provider::default_provider(), + DataEncryptionAlgorithm::Aes128Gcm, + Some(&encrypted_key) + ), Err(XmlEncError::InvalidKekSize { algorithm: KeyWrapAlgorithm::AesKw128, expected: 16, @@ -836,7 +849,11 @@ mod tests { carried_key_name: None, }; let resolved = PrivateKeyDecryptor::new(private_key) - .resolve_key(DataEncryptionAlgorithm::Aes128Gcm, Some(&encrypted_key)) + .resolve_key( + crate::provider::default_provider(), + DataEncryptionAlgorithm::Aes128Gcm, + Some(&encrypted_key), + ) .expect("OAEP 1.1 wrapped key must resolve"); assert_eq!(resolved, session_key); } @@ -875,7 +892,11 @@ mod tests { carried_key_name: None, }; let resolved = PrivateKeyDecryptor::new(private_key) - .resolve_key(DataEncryptionAlgorithm::Aes128Gcm, Some(&encrypted_key)) + .resolve_key( + crate::provider::default_provider(), + DataEncryptionAlgorithm::Aes128Gcm, + Some(&encrypted_key), + ) .expect("legacy OAEP URI with SHA-256 must resolve"); assert_eq!(resolved, session_key); } @@ -924,7 +945,11 @@ mod tests { carried_key_name: None, }; let resolved = PrivateKeyDecryptor::new(private_key.clone()) - .resolve_key(DataEncryptionAlgorithm::Aes128Gcm, Some(&encrypted_key)) + .resolve_key( + crate::provider::default_provider(), + DataEncryptionAlgorithm::Aes128Gcm, + Some(&encrypted_key), + ) .expect("official XMLENC SHA-384 URI must resolve"); assert_eq!(resolved, session_key); } @@ -956,14 +981,14 @@ mod tests { carried_key_name: None, }; assert!(matches!( - decryptor.resolve_key(DataEncryptionAlgorithm::Aes128Gcm, Some(&encrypted_key)), + decryptor.resolve_key(crate::provider::default_provider(), DataEncryptionAlgorithm::Aes128Gcm, Some(&encrypted_key)), Err(XmlEncError::UnsupportedAlgorithm(uri)) if uri == "urn:unsupported:digest" )); encrypted_key.encryption_method.oaep_digest = None; encrypted_key.encryption_method.mgf_algorithm = Some("urn:unsupported:mgf".into()); assert!(matches!( - decryptor.resolve_key(DataEncryptionAlgorithm::Aes128Gcm, Some(&encrypted_key)), + decryptor.resolve_key(crate::provider::default_provider(), DataEncryptionAlgorithm::Aes128Gcm, Some(&encrypted_key)), Err(XmlEncError::UnsupportedAlgorithm(uri)) if uri == "urn:unsupported:mgf" )); } diff --git a/src/xmlenc/encrypt.rs b/src/xmlenc/encrypt.rs index ac0e8664..7f9017b9 100644 --- a/src/xmlenc/encrypt.rs +++ b/src/xmlenc/encrypt.rs @@ -1,38 +1,22 @@ //! XMLEnc content encryption, key wrapping, and XML generation. -use std::fmt; +use std::{fmt, sync::Arc}; -use aes::{ - Aes128, Aes256, - cipher::{BlockModeEncrypt, KeyIvInit, block_padding::NoPadding}, -}; -use aes_gcm::{ - Aes128Gcm, Aes256Gcm, Nonce, - aead::{AeadInOut, KeyInit}, -}; -use aes_kw::{KwAes128, KwAes256}; use base64::{Engine as _, engine::general_purpose::STANDARD}; -use cbc::Encryptor; -use getrandom::{SysRng, rand_core::TryRng}; use quick_xml::{ Writer, events::{BytesEnd, BytesStart, BytesText, Event}, }; use roxmltree::{Document, Node, ParsingOptions}; -use rsa::{Oaep, RsaPublicKey, traits::PaddingScheme}; -use sha1::Sha1; -use sha2::{Sha256, Sha384, Sha512}; +use rsa::RsaPublicKey; use crate::xml::is_xml_1_0_character; -use super::types::{ - MAX_ENCRYPTION_DOCUMENT_LEN, MAX_ENCRYPTION_METADATA_LEN, MAX_ENCRYPTION_PLAINTEXT_LEN, - MAX_ENCRYPTION_RECIPIENTS, XMLDSIG_NS, XMLENC_NS, XMLENC11_NS, -}; +use super::types::{XMLDSIG_NS, XMLENC_NS, XMLENC11_NS}; use super::{ DataEncryptionAlgorithm, DocumentEncryptionOptions, EncryptedDataType, EncryptionRecipient, - EncryptionResult, KeyWrapAlgorithm, OaepDigestAlgorithm, ReplacementMode, RsaOaepParameters, - XmlEncError, has_single_element_with_boundary_trivia, + EncryptionResult, KeyWrapAlgorithm, ReplacementMode, RsaOaepParameters, XmlEncError, + has_single_element_with_boundary_trivia, }; const XML_WHITESPACE: &[char] = &[' ', '\t', '\n', '\r']; @@ -46,6 +30,8 @@ pub struct EncryptedDataBuilder { direct_key: Option>, direct_key_name: Option, recipients: Vec, + policy: crate::policy::EncryptionPolicy, + provider: Arc, } impl fmt::Debug for EncryptedDataBuilder { @@ -61,6 +47,8 @@ impl fmt::Debug for EncryptedDataBuilder { ) .field("direct_key_name", &self.direct_key_name) .field("recipients", &self.recipients) + .field("policy", &self.policy) + .field("provider", &self.provider.name()) .finish() } } @@ -75,9 +63,23 @@ impl EncryptedDataBuilder { direct_key: None, direct_key_name: None, recipients: Vec::new(), + policy: crate::policy::EncryptionPolicy::default(), + provider: Arc::new(crate::provider::RustCryptoProvider), } } + /// Replace the complete immutable encryption policy snapshot. + pub fn policy(mut self, policy: crate::policy::EncryptionPolicy) -> Self { + self.policy = policy; + self + } + + /// Select the cryptographic provider for this operation context. + pub fn provider(mut self, provider: Arc) -> Self { + self.provider = provider; + self + } + /// Set whether XML encryption covers one element or its child content. pub fn encryption_type(mut self, encrypted_type: EncryptedDataType) -> Self { self.encrypted_type = encrypted_type; @@ -120,7 +122,7 @@ impl EncryptedDataBuilder { /// Encrypt one complete XML element or an XML content fragment. pub fn encrypt_xml(&self, xml: &str) -> Result { - validate_plaintext_len(xml.len())?; + self.validate_plaintext_len(xml.len())?; validate_xml_plaintext(xml, &self.encrypted_type)?; self.encrypt_payload(xml.as_bytes(), Some(self.encrypted_type.clone())) } @@ -136,9 +138,9 @@ impl EncryptedDataBuilder { xml: &str, options: DocumentEncryptionOptions<'_>, ) -> Result { - validate_document_len(xml.len())?; + self.validate_document_len(xml.len())?; let parsing_options = ParsingOptions { - allow_dtd: options.allow_dtd, + allow_dtd: self.policy.xml.allow_internal_dtd, entity_resolver: None, ..ParsingOptions::default() }; @@ -171,20 +173,25 @@ impl EncryptedDataBuilder { plaintext: &[u8], encrypted_type: Option, ) -> Result { - validate_plaintext_len(plaintext.len())?; + self.validate_plaintext_len(plaintext.len())?; self.validate_configuration()?; let content_key = if let Some(key) = &self.direct_key { validate_content_key(self.algorithm, key)?; key.clone() } else { - random_bytes(self.algorithm.key_len())? + random_bytes(self.provider.as_ref(), self.algorithm.key_len())? }; - let ciphertext = encrypt_content(self.algorithm, &content_key, plaintext)?; + let ciphertext = encrypt_content( + self.provider.as_ref(), + self.algorithm, + &content_key, + plaintext, + )?; let encrypted_keys = self .recipients .iter() - .map(|recipient| wrap_content_key(recipient, &content_key)) + .map(|recipient| wrap_content_key(self.provider.as_ref(), recipient, &content_key)) .collect::, _>>()?; let encrypted_data_xml = render_encrypted_data( self.algorithm, @@ -207,19 +214,32 @@ impl EncryptedDataBuilder { } fn validate_configuration(&self) -> Result<(), XmlEncError> { + self.policy.resources.validate()?; + if self + .policy + .data_algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(&self.algorithm)) + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "encryption", + algorithm: self.algorithm.to_string(), + } + .into()); + } if matches!(self.encrypted_type, EncryptedDataType::Other(_)) { return Err(XmlEncError::InvalidEncryptionConfig( "Other Type hints are not valid for XML encryption".into(), )); } - if self.recipients.len() > MAX_ENCRYPTION_RECIPIENTS { + if self.recipients.len() > self.policy.resources.max_encryption_recipients { return Err(XmlEncError::TooManyRecipients { - maximum: MAX_ENCRYPTION_RECIPIENTS, + maximum: self.policy.resources.max_encryption_recipients, actual: self.recipients.len(), }); } - validate_metadata("EncryptedData Id", self.id.as_deref())?; - validate_key_name("direct KeyName", self.direct_key_name.as_deref())?; + self.validate_metadata("EncryptedData Id", self.id.as_deref())?; + self.validate_key_name("direct KeyName", self.direct_key_name.as_deref())?; for recipient in &self.recipients { match recipient { EncryptionRecipient::RsaOaep { @@ -228,17 +248,46 @@ impl EncryptedDataBuilder { key_name, .. } => { - validate_metadata("EncryptedKey Recipient", recipient.as_deref())?; - validate_key_name("EncryptedKey KeyName", key_name.as_deref())?; - validate_metadata_len("OAEPparams", parameters.label.len())?; + if self + .policy + .key_transport_algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(¶meters.algorithm)) + || self.policy.oaep_digests.as_ref().is_some_and(|allowed| { + !allowed.contains(¶meters.digest) + || !allowed.contains(¶meters.mgf_digest) + }) + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "encryption", + algorithm: parameters.algorithm.uri().to_string(), + } + .into()); + } + self.validate_metadata("EncryptedKey Recipient", recipient.as_deref())?; + self.validate_key_name("EncryptedKey KeyName", key_name.as_deref())?; + self.validate_metadata_len("OAEPparams", parameters.label.len())?; } EncryptionRecipient::AesKeyWrap { + algorithm, recipient, key_name, .. } => { - validate_metadata("EncryptedKey Recipient", recipient.as_deref())?; - validate_key_name("EncryptedKey KeyName", key_name.as_deref())?; + if self + .policy + .key_wrap_algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(algorithm)) + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "encryption", + algorithm: algorithm.uri().to_string(), + } + .into()); + } + self.validate_metadata("EncryptedKey Recipient", recipient.as_deref())?; + self.validate_key_name("EncryptedKey KeyName", key_name.as_deref())?; } } } @@ -257,33 +306,85 @@ impl EncryptedDataBuilder { _ => Ok(()), } } + + fn validate_metadata( + &self, + field: &'static str, + value: Option<&str>, + ) -> Result<(), XmlEncError> { + validate_metadata( + field, + value, + self.policy.resources.max_encryption_metadata_bytes, + ) + } + + fn validate_key_name( + &self, + field: &'static str, + value: Option<&str>, + ) -> Result<(), XmlEncError> { + validate_key_name( + field, + value, + self.policy.resources.max_encryption_metadata_bytes, + ) + } + + fn validate_metadata_len(&self, field: &'static str, actual: usize) -> Result<(), XmlEncError> { + validate_metadata_len( + field, + actual, + self.policy.resources.max_encryption_metadata_bytes, + ) + } + + fn validate_plaintext_len(&self, actual: usize) -> Result<(), XmlEncError> { + validate_plaintext_len(actual, self.policy.resources.max_encryption_plaintext_bytes) + } + + fn validate_document_len(&self, actual: usize) -> Result<(), XmlEncError> { + validate_document_len(actual, self.policy.resources.max_encryption_document_bytes) + } } -fn validate_metadata(field: &'static str, value: Option<&str>) -> Result<(), XmlEncError> { +fn validate_metadata( + field: &'static str, + value: Option<&str>, + maximum: usize, +) -> Result<(), XmlEncError> { if value.is_some_and(|value| !value.chars().all(is_xml_1_0_character)) { return Err(XmlEncError::InvalidEncryptionConfig(format!( "{field} contains a character forbidden by XML 1.0" ))); } - validate_metadata_len(field, value.map_or(0, str::len)) + validate_metadata_len(field, value.map_or(0, str::len), maximum) } -fn validate_key_name(field: &'static str, value: Option<&str>) -> Result<(), XmlEncError> { +fn validate_key_name( + field: &'static str, + value: Option<&str>, + maximum: usize, +) -> Result<(), XmlEncError> { if value.is_some_and(str::is_empty) { return Err(XmlEncError::InvalidEncryptionConfig(format!( "{field} must not be empty" ))); } - validate_metadata(field, value) + validate_metadata(field, value, maximum) } -fn validate_metadata_len(field: &'static str, actual: usize) -> Result<(), XmlEncError> { - if actual <= MAX_ENCRYPTION_METADATA_LEN { +fn validate_metadata_len( + field: &'static str, + actual: usize, + maximum: usize, +) -> Result<(), XmlEncError> { + if actual <= maximum { Ok(()) } else { Err(XmlEncError::EncryptionMetadataTooLarge { field, - maximum: MAX_ENCRYPTION_METADATA_LEN, + maximum, actual, }) } @@ -306,23 +407,17 @@ struct ContentBoundaries { start_tag_end: usize, } -fn validate_plaintext_len(actual: usize) -> Result<(), XmlEncError> { - if actual <= MAX_ENCRYPTION_PLAINTEXT_LEN { +fn validate_plaintext_len(actual: usize, maximum: usize) -> Result<(), XmlEncError> { + if actual <= maximum { Ok(()) } else { - Err(XmlEncError::PlaintextTooLarge { - maximum: MAX_ENCRYPTION_PLAINTEXT_LEN, - actual, - }) + Err(XmlEncError::PlaintextTooLarge { maximum, actual }) } } -fn validate_document_len(actual: usize) -> Result<(), XmlEncError> { - if actual > MAX_ENCRYPTION_DOCUMENT_LEN { - return Err(XmlEncError::DocumentTooLarge { - maximum: MAX_ENCRYPTION_DOCUMENT_LEN, - actual, - }); +fn validate_document_len(actual: usize, maximum: usize) -> Result<(), XmlEncError> { + if actual > maximum { + return Err(XmlEncError::DocumentTooLarge { maximum, actual }); } Ok(()) } @@ -339,88 +434,26 @@ fn validate_content_key(algorithm: DataEncryptionAlgorithm, key: &[u8]) -> Resul } } -fn random_bytes(len: usize) -> Result, XmlEncError> { +fn random_bytes( + provider: &dyn crate::provider::CryptoProvider, + len: usize, +) -> Result, XmlEncError> { let mut bytes = vec![0_u8; len]; - SysRng - .try_fill_bytes(&mut bytes) - .map_err(|error| XmlEncError::Rng(error.to_string()))?; + provider.fill_random(&mut bytes)?; Ok(bytes) } fn encrypt_content( + provider: &dyn crate::provider::CryptoProvider, algorithm: DataEncryptionAlgorithm, key: &[u8], plaintext: &[u8], ) -> Result, XmlEncError> { - validate_content_key(algorithm, key)?; - match algorithm { - DataEncryptionAlgorithm::Aes128Cbc => encrypt_cbc::(key, plaintext), - DataEncryptionAlgorithm::Aes256Cbc => encrypt_cbc::(key, plaintext), - DataEncryptionAlgorithm::Aes128Gcm => encrypt_gcm::(key, plaintext), - DataEncryptionAlgorithm::Aes256Gcm => encrypt_gcm::(key, plaintext), - } -} - -fn encrypt_cbc(key: &[u8], plaintext: &[u8]) -> Result, XmlEncError> -where - C: aes::cipher::BlockCipherEncrypt + aes::cipher::KeyInit, -{ - const BLOCK: usize = 16; - let iv = random_bytes(BLOCK)?; - let pad_len = BLOCK - (plaintext.len() % BLOCK); - let mut padded = Vec::with_capacity(plaintext.len() + pad_len); - padded.extend_from_slice(plaintext); - if pad_len > 1 { - padded.extend_from_slice(&random_bytes(pad_len - 1)?); - } - padded.push(pad_len as u8); - let padded_len = padded.len(); - Encryptor::::new_from_slices(key, &iv) - .map_err(|_| XmlEncError::InvalidKeySize { - algorithm: if key.len() == 16 { - DataEncryptionAlgorithm::Aes128Cbc - } else { - DataEncryptionAlgorithm::Aes256Cbc - }, - expected: key.len(), - actual: key.len(), - })? - .encrypt_padded::(&mut padded, padded_len) - .map_err(|error| XmlEncError::XmlSerialize(error.to_string()))?; - let mut output = Vec::with_capacity(BLOCK + padded.len()); - output.extend_from_slice(&iv); - output.extend_from_slice(&padded); - Ok(output) -} - -fn encrypt_gcm(key: &[u8], plaintext: &[u8]) -> Result, XmlEncError> -where - C: AeadInOut + KeyInit, -{ - const NONCE_LEN: usize = 12; - let nonce = random_bytes(NONCE_LEN)?; - let cipher = C::new_from_slice(key).map_err(|_| XmlEncError::InvalidKeySize { - algorithm: if key.len() == 16 { - DataEncryptionAlgorithm::Aes128Gcm - } else { - DataEncryptionAlgorithm::Aes256Gcm - }, - expected: key.len(), - actual: key.len(), - })?; - let mut encrypted = plaintext.to_vec(); - let nonce_value = Nonce::try_from(nonce.as_slice()) - .map_err(|error| XmlEncError::XmlSerialize(error.to_string()))?; - cipher - .encrypt_in_place(&nonce_value, b"", &mut encrypted) - .map_err(|_| XmlEncError::AeadAuthenticationFailed)?; - let mut output = Vec::with_capacity(NONCE_LEN + encrypted.len()); - output.extend_from_slice(&nonce); - output.extend_from_slice(&encrypted); - Ok(output) + Ok(provider.encrypt_data(algorithm, key, plaintext)?) } fn wrap_content_key( + provider: &dyn crate::provider::CryptoProvider, recipient: &EncryptionRecipient, content_key: &[u8], ) -> Result { @@ -435,7 +468,7 @@ fn wrap_content_key( oaep: Some(parameters.clone()), recipient: recipient.clone(), key_name: key_name.clone(), - ciphertext: wrap_rsa_oaep(public_key, parameters, content_key)?, + ciphertext: wrap_rsa_oaep(provider, public_key, parameters, content_key)?, }), EncryptionRecipient::AesKeyWrap { kek, @@ -443,121 +476,33 @@ fn wrap_content_key( recipient, key_name, } => { - if kek.len() != algorithm.key_len() { - return Err(XmlEncError::InvalidKekSize { - algorithm: *algorithm, - expected: algorithm.key_len(), - actual: kek.len(), - }); - } - let mut output = vec![0_u8; content_key.len() + 8]; - let wrapped = match algorithm { - KeyWrapAlgorithm::AesKw128 => KwAes128::new_from_slice(kek) - .map_err(|_| invalid_kek_size(*algorithm, kek.len()))? - .wrap_key(content_key, &mut output), - KeyWrapAlgorithm::AesKw256 => KwAes256::new_from_slice(kek) - .map_err(|_| invalid_kek_size(*algorithm, kek.len()))? - .wrap_key(content_key, &mut output), - } - .map_err(|_| XmlEncError::KeyWrapIntegrity)?; + let wrapped = provider.wrap_key(*algorithm, kek, content_key)?; Ok(WrappedKey { algorithm_uri: algorithm.uri(), oaep: None, recipient: recipient.clone(), key_name: key_name.clone(), - ciphertext: wrapped.to_vec(), + ciphertext: wrapped, }) } } } fn wrap_rsa_oaep( + provider: &dyn crate::provider::CryptoProvider, public_key: &RsaPublicKey, parameters: &RsaOaepParameters, content_key: &[u8], ) -> Result, XmlEncError> { - if parameters.algorithm == super::KeyTransportAlgorithm::RsaOaepMgf1p - && parameters.mgf_digest != OaepDigestAlgorithm::Sha1 - { - return Err(XmlEncError::InvalidEncryptionConfig( - "legacy rsa-oaep-mgf1p requires MGF1-SHA1".into(), - )); - } - let mut rng = SysRng; - macro_rules! encrypt_with { - ($digest:ty, $mgf:ty) => { - // Call `PaddingScheme` directly: it accepts `TryCryptoRng`, so a - // `SysRng` failure returns `rsa::Error::Rng` for the mapping below - // instead of entering RSA's infallible `CryptoRng` convenience API. - Oaep::<$digest, $mgf>::new_with_mgf_hash_and_label(parameters.label.clone()).encrypt( - &mut rng, - public_key, - content_key, - ) - }; - } - let result = match (parameters.digest, parameters.mgf_digest) { - (OaepDigestAlgorithm::Sha1, OaepDigestAlgorithm::Sha1) => { - encrypt_with!(Sha1, Sha1) - } - (OaepDigestAlgorithm::Sha1, OaepDigestAlgorithm::Sha256) => { - encrypt_with!(Sha1, Sha256) - } - (OaepDigestAlgorithm::Sha1, OaepDigestAlgorithm::Sha384) => { - encrypt_with!(Sha1, Sha384) - } - (OaepDigestAlgorithm::Sha1, OaepDigestAlgorithm::Sha512) => { - encrypt_with!(Sha1, Sha512) - } - (OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha1) => { - encrypt_with!(Sha256, Sha1) - } - (OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha256) => { - encrypt_with!(Sha256, Sha256) - } - (OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha384) => { - encrypt_with!(Sha256, Sha384) - } - (OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha512) => { - encrypt_with!(Sha256, Sha512) - } - (OaepDigestAlgorithm::Sha384, OaepDigestAlgorithm::Sha1) => { - encrypt_with!(Sha384, Sha1) - } - (OaepDigestAlgorithm::Sha384, OaepDigestAlgorithm::Sha256) => { - encrypt_with!(Sha384, Sha256) - } - (OaepDigestAlgorithm::Sha384, OaepDigestAlgorithm::Sha384) => { - encrypt_with!(Sha384, Sha384) - } - (OaepDigestAlgorithm::Sha384, OaepDigestAlgorithm::Sha512) => { - encrypt_with!(Sha384, Sha512) - } - (OaepDigestAlgorithm::Sha512, OaepDigestAlgorithm::Sha1) => { - encrypt_with!(Sha512, Sha1) - } - (OaepDigestAlgorithm::Sha512, OaepDigestAlgorithm::Sha256) => { - encrypt_with!(Sha512, Sha256) - } - (OaepDigestAlgorithm::Sha512, OaepDigestAlgorithm::Sha384) => { - encrypt_with!(Sha512, Sha384) - } - (OaepDigestAlgorithm::Sha512, OaepDigestAlgorithm::Sha512) => { - encrypt_with!(Sha512, Sha512) - } - }; - result.map_err(|error| match error { - rsa::Error::Rng => XmlEncError::Rng("RSA-OAEP random generation failed".into()), - error => XmlEncError::RsaEncrypt(error.to_string()), - }) -} - -fn invalid_kek_size(algorithm: KeyWrapAlgorithm, actual: usize) -> XmlEncError { - XmlEncError::InvalidKekSize { - algorithm, - expected: algorithm.key_len(), - actual, - } + provider + .transport_key(public_key, parameters, content_key) + .map_err(|error| match error { + crate::provider::ProviderError::Random(message) => XmlEncError::Rng(message), + crate::provider::ProviderError::InvalidInput(reason) => { + XmlEncError::InvalidEncryptionConfig(reason.into()) + } + error => XmlEncError::RsaEncrypt(error.to_string()), + }) } fn render_encrypted_data( @@ -802,13 +747,20 @@ fn replace_range(xml: &str, range: std::ops::Range, replacement: &str) -> #[cfg(test)] mod tests { + use getrandom::SysRng; use getrandom::rand_core::UnwrapErr; use rsa::{RsaPrivateKey, RsaPublicKey}; use super::*; + use crate::hard_limits::{ + ENCRYPTION_DOCUMENT_BYTE_CEILING as MAX_ENCRYPTION_DOCUMENT_LEN, + ENCRYPTION_METADATA_BYTE_CEILING as MAX_ENCRYPTION_METADATA_LEN, + ENCRYPTION_PLAINTEXT_BYTE_CEILING as MAX_ENCRYPTION_PLAINTEXT_LEN, + ENCRYPTION_RECIPIENT_CEILING as MAX_ENCRYPTION_RECIPIENTS, + }; use crate::xmlenc::{ - KekDecryptor, PrivateKeyDecryptor, SymmetricKeyDecryptor, decrypt, decrypt_document, - parse_encrypted_data, + KekDecryptor, OaepDigestAlgorithm, PrivateKeyDecryptor, SymmetricKeyDecryptor, decrypt, + decrypt_document, parse_encrypted_data, }; #[test] @@ -942,9 +894,15 @@ mod tests { .expect_err("missing key source must fail"); assert!(matches!(no_key, XmlEncError::InvalidEncryptionConfig(_))); - assert!(validate_plaintext_len(MAX_ENCRYPTION_PLAINTEXT_LEN).is_ok()); + assert!( + validate_plaintext_len(MAX_ENCRYPTION_PLAINTEXT_LEN, MAX_ENCRYPTION_PLAINTEXT_LEN,) + .is_ok() + ); assert!(matches!( - validate_plaintext_len(MAX_ENCRYPTION_PLAINTEXT_LEN + 1), + validate_plaintext_len( + MAX_ENCRYPTION_PLAINTEXT_LEN + 1, + MAX_ENCRYPTION_PLAINTEXT_LEN, + ), Err(XmlEncError::PlaintextTooLarge { .. }) )); diff --git a/src/xmlenc/mod.rs b/src/xmlenc/mod.rs index 09842413..b59a5ea5 100644 --- a/src/xmlenc/mod.rs +++ b/src/xmlenc/mod.rs @@ -20,8 +20,9 @@ mod parse; mod types; pub use decrypt::{ - DecryptionKeyResolver, DocumentDecryptionOptions, KekDecryptor, PrivateKeyDecryptor, - SymmetricKeyDecryptor, decrypt, decrypt_data, decrypt_document, decrypt_document_with_options, + DecryptContext, DecryptionKeyResolver, DocumentDecryptionOptions, KekDecryptor, + PrivateKeyDecryptor, SymmetricKeyDecryptor, decrypt, decrypt_data, decrypt_document, + decrypt_document_with_options, }; pub use encrypt::EncryptedDataBuilder; pub use parse::parse_encrypted_data; diff --git a/src/xmlenc/types.rs b/src/xmlenc/types.rs index ca10da33..db6a5acb 100644 --- a/src/xmlenc/types.rs +++ b/src/xmlenc/types.rs @@ -12,22 +12,8 @@ pub const XMLENC11_NS: &str = "http://www.w3.org/2009/xmlenc11#"; pub const XMLDSIG_NS: &str = "http://www.w3.org/2000/09/xmldsig#"; /// Maximum normalized base64 text accepted from a `CipherValue`. -pub const MAX_CIPHER_VALUE_BASE64_LEN: usize = 16 * 1024 * 1024; -/// Maximum plaintext accepted by the encryption API. -/// -/// The limit leaves room for CBC/GCM framing while guaranteeing that the -/// resulting base64 `CipherValue` fits the parser's input bound. -pub const MAX_ENCRYPTION_PLAINTEXT_LEN: usize = (MAX_CIPHER_VALUE_BASE64_LEN / 4 * 3) - 32; -/// Maximum caller-owned XML document size accepted for node encryption. -/// -/// This separately bounds parser work while leaving room around a maximum-size -/// selected plaintext element or content fragment. -pub const MAX_ENCRYPTION_DOCUMENT_LEN: usize = MAX_CIPHER_VALUE_BASE64_LEN; -/// Maximum number of independently wrapped copies of one content key. -pub const MAX_ENCRYPTION_RECIPIENTS: usize = 64; -/// Maximum byte length of one caller-controlled XML metadata value. -pub const MAX_ENCRYPTION_METADATA_LEN: usize = 4 * 1024; - +pub const MAX_CIPHER_VALUE_BASE64_LEN: usize = + crate::hard_limits::ENCRYPTION_CIPHER_VALUE_BASE64_BYTE_CEILING; /// The `Type` attribute on an `EncryptedData` element. #[derive(Debug, Clone, PartialEq, Eq)] pub enum EncryptedDataType { @@ -40,7 +26,7 @@ pub enum EncryptedDataType { } /// Supported content-encryption algorithms. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DataEncryptionAlgorithm { /// AES-128 in CBC mode with XMLEnc padding. Aes128Cbc, @@ -130,7 +116,7 @@ impl KeyWrapAlgorithm { } /// Supported asymmetric session-key transport algorithms. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum KeyTransportAlgorithm { /// XML Encryption 1.0 OAEP with SHA-1 and MGF1-SHA-1. RsaOaepMgf1p, @@ -139,7 +125,7 @@ pub enum KeyTransportAlgorithm { } /// Supported symmetric key-wrap algorithms. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum KeyWrapAlgorithm { /// RFC 3394 AES key wrap with a 128-bit KEK. AesKw128, @@ -148,7 +134,7 @@ pub enum KeyWrapAlgorithm { } /// Digest algorithms accepted by RSA-OAEP encryption. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum OaepDigestAlgorithm { /// SHA-1, retained for legacy XMLEnc OAEP interoperability. Sha1, @@ -452,6 +438,14 @@ pub enum DecryptedContent { #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum XmlEncError { + /// The compiled encryption or decryption policy rejected an operation input. + #[error("XML Encryption policy violation: {0}")] + Policy(#[from] crate::policy::PolicyViolation), + + /// The selected cryptographic provider rejected or failed an operation. + #[error("cryptographic provider error: {0}")] + Provider(#[from] crate::provider::ProviderError), + /// XML document parsing failed. #[error("XML parsing error: {0}")] XmlParse(#[from] roxmltree::Error), diff --git a/tests/donor_full_verification_suite.rs b/tests/donor_full_verification_suite.rs index 5fbe5ca2..e089d951 100644 --- a/tests/donor_full_verification_suite.rs +++ b/tests/donor_full_verification_suite.rs @@ -5,6 +5,7 @@ use std::{ time::{Duration, SystemTime}, }; +use xml_sec::policy::{KeyTrustPolicy, VerificationPolicy}; use xml_sec::xmldsig::{ DefaultKeyResolver, DsigStatus, KeyResolverConfig, SignatureAlgorithm, VerificationKey, VerifyContext, @@ -126,13 +127,19 @@ fn donor_full_verification_suite_accepts_every_supported_case() { let root = project_root(); let mut passed = 0usize; let mut failed = Vec::::new(); + let mut compatibility_policy = VerificationPolicy::default(); + compatibility_policy.key_trust.allow_legacy_rsa_sha1 = true; for case in cases() { match case.expectation { Expectation::Embedded => { let xml = read_fixture(&root.join(case.xml_path)); let resolver = DefaultKeyResolver::default(); - match VerifyContext::new().key_resolver(&resolver).verify(&xml) { + match VerifyContext::new() + .policy(compatibility_policy.clone()) + .key_resolver(&resolver) + .verify(&xml) + { Ok(result) if matches!(result.status, DsigStatus::Valid) => { passed += 1; } @@ -164,7 +171,11 @@ fn donor_full_verification_suite_accepts_every_supported_case() { }, ); let resolver = DefaultKeyResolver::new(config); - match VerifyContext::new().key_resolver(&resolver).verify(&xml) { + match VerifyContext::new() + .policy(compatibility_policy.clone()) + .key_resolver(&resolver) + .verify(&xml) + { Ok(result) if matches!(result.status, DsigStatus::Valid) => passed += 1, Ok(result) => failed.push(format!( "{}: expected Valid, got {:?}", @@ -184,7 +195,11 @@ fn donor_full_verification_suite_accepts_every_supported_case() { .collect(), ..KeyResolverConfig::default() }); - match VerifyContext::new().key_resolver(&resolver).verify(&xml) { + match VerifyContext::new() + .policy(compatibility_policy.clone()) + .key_resolver(&resolver) + .verify(&xml) + { Ok(result) if matches!(result.status, DsigStatus::Valid) => passed += 1, Ok(result) => failed.push(format!( "{}: expected Valid, got {:?}", @@ -199,14 +214,21 @@ fn donor_full_verification_suite_accepts_every_supported_case() { let xml = read_fixture(&root.join(case.xml_path)); let resolver = DefaultKeyResolver::new(KeyResolverConfig { trusted_certs: vec![read_pem_der(&root.join(trust_anchor_path), "CERTIFICATE")], - verify_chains: true, - // 2027-01-15 UTC, inside the donor chain's 2026-2126 validity window. - verification_time: Some( - SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000), - ), + trust: KeyTrustPolicy { + verify_x509_chains: true, + // 2027-01-15 UTC, inside the donor chain's 2026-2126 validity window. + verification_time: Some( + SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000), + ), + ..KeyTrustPolicy::default() + }, ..KeyResolverConfig::default() }); - match VerifyContext::new().key_resolver(&resolver).verify(&xml) { + match VerifyContext::new() + .policy(compatibility_policy.clone()) + .key_resolver(&resolver) + .verify(&xml) + { Ok(result) if matches!(result.status, DsigStatus::Valid) => passed += 1, Ok(result) => failed.push(format!( "{}: expected Valid, got {:?}", diff --git a/tests/donor_negative_vectors.rs b/tests/donor_negative_vectors.rs index 26463fc6..a8cb17bb 100644 --- a/tests/donor_negative_vectors.rs +++ b/tests/donor_negative_vectors.rs @@ -12,10 +12,11 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD; use roxmltree::Document; use x509_parser::prelude::{FromDer, X509Certificate}; +use xml_sec::policy::PolicyViolation; use xml_sec::xmldsig::{ DsigError, DsigStatus, FailureReason, KeyInfoSource, ParseError, SignatureAlgorithm, - SignatureVerificationError, VerificationKey, VerifyContext, X509ChainError, X509ChainOptions, - X509DataInfo, parse_key_info, verify_signature_with_pem_key, verify_x509_certificate_chain, + VerificationKey, VerifyContext, X509ChainError, X509ChainOptions, X509DataInfo, parse_key_info, + verify_signature_with_pem_key, verify_x509_certificate_chain, }; const PHAOS_DIR: &str = "tests/fixtures/xmldsig/phaos-xmldsig-three"; @@ -73,7 +74,13 @@ fn phaos_bad_digest_reports_reference_mismatch_before_key_use() { // is advisory. The unrelated strong key is never used because digest // validation fails first, proving the exact fail-fast boundary. let xml = read_vector("signature-rsa-enveloped-bad-digest-val.xml"); - let result = verify_signature_with_pem_key(&xml, STRONG_RSA_PUBLIC_KEY, false) + let mut policy = xml_sec::policy::VerificationPolicy::default(); + policy.key_trust.allow_legacy_rsa_sha1 = true; + let key = phaos_verification_key(); + let result = VerifyContext::new() + .policy(policy) + .key(&key) + .verify(&xml) .expect("bad DigestValue must be a completed invalid verification"); assert_eq!( @@ -102,8 +109,8 @@ fn phaos_bad_signature_artifact_fails_on_its_unsupported_md5_reference() { #[test] fn phaos_valid_baseline_rejects_legacy_rsa_key_policy() { - // References in the historical positive vector are valid, but its - // 1024-bit RSA key is below the crate's 2048-bit verification minimum. + // References in the historical positive vector are valid, but RSA-SHA1 is + // rejected by the default verification policy before backend key handling. let xml = read_vector("signature-rsa-enveloped.xml"); let key = phaos_verification_key(); let error = VerifyContext::new() @@ -113,7 +120,10 @@ fn phaos_valid_baseline_rejects_legacy_rsa_key_policy() { assert!(matches!( error, - DsigError::Crypto(SignatureVerificationError::InvalidKeyDer) + DsigError::Policy(PolicyViolation::Algorithm { + operation: "verification", + .. + }) )); } diff --git a/tests/merlin_interop.rs b/tests/merlin_interop.rs index d828433f..8370802a 100644 --- a/tests/merlin_interop.rs +++ b/tests/merlin_interop.rs @@ -7,17 +7,26 @@ use std::{ }; use x509_parser::prelude::{FromDer, X509Certificate}; +use xml_sec::policy::KeyTrustPolicy; use xml_sec::xmldsig::{ DefaultKeyResolver, DsigError, DsigStatus, FailureReason, HmacSha1VerificationKey, - KeyResolutionError, KeyResolverConfig, ParseError, SignatureAlgorithm, - SignatureVerificationError, UriTypeSet, VerificationKey, VerifyContext, X509ChainError, - XPathHereSemantics, + KeyResolutionError, KeyResolverConfig, ParseError, SignatureAlgorithm, UriTypeSet, + VerificationKey, VerifyContext, X509ChainError, XPathHereSemantics, }; const MERLIN: &str = "tests/fixtures/xmldsig/merlin-xmldsig-twenty-three"; const DONOR_EXTERNAL: &str = "tests/fixtures/xmldsig/external-data"; const VERIFY_2005: u64 = 1_104_580_800; +fn chain_policy(check_crls: bool) -> KeyTrustPolicy { + KeyTrustPolicy { + verify_x509_chains: true, + check_crls, + verification_time: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(VERIFY_2005)), + ..KeyTrustPolicy::default() + } +} + fn root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) } @@ -101,13 +110,13 @@ fn verifies_all_merlin_documents_with_upstream_expectations() { .verify(&xml(name)), ); } - let legacy_rsa = DefaultKeyResolver::new(KeyResolverConfig { - allow_legacy_rsa_sha1: true, - ..KeyResolverConfig::default() - }); + let legacy_rsa = DefaultKeyResolver::default(); + let mut legacy_policy = xml_sec::policy::VerificationPolicy::default(); + legacy_policy.key_trust.allow_legacy_rsa_sha1 = true; assert_valid( "signature-enveloping-rsa", VerifyContext::new() + .policy(legacy_policy) .key_resolver(&legacy_rsa) .verify(&xml("signature-enveloping-rsa")), ); @@ -167,8 +176,7 @@ fn verifies_all_merlin_documents_with_upstream_expectations() { let resolver = DefaultKeyResolver::new(KeyResolverConfig { lookup_certs, trusted_certs: vec![cert("ca.pem")], - verify_chains: true, - verification_time: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(VERIFY_2005)), + trust: chain_policy(false), ..KeyResolverConfig::default() }); assert_valid( @@ -184,8 +192,7 @@ fn verifies_all_merlin_documents_with_upstream_expectations() { let retrieval = DefaultKeyResolver::new(KeyResolverConfig { lookup_certs: vec![cert("balor.pem")], trusted_certs: vec![cert("ca.pem")], - verify_chains: true, - verification_time: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(VERIFY_2005)), + trust: chain_policy(false), ..KeyResolverConfig::default() }); assert_valid( @@ -204,9 +211,7 @@ fn verifies_all_merlin_documents_with_upstream_expectations() { let revoked_resources = external_resources(); let revoked = DefaultKeyResolver::new(KeyResolverConfig { trusted_certs: vec![cert("ca.pem")], - verify_chains: true, - check_crls: true, - verification_time: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(VERIFY_2005)), + trust: chain_policy(true), ..KeyResolverConfig::default() }); let revoked_error = VerifyContext::new() @@ -231,7 +236,10 @@ fn verifies_all_merlin_documents_with_upstream_expectations() { let complex = DefaultKeyResolver::new(KeyResolverConfig { trusted_certs: vec![cert("merlin.pem")], - verification_time: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(VERIFY_2005)), + trust: KeyTrustPolicy { + verification_time: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(VERIFY_2005)), + ..KeyTrustPolicy::default() + }, ..KeyResolverConfig::default() }); let result = VerifyContext::new() @@ -337,9 +345,12 @@ fn bounds_external_resources_before_dereference() { .allowed_uri_types(UriTypeSet::ALL) .external_resources(&oversized) .verify(&xml("signature-external-dsa")), - Err(DsigError::InvalidStructure { - reason: "external resource exceeds maximum allowed length" - }) + Err(DsigError::Policy( + xml_sec::policy::PolicyViolation::ResourceLimit { + resource: "external resource bytes", + .. + } + )) )); let mut aggregate = external_resources(); @@ -351,9 +362,12 @@ fn bounds_external_resources_before_dereference() { .allowed_uri_types(UriTypeSet::ALL) .external_resources(&aggregate) .verify(&xml("signature-external-dsa")), - Err(DsigError::InvalidStructure { - reason: "external resources exceed maximum aggregate length" - }) + Err(DsigError::Policy( + xml_sec::policy::PolicyViolation::ResourceLimit { + resource: "aggregate external resource bytes", + .. + } + )) )); } @@ -461,7 +475,12 @@ fn rejects_missing_ambiguous_and_weak_key_resolution() { .verify(&xml("signature-enveloping-rsa")); assert!(matches!( weak, - Err(DsigError::Crypto(SignatureVerificationError::InvalidKeyDer)) + Err(DsigError::Policy( + xml_sec::policy::PolicyViolation::Algorithm { + operation: "verification", + .. + } + )) )); } @@ -497,8 +516,7 @@ fn rejects_dtd_and_unsupported_retrieval_defaults() { let retrieval = DefaultKeyResolver::new(KeyResolverConfig { lookup_certs: vec![cert("balor.pem")], trusted_certs: vec![cert("ca.pem")], - verify_chains: true, - verification_time: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(VERIFY_2005)), + trust: chain_policy(false), ..KeyResolverConfig::default() }); let reference_error = VerifyContext::new() From 7cae909105f17b706eb9f2948b87754e82a76fa3 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 7 Aug 2026 15:10:27 +0300 Subject: [PATCH 25/26] fix(security): enforce policy invariants - enforce operation-wide policy and resource ceilings - propagate structured provider and digest failures - cover signing, verification, XMLEnc, and donor regressions --- src/policy.rs | 78 +++++++++-- src/provider.rs | 171 +++++++++++++++++----- src/xmldsig/digest.rs | 123 +++++++++++++++- src/xmldsig/keys.rs | 9 +- src/xmldsig/parse.rs | 30 ++-- src/xmldsig/sign.rs | 61 +++++--- src/xmldsig/transforms.rs | 46 ++++-- src/xmldsig/verify.rs | 118 +++++++++++++--- src/xmlenc/decrypt.rs | 187 +++++++++++++++++++++++-- src/xmlenc/encrypt.rs | 61 +++++++- src/xmlenc/types.rs | 5 +- tests/donor_full_verification_suite.rs | 93 +++--------- tests/merlin_interop.rs | 19 +-- tests/signing_digest.rs | 63 +++++++++ 14 files changed, 846 insertions(+), 218 deletions(-) diff --git a/src/policy.rs b/src/policy.rs index b1d5b816..c626388c 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -4,7 +4,10 @@ //! document targets, tenant identity, and external resource bytes remain in //! operation request contexts and are deliberately not stored here. -use std::{collections::HashSet, time::SystemTime}; +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] +use std::collections::HashSet; +#[cfg(feature = "xmldsig")] +use std::time::SystemTime; #[cfg(feature = "xmldsig")] use crate::xmldsig::{DigestAlgorithm, SignatureAlgorithm, UriTypeSet, XPathHereSemantics}; @@ -95,32 +98,55 @@ impl Default for ResourcePolicy { impl ResourcePolicy { /// Validate policy values against non-configurable implementation ceilings. pub fn validate(&self) -> Result<(), PolicyViolation> { - self.within( + Self::within( "XML nodes", self.max_xml_nodes, crate::hard_limits::XML_DOCUMENT_NODE_CEILING as usize, )?; - self.within( + Self::within( "canonicalized bytes", self.max_canonicalized_bytes, crate::hard_limits::CANONICALIZED_SIGNATURE_DATA_BYTE_CEILING, )?; - self.within("signature references", self.max_references, 64)?; - self.within( + Self::within("signature references", self.max_references, 64)?; + Self::within( "reference transforms", self.max_transforms_per_reference, 64, )?; - self.within( + Self::within( "encryption document", self.max_encryption_document_bytes, crate::hard_limits::ENCRYPTION_DOCUMENT_BYTE_CEILING, )?; - Ok(()) + Self::within( + "external resource bytes", + self.max_external_resource_bytes, + crate::hard_limits::EXTERNAL_RESOURCE_BYTE_CEILING, + )?; + Self::within( + "aggregate external resource bytes", + self.max_external_resource_total_bytes, + crate::hard_limits::EXTERNAL_RESOURCE_TOTAL_BYTE_CEILING, + )?; + Self::within( + "encryption plaintext bytes", + self.max_encryption_plaintext_bytes, + crate::hard_limits::ENCRYPTION_PLAINTEXT_BYTE_CEILING, + )?; + Self::within( + "encryption recipients", + self.max_encryption_recipients, + crate::hard_limits::ENCRYPTION_RECIPIENT_CEILING, + )?; + Self::within( + "encryption metadata bytes", + self.max_encryption_metadata_bytes, + crate::hard_limits::ENCRYPTION_METADATA_BYTE_CEILING, + ) } fn within( - &self, resource: &'static str, selected: usize, ceiling: usize, @@ -178,8 +204,8 @@ impl Default for KeyTrustPolicy { #[cfg(feature = "xmldsig")] impl KeyTrustPolicy { fn validate(&self) -> Result<(), PolicyViolation> { - ResourcePolicy::default().within("X.509 chain depth", self.max_x509_chain_depth, 9)?; - ResourcePolicy::default().within("X.509 candidate paths", self.max_x509_candidate_paths, 64) + ResourcePolicy::within("X.509 chain depth", self.max_x509_chain_depth, 9)?; + ResourcePolicy::within("X.509 candidate paths", self.max_x509_candidate_paths, 64) } } @@ -209,7 +235,6 @@ pub struct VerificationPolicy { pub resources: ResourcePolicy, } -#[cfg(feature = "xmldsig")] #[cfg(feature = "xmldsig")] impl VerificationPolicy { /// Validate the complete snapshot against implementation hard ceilings. @@ -261,7 +286,6 @@ pub struct SigningPolicy { pub resources: ResourcePolicy, } -#[cfg(feature = "xmldsig")] /// Immutable policy snapshot for XMLEnc encryption. #[cfg(feature = "xmlenc")] #[derive(Debug, Clone, Default)] @@ -280,7 +304,6 @@ pub struct EncryptionPolicy { pub resources: ResourcePolicy, } -#[cfg(feature = "xmlenc")] /// Immutable policy snapshot for XMLEnc decryption. #[cfg(feature = "xmlenc")] pub type DecryptionPolicy = EncryptionPolicy; @@ -305,6 +328,35 @@ mod tests { )); } + #[test] + fn every_resource_policy_field_obeys_its_hard_ceiling() { + // Each public tuning knob is only a stricter operational limit; none + // may raise the implementation's allocation ceiling. + let mut policies = Vec::new(); + let mut external = ResourcePolicy::default(); + external.max_external_resource_bytes += 1; + policies.push(external); + let mut aggregate = ResourcePolicy::default(); + aggregate.max_external_resource_total_bytes += 1; + policies.push(aggregate); + let mut plaintext = ResourcePolicy::default(); + plaintext.max_encryption_plaintext_bytes += 1; + policies.push(plaintext); + let mut recipients = ResourcePolicy::default(); + recipients.max_encryption_recipients += 1; + policies.push(recipients); + let mut metadata = ResourcePolicy::default(); + metadata.max_encryption_metadata_bytes += 1; + policies.push(metadata); + + for policy in policies { + assert!(matches!( + policy.validate(), + Err(PolicyViolation::ResourceLimit { .. }) + )); + } + } + #[cfg(feature = "xmldsig")] #[test] fn rsa_sha1_requires_legacy_verification_policy() { diff --git a/src/provider.rs b/src/provider.rs index d3c2c500..9b118aea 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -53,6 +53,39 @@ pub struct CapabilityQuery<'a> { pub algorithm: Option<&'a str>, } +/// Structured invalid-input reasons returned by cryptographic providers. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum ProviderInputError { + /// A primitive rejected a key or IV after its public preconditions were checked. + #[error("failed to initialize {0}")] + PrimitiveInitialization(&'static str), + /// AES-CBC input does not contain an IV followed by complete blocks. + #[error("invalid AES-CBC framing")] + AesCbcFraming, + /// AES-CBC block decryption failed. + #[error("invalid AES-CBC ciphertext")] + AesCbcCiphertext, + /// AES-CBC produced no plaintext block. + #[error("empty AES-CBC plaintext")] + AesCbcPlaintext, + /// XMLEnc CBC padding length is outside the valid block range. + #[error("invalid XMLEnc CBC padding length {pad_len}")] + XmlEncCbcPadding { + /// Last plaintext octet interpreted as the padding length. + pad_len: u8, + }, + /// AES-GCM input does not contain a nonce and authentication tag. + #[error("invalid AES-GCM framing")] + AesGcmFraming, + /// AES key-wrap input or output framing is invalid. + #[error("invalid AES key-wrap framing")] + AesKeyWrapFraming, + /// The legacy RSA-OAEP URI requires MGF1-SHA1. + #[error("legacy RSA-OAEP requires MGF1-SHA1")] + LegacyRsaOaepMgf, +} + /// Failure returned by a cryptographic provider. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[non_exhaustive] @@ -73,9 +106,9 @@ pub enum ProviderError { /// Supplied key length. actual: usize, }, - /// Input framing or padding is invalid. + /// Input framing, padding, or primitive initialization is invalid. #[error("invalid cryptographic input: {0}")] - InvalidInput(&'static str), + InvalidInput(ProviderInputError), /// Authenticated decryption or key-wrap integrity validation failed. #[error("cryptographic authentication failed")] AuthenticationFailed, @@ -230,9 +263,8 @@ impl CryptoProvider for RustCryptoProvider { | "http://www.w3.org/2001/04/xmlenc#sha512" ) }), - ProviderOperation::Sign | ProviderOperation::Verify => { - query.algorithm.is_none_or(is_supported_signature_uri) - } + ProviderOperation::Sign => query.algorithm.is_none_or(is_supported_signing_uri), + ProviderOperation::Verify => query.algorithm.is_none_or(is_supported_signature_uri), ProviderOperation::Encrypt | ProviderOperation::Decrypt => { query.algorithm.is_none_or(is_supported_data_encryption_uri) } @@ -395,6 +427,17 @@ fn is_supported_signature_uri(algorithm: &str) -> bool { ) } +fn is_supported_signing_uri(algorithm: &str) -> bool { + matches!( + algorithm, + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" + | "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384" + | "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512" + | "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256" + | "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384" + ) +} + fn is_supported_data_encryption_uri(algorithm: &str) -> bool { matches!( algorithm, @@ -428,7 +471,7 @@ mod rustcrypto { use sha1::Sha1; use sha2::{Sha256, Sha384, Sha512}; - use super::{CryptoProvider, ProviderError}; + use super::{CryptoProvider, ProviderError, ProviderInputError}; use crate::xmlenc::{ DataEncryptionAlgorithm, KeyTransportAlgorithm, KeyWrapAlgorithm, OaepDigestAlgorithm, RsaOaepParameters, @@ -497,12 +540,15 @@ mod rustcrypto { } *padded.last_mut().expect("padding is non-empty") = pad_len as u8; Encryptor::::new_from_slices(key, &iv) - .map_err(|_| ProviderError::InvalidKeySize { - expected: key.len(), - actual: key.len(), + .map_err(|_| { + ProviderError::InvalidInput(ProviderInputError::PrimitiveInitialization("AES-CBC")) })? .encrypt_padded::(&mut padded, plaintext.len() + pad_len) - .map_err(|_| ProviderError::InvalidInput("AES-CBC padding"))?; + .map_err(|_| { + ProviderError::InvalidInput(ProviderInputError::PrimitiveInitialization( + "AES-CBC padding", + )) + })?; let mut output = Vec::with_capacity(16 + padded.len()); output.extend_from_slice(&iv); output.extend_from_slice(&padded); @@ -514,26 +560,28 @@ mod rustcrypto { C: aes::cipher::BlockCipherDecrypt + aes::cipher::KeyInit, { if ciphertext.len() < 32 || !(ciphertext.len() - 16).is_multiple_of(16) { - return Err(ProviderError::InvalidInput("AES-CBC framing")); + return Err(ProviderError::InvalidInput( + ProviderInputError::AesCbcFraming, + )); } let (iv, body) = ciphertext.split_at(16); let mut plaintext = body.to_vec(); Decryptor::::new_from_slices(key, iv) - .map_err(|_| ProviderError::InvalidKeySize { - expected: key.len(), - actual: key.len(), + .map_err(|_| { + ProviderError::InvalidInput(ProviderInputError::PrimitiveInitialization("AES-CBC")) })? .decrypt_padded::(&mut plaintext) - .map_err(|_| ProviderError::InvalidInput("AES-CBC ciphertext"))?; - let pad_len = usize::from( - *plaintext - .last() - .ok_or(ProviderError::InvalidInput("AES-CBC plaintext"))?, - ); - if !(1..=16).contains(&pad_len) || pad_len > plaintext.len() { - return Err(ProviderError::InvalidInput("XMLEnc CBC padding")); + .map_err(|_| ProviderError::InvalidInput(ProviderInputError::AesCbcCiphertext))?; + let pad_len = *plaintext.last().ok_or(ProviderError::InvalidInput( + ProviderInputError::AesCbcPlaintext, + ))?; + let padding_bytes = usize::from(pad_len); + if !(1..=16).contains(&padding_bytes) || padding_bytes > plaintext.len() { + return Err(ProviderError::InvalidInput( + ProviderInputError::XmlEncCbcPadding { pad_len }, + )); } - plaintext.truncate(plaintext.len() - pad_len); + plaintext.truncate(plaintext.len() - padding_bytes); Ok(plaintext) } @@ -547,13 +595,15 @@ mod rustcrypto { { let mut nonce = [0_u8; 12]; provider.fill_random(&mut nonce)?; - let cipher = C::new_from_slice(key).map_err(|_| ProviderError::InvalidKeySize { - expected: key.len(), - actual: key.len(), + let cipher = C::new_from_slice(key).map_err(|_| { + ProviderError::InvalidInput(ProviderInputError::PrimitiveInitialization("AES-GCM")) })?; let mut output = plaintext.to_vec(); - let nonce = Nonce::try_from(nonce.as_slice()) - .map_err(|_| ProviderError::InvalidInput("AES-GCM nonce"))?; + let nonce = Nonce::try_from(nonce.as_slice()).map_err(|_| { + ProviderError::InvalidInput(ProviderInputError::PrimitiveInitialization( + "AES-GCM nonce", + )) + })?; cipher .encrypt_in_place(&nonce, &[], &mut output) .map_err(|_| ProviderError::AuthenticationFailed)?; @@ -568,16 +618,20 @@ mod rustcrypto { C: AeadInOut + KeyInit, { if ciphertext.len() < 28 { - return Err(ProviderError::InvalidInput("AES-GCM framing")); + return Err(ProviderError::InvalidInput( + ProviderInputError::AesGcmFraming, + )); } let (nonce, body) = ciphertext.split_at(12); - let cipher = C::new_from_slice(key).map_err(|_| ProviderError::InvalidKeySize { - expected: key.len(), - actual: key.len(), + let cipher = C::new_from_slice(key).map_err(|_| { + ProviderError::InvalidInput(ProviderInputError::PrimitiveInitialization("AES-GCM")) })?; let mut plaintext = body.to_vec(); - let nonce = - Nonce::try_from(nonce).map_err(|_| ProviderError::InvalidInput("AES-GCM nonce"))?; + let nonce = Nonce::try_from(nonce).map_err(|_| { + ProviderError::InvalidInput(ProviderInputError::PrimitiveInitialization( + "AES-GCM nonce", + )) + })?; cipher .decrypt_in_place(&nonce, &[], &mut plaintext) .map_err(|_| ProviderError::AuthenticationFailed)?; @@ -605,7 +659,7 @@ mod rustcrypto { })? .wrap_key(key, &mut output), } - .map_err(|_| ProviderError::InvalidInput("AES key wrap"))?; + .map_err(|_| ProviderError::InvalidInput(ProviderInputError::AesKeyWrapFraming))?; Ok(output) } @@ -616,7 +670,9 @@ mod rustcrypto { ) -> Result, ProviderError> { check_key(algorithm.key_len(), kek)?; if wrapped.len() < 16 || !wrapped.len().is_multiple_of(8) { - return Err(ProviderError::InvalidInput("AES key wrap framing")); + return Err(ProviderError::InvalidInput( + ProviderInputError::AesKeyWrapFraming, + )); } let mut output = vec![0_u8; wrapped.len() - 8]; let key = match algorithm { @@ -647,7 +703,7 @@ mod rustcrypto { && parameters.mgf_digest != OaepDigestAlgorithm::Sha1 { return Err(ProviderError::InvalidInput( - "legacy RSA-OAEP requires MGF1-SHA1", + ProviderInputError::LegacyRsaOaepMgf, )); } let mut rng = super::ProviderRng(provider); @@ -716,6 +772,13 @@ mod rustcrypto { parameters: &RsaOaepParameters, ciphertext: &[u8], ) -> Result, ProviderError> { + if parameters.algorithm == KeyTransportAlgorithm::RsaOaepMgf1p + && parameters.mgf_digest != OaepDigestAlgorithm::Sha1 + { + return Err(ProviderError::InvalidInput( + ProviderInputError::LegacyRsaOaepMgf, + )); + } let mut rng = super::ProviderRng(provider); macro_rules! decrypt_with { ($digest:ty, $mgf:ty) => { @@ -798,9 +861,43 @@ mod tests { operation: ProviderOperation::KeyAgreement, algorithm: None })); + assert!(!RUST_CRYPTO_PROVIDER.supports(CapabilityQuery { + operation: ProviderOperation::Sign, + algorithm: Some("http://www.w3.org/2000/09/xmldsig#rsa-sha1") + })); + assert!(RUST_CRYPTO_PROVIDER.supports(CapabilityQuery { + operation: ProviderOperation::Verify, + algorithm: Some("http://www.w3.org/2000/09/xmldsig#rsa-sha1") + })); assert!(!RUST_CRYPTO_PROVIDER.supports(CapabilityQuery { operation: ProviderOperation::Verify, algorithm: Some("urn:unsupported:signature"), })); } + + #[cfg(feature = "xmlenc")] + #[test] + fn legacy_oaep_mgf_constraint_is_symmetric() { + use rsa::pkcs8::DecodePrivateKey; + + // The legacy URI fixes MGF1 to SHA-1 for both directions; rejecting + // before RSA processing keeps transport and recovery capabilities equal. + let key = rsa::RsaPrivateKey::from_pkcs8_pem(include_str!( + "../tests/fixtures/keys/rsa/rsa-2048-key.pem" + )) + .expect("RSA fixture must parse"); + let parameters = crate::xmlenc::RsaOaepParameters { + algorithm: crate::xmlenc::KeyTransportAlgorithm::RsaOaepMgf1p, + digest: crate::xmlenc::OaepDigestAlgorithm::Sha256, + mgf_digest: crate::xmlenc::OaepDigestAlgorithm::Sha256, + label: Vec::new(), + }; + + assert!(matches!( + RUST_CRYPTO_PROVIDER.recover_key(&key, ¶meters, &[0_u8; 256]), + Err(ProviderError::InvalidInput( + ProviderInputError::LegacyRsaOaepMgf + )) + )); + } } diff --git a/src/xmldsig/digest.rs b/src/xmldsig/digest.rs index 3d404620..8d1a4560 100644 --- a/src/xmldsig/digest.rs +++ b/src/xmldsig/digest.rs @@ -80,6 +80,7 @@ impl DigestAlgorithm { /// Returns the raw digest bytes (not base64-encoded). pub fn compute_digest(algorithm: DigestAlgorithm, data: &[u8]) -> Vec { compute_digest_with_provider(crate::provider::default_provider(), algorithm, data) + .expect("default provider advertises every XMLDSig digest") } /// Compute a digest with an explicitly selected provider. @@ -87,10 +88,8 @@ pub fn compute_digest_with_provider( provider: &dyn crate::provider::CryptoProvider, algorithm: DigestAlgorithm, data: &[u8], -) -> Vec { - provider - .digest(algorithm, data) - .expect("default provider advertises every XMLDSig digest") +) -> Result, crate::provider::ProviderError> { + provider.digest(algorithm, data) } /// Constant-time comparison of two byte slices. @@ -109,6 +108,122 @@ pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { mod tests { use super::*; + struct RejectingDigestProvider; + + impl crate::provider::CryptoProvider for RejectingDigestProvider { + fn name(&self) -> &'static str { + "rejecting-digest" + } + + fn supports(&self, query: crate::provider::CapabilityQuery<'_>) -> bool { + crate::provider::default_provider().supports(query) + } + + fn fill_random(&self, output: &mut [u8]) -> Result<(), crate::provider::ProviderError> { + crate::provider::default_provider().fill_random(output) + } + + fn digest( + &self, + algorithm: DigestAlgorithm, + _data: &[u8], + ) -> Result, crate::provider::ProviderError> { + Err(crate::provider::ProviderError::Unsupported { + operation: crate::provider::ProviderOperation::Digest, + algorithm: Some(algorithm.uri().to_owned()), + }) + } + + fn sign( + &self, + key: &dyn super::super::SigningKey, + algorithm: super::super::SignatureAlgorithm, + data: &[u8], + ) -> Result, super::super::SigningKeyError> { + crate::provider::default_provider().sign(key, algorithm, data) + } + + fn verify( + &self, + key: &dyn super::super::VerifyingKey, + algorithm: super::super::SignatureAlgorithm, + data: &[u8], + signature: &[u8], + ) -> Result { + crate::provider::default_provider().verify(key, algorithm, data, signature) + } + + #[cfg(feature = "xmlenc")] + fn encrypt_data( + &self, + algorithm: crate::xmlenc::DataEncryptionAlgorithm, + key: &[u8], + plaintext: &[u8], + ) -> Result, crate::provider::ProviderError> { + crate::provider::default_provider().encrypt_data(algorithm, key, plaintext) + } + + #[cfg(feature = "xmlenc")] + fn decrypt_data( + &self, + algorithm: crate::xmlenc::DataEncryptionAlgorithm, + key: &[u8], + ciphertext: &[u8], + ) -> Result, crate::provider::ProviderError> { + crate::provider::default_provider().decrypt_data(algorithm, key, ciphertext) + } + + #[cfg(feature = "xmlenc")] + fn wrap_key( + &self, + algorithm: crate::xmlenc::KeyWrapAlgorithm, + kek: &[u8], + key: &[u8], + ) -> Result, crate::provider::ProviderError> { + crate::provider::default_provider().wrap_key(algorithm, kek, key) + } + + #[cfg(feature = "xmlenc")] + fn unwrap_key( + &self, + algorithm: crate::xmlenc::KeyWrapAlgorithm, + kek: &[u8], + wrapped: &[u8], + ) -> Result, crate::provider::ProviderError> { + crate::provider::default_provider().unwrap_key(algorithm, kek, wrapped) + } + + #[cfg(feature = "xmlenc")] + fn transport_key( + &self, + key: &rsa::RsaPublicKey, + parameters: &crate::xmlenc::RsaOaepParameters, + plaintext: &[u8], + ) -> Result, crate::provider::ProviderError> { + crate::provider::default_provider().transport_key(key, parameters, plaintext) + } + + #[cfg(feature = "xmlenc")] + fn recover_key( + &self, + key: &rsa::RsaPrivateKey, + parameters: &crate::xmlenc::RsaOaepParameters, + ciphertext: &[u8], + ) -> Result, crate::provider::ProviderError> { + crate::provider::default_provider().recover_key(key, parameters, ciphertext) + } + } + + #[test] + fn explicit_provider_digest_failures_are_returned() { + // A restricted provider is caller-controlled and must never turn an + // unsupported document-selected digest into a process panic. + assert!(matches!( + compute_digest_with_provider(&RejectingDigestProvider, DigestAlgorithm::Sha256, b"x"), + Err(crate::provider::ProviderError::Unsupported { .. }) + )); + } + // ── from_uri / uri round-trip ──────────────────────────────────── #[test] diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 62ae9e61..43c0a909 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -234,17 +234,13 @@ impl DefaultKeyResolver { .ok_or(KeyResolutionError::InvalidCertificate)? .clone(); if trust.verify_x509_chains { - let selected = self.prepare_embedded_x509(info, signing_index, trust)?; - self.verify_x509_policy(&selected, trust)?; + self.prepare_embedded_x509(info, signing_index, trust)?; } certificate_der } else { let Some(selected) = self.resolve_configured_x509(info, trust)? else { return Ok(None); }; - if trust.verify_x509_chains { - self.verify_x509_policy(&selected, trust)?; - } selected .certificate_chain .first() @@ -451,6 +447,9 @@ impl DefaultKeyResolver { self.select_valid_x509_path(&mut available, signing_index, trust)?; available.certificate_chain.clone() }; + if trust.verify_x509_chains && signing_index < self.config.trusted_certs.len() { + self.verify_x509_policy(&available, trust)?; + } Ok(Some(available)) } diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index 32428309..18657d06 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -1274,6 +1274,7 @@ pub(crate) fn build_x509_certificate_paths_to_trusted_prefix( let mut pending = vec![vec![signing_idx]]; let mut completed = Vec::new(); let mut depth_exceeded = false; + let mut issuer_cache = vec![None; info.parsed_certificates.len()]; while let Some(path) = pending.pop() { let current_idx = *path .last() @@ -1294,19 +1295,24 @@ pub(crate) fn build_x509_certificate_paths_to_trusted_prefix( if distinguished_names_equal(¤t.subject_dn, ¤t.issuer_dn) { continue; } - let issuers = info - .parsed_certificates + let issuers = issuer_cache[current_idx].get_or_insert_with(|| { + info.parsed_certificates + .iter() + .enumerate() + .filter(|(issuer_idx, issuer)| { + distinguished_names_equal(&issuer.subject_dn, ¤t.issuer_dn) + && certificate_signature_matches( + &info.certificates[current_idx], + &info.certificates[*issuer_idx], + ) + }) + .map(|(issuer_idx, _)| issuer_idx) + .collect::>() + }); + let issuers = issuers .iter() - .enumerate() - .filter(|(issuer_idx, issuer)| { - !path.contains(issuer_idx) - && distinguished_names_equal(&issuer.subject_dn, ¤t.issuer_dn) - && certificate_signature_matches( - &info.certificates[current_idx], - &info.certificates[*issuer_idx], - ) - }) - .map(|(issuer_idx, _)| issuer_idx) + .copied() + .filter(|issuer_idx| !path.contains(issuer_idx)) .collect::>(); if pending.len().saturating_add(issuers.len()) > max_candidate_paths { return Err(X509ChainBuildError::AmbiguousIssuer); diff --git a/src/xmldsig/sign.rs b/src/xmldsig/sign.rs index 8377a1ef..48558fd9 100644 --- a/src/xmldsig/sign.rs +++ b/src/xmldsig/sign.rs @@ -56,6 +56,10 @@ pub struct ComputedReferenceDigest { /// Errors returned by the XMLDSig signing digest pass. #[derive(Debug, thiserror::Error)] pub enum SigningDigestError { + /// The selected provider could not compute a reference digest. + #[error("cryptographic provider error: {0}")] + Provider(#[from] crate::provider::ProviderError), + /// The compiled signing policy rejected an operation input. #[error("signing policy violation: {0}")] Policy(#[from] crate::policy::PolicyViolation), @@ -539,6 +543,9 @@ impl<'a> SignContext<'a> { /// base64 ``. pub fn sign_template(&self, xml: &str) -> Result { self.policy.resources.validate()?; + let execution_budget = TransformExecutionBudget::with_c14n_limit( + self.policy.resources.max_canonicalized_bytes, + ); let transform_options = TransformOptions::default() .allow_internal_dtd(self.policy.xml.allow_internal_dtd) .xpath_here_semantics(self.policy.xpath_here_semantics); @@ -547,16 +554,12 @@ impl<'a> SignContext<'a> { transform_options, Some(&self.policy), self.provider, + &execution_budget, )?; let (algorithm, canonical_signed_info) = canonicalize_signed_info(&with_digests)?; - if canonical_signed_info.len() > self.policy.resources.max_canonicalized_bytes { - return Err(crate::policy::PolicyViolation::ResourceLimit { - resource: "canonicalized SignedInfo bytes", - maximum: self.policy.resources.max_canonicalized_bytes, - actual: canonical_signed_info.len(), - } - .into()); - } + execution_budget + .charge_c14n_output(canonical_signed_info.len()) + .map_err(SigningDigestError::Transform)?; if !algorithm.signing_allowed() || self .policy @@ -611,11 +614,13 @@ struct SigningReference { pub fn compute_reference_digest_values( xml: &str, ) -> Result, SigningDigestError> { + let execution_budget = TransformExecutionBudget::default(); compute_reference_digest_values_with_options( xml, TransformOptions::default(), None, crate::provider::default_provider(), + &execution_budget, ) } @@ -624,6 +629,7 @@ fn compute_reference_digest_values_with_options( transform_options: TransformOptions, policy: Option<&crate::policy::SigningPolicy>, provider: &dyn crate::provider::CryptoProvider, + execution_budget: &TransformExecutionBudget, ) -> Result, SigningDigestError> { let doc = Document::parse(xml)?; let signature = find_signing_signature_node(&doc)?; @@ -647,11 +653,21 @@ fn compute_reference_digest_values_with_options( } .into()); } + if let Some(allowed) = policy.transforms.as_ref() { + for transform in &reference.transforms { + let uri = transform.algorithm_uri(); + if !allowed.contains(uri) { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "signing transform", + algorithm: uri.to_owned(), + } + .into()); + } + } + } } } let resolver = UriReferenceResolver::new(&doc); - let execution_budget = TransformExecutionBudget::default(); - references .into_iter() .enumerate() @@ -677,10 +693,13 @@ fn compute_reference_digest_values_with_options( initial_data, &reference.transforms, transform_options, - &execution_budget, + execution_budget, + )?; + let digest = super::compute_digest_with_provider( + provider, + reference.digest_method, + &pre_digest, )?; - let digest = - super::compute_digest_with_provider(provider, reference.digest_method, &pre_digest); let digest_value = base64::engine::general_purpose::STANDARD.encode(digest); Ok(ComputedReferenceDigest { index, @@ -699,11 +718,13 @@ fn compute_reference_digest_values_with_options( /// and writes the base64 digest into the matching `` in document /// order. pub fn fill_reference_digest_values(xml: &str) -> Result { + let execution_budget = TransformExecutionBudget::default(); fill_reference_digest_values_with_options( xml, TransformOptions::default(), None, crate::provider::default_provider(), + &execution_budget, ) } @@ -712,11 +733,17 @@ fn fill_reference_digest_values_with_options( transform_options: TransformOptions, policy: Option<&crate::policy::SigningPolicy>, provider: &dyn crate::provider::CryptoProvider, + execution_budget: &TransformExecutionBudget, ) -> Result { - let digest_values = - compute_reference_digest_values_with_options(xml, transform_options, policy, provider)? - .into_iter() - .map(|digest| digest.digest_value); + let digest_values = compute_reference_digest_values_with_options( + xml, + transform_options, + policy, + provider, + execution_budget, + )? + .into_iter() + .map(|digest| digest.digest_value); Ok(fill_signed_info_digest_values(xml, digest_values)?) } diff --git a/src/xmldsig/transforms.rs b/src/xmldsig/transforms.rs index f12f1632..323c3642 100644 --- a/src/xmldsig/transforms.rs +++ b/src/xmldsig/transforms.rs @@ -134,6 +134,7 @@ struct Base64WorkBudget { struct C14nOutputBudget { remaining: Cell, + max_bytes: usize, } fn charge_byte_budget(remaining: &Cell, bytes: usize) -> bool { @@ -149,11 +150,19 @@ impl Default for C14nOutputBudget { fn default() -> Self { Self { remaining: Cell::new(MAX_C14N_OUTPUT_BYTES), + max_bytes: MAX_C14N_OUTPUT_BYTES, } } } impl C14nOutputBudget { + fn with_limit(max_bytes: usize) -> Self { + Self { + remaining: Cell::new(max_bytes), + max_bytes, + } + } + fn remaining(&self) -> usize { self.remaining.get() } @@ -161,7 +170,7 @@ impl C14nOutputBudget { fn charge(&self, bytes: usize) -> Result<(), TransformError> { if !charge_byte_budget(&self.remaining, bytes) { return Err(TransformError::C14nOutputTooLarge { - max_bytes: MAX_C14N_OUTPUT_BYTES, + max_bytes: self.max_bytes, }); } Ok(()) @@ -199,18 +208,6 @@ impl TransformExecutionBudget { } } - fn with_c14n_limit(limit: usize) -> Self { - Self { - xpath: XPathWorkBudget::default(), - base64: Base64WorkBudget::default(), - c14n: C14nOutputBudget { - remaining: Cell::new(limit), - }, - node_filter: NodeFilterWorkBudget::default(), - node_set_materialization: NodeSetMaterializationBudget::default(), - } - } - fn with_node_filter_limit(limit: usize) -> Self { Self { xpath: XPathWorkBudget::default(), @@ -235,6 +232,17 @@ impl TransformExecutionBudget { } impl TransformExecutionBudget { + pub(crate) fn with_c14n_limit(max_bytes: usize) -> Self { + Self { + c14n: C14nOutputBudget::with_limit(max_bytes), + ..Self::default() + } + } + + pub(crate) fn charge_c14n_output(&self, bytes: usize) -> Result<(), TransformError> { + self.c14n.charge(bytes) + } + pub(crate) fn node_set_materialization(&self) -> &NodeSetMaterializationBudget { &self.node_set_materialization } @@ -458,6 +466,18 @@ pub enum Transform { Base64Decode, } +impl Transform { + pub(crate) fn algorithm_uri(&self) -> &'static str { + match self { + Self::Enveloped => ENVELOPED_SIGNATURE_URI, + Self::XpathExcludeAllSignatures | Self::XPath(_) => XPATH_TRANSFORM_URI, + Self::XPathFilter2(_) => XPATH_FILTER2_TRANSFORM_URI, + Self::C14n(algorithm) => algorithm.uri(), + Self::Base64Decode => BASE64_TRANSFORM_URI, + } + } +} + /// Apply a single transform to the pipeline data. /// /// `signature_node` is the `` element that contains the diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index f81e8a6c..1a58ae4e 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -21,10 +21,11 @@ use crate::hard_limits::{CANONICALIZED_SIGNATURE_DATA_BYTE_CEILING, XML_DOCUMENT #[cfg(test)] use super::digest::compute_digest; use super::digest::{DigestAlgorithm, constant_time_eq}; +#[cfg(test)] +use super::parse::MAX_REFERENCES_PER_SIGNATURE; use super::parse::{ - KeyInfo, MAX_REFERENCES_PER_SIGNATURE, MAX_X509_DATA_TOTAL_BINARY_LEN, - MAX_X509_DECODED_BINARY_LEN, ParseError, Reference, RetrievalMethodTransforms, - SignatureAlgorithm, XMLDSIG_NS, + KeyInfo, MAX_X509_DATA_TOTAL_BINARY_LEN, MAX_X509_DECODED_BINARY_LEN, ParseError, Reference, + RetrievalMethodTransforms, SignatureAlgorithm, XMLDSIG_NS, }; use super::parse::{ parse_key_info, parse_reference_with_xpath_budget, parse_signed_info_with_xpath_budget, @@ -34,10 +35,11 @@ use super::signature::{ SignatureVerificationError, verify_dsa_signature_spki, verify_ecdsa_signature_pem, verify_rsa_signature_pem, }; +#[cfg(test)] +use super::transforms::{BASE64_TRANSFORM_URI, XPATH_TRANSFORM_URI}; use super::transforms::{ - BASE64_TRANSFORM_URI, DEFAULT_IMPLICIT_C14N_URI, Transform, TransformExecutionBudget, - TransformOptions, XPATH_TRANSFORM_URI, XPathHereSemantics, XPathSignatureParseBudget, - execute_transforms_with_options_and_budget, + DEFAULT_IMPLICIT_C14N_URI, Transform, TransformExecutionBudget, TransformOptions, + XPathHereSemantics, XPathSignatureParseBudget, execute_transforms_with_options_and_budget, }; use super::uri::{UriReferenceResolver, same_document_reference_id}; use super::whitespace::{is_xml_whitespace_only, normalize_xml_base64_bytes}; @@ -638,7 +640,7 @@ fn process_reference_with_options( execution.provider, reference.digest_method, &pre_digest_bytes, - ); + )?; // 4. Compare with stored DigestValue (constant-time) let status = if constant_time_eq(&computed_digest, &reference.digest_value) { @@ -738,6 +740,10 @@ fn process_all_references_with_options( #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum ReferenceProcessingError { + /// The selected provider could not compute the declared digest. + #[error("cryptographic provider error: {0}")] + Provider(#[from] crate::provider::ProviderError), + /// `` omitted the `URI` attribute, which we do not resolve implicitly. #[error("reference URI is required; omitted URI references are not supported")] MissingUri, @@ -1155,7 +1161,10 @@ fn verify_signature_with_context( let manifest_references = if ctx.policy.process_manifests { let signed_info_reference_nodes = collect_authenticated_signed_info_reference_nodes(&signed_info.references, &resolver); - let remaining_reference_capacity = MAX_REFERENCES_PER_SIGNATURE + let remaining_reference_capacity = ctx + .policy + .resources + .max_references .checked_sub(signed_info.references.len()) .ok_or(SignatureVerificationPipelineError::InvalidStructure { reason: "SignedInfo exceeds the per-signature Reference limit", @@ -1418,6 +1427,19 @@ fn process_manifest_references( } results.reserve(manifest_references.len()); for (index, reference, reference_node_id) in &manifest_references { + if ctx + .policy + .digest_algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(&reference.digest_method)) + { + results.push(manifest_reference_invalid_result( + reference, + *index, + FailureReason::ReferencePolicyViolation { ref_index: *index }, + )); + continue; + } match enforce_reference_policies( std::slice::from_ref(reference), ctx.policy.reference_uri_types, @@ -1678,7 +1700,7 @@ fn enforce_reference_policies( if let Some(allowed) = allowed_transforms { for transform in &reference.transforms { - let transform_uri = transform_uri(transform); + let transform_uri = transform.algorithm_uri(); if !allowed.contains(transform_uri) { return Err(SignatureVerificationPipelineError::DisallowedTransform { algorithm: transform_uri.to_owned(), @@ -1704,16 +1726,6 @@ fn enforce_reference_policies( Ok(()) } -fn transform_uri(transform: &Transform) -> &'static str { - match transform { - Transform::Enveloped => super::transforms::ENVELOPED_SIGNATURE_URI, - Transform::XpathExcludeAllSignatures | Transform::XPath(_) => XPATH_TRANSFORM_URI, - Transform::XPathFilter2(_) => super::transforms::XPATH_FILTER2_TRANSFORM_URI, - Transform::C14n(algo) => algo.uri(), - Transform::Base64Decode => BASE64_TRANSFORM_URI, - } -} - #[derive(Debug, Clone, Copy)] struct SignatureChildNodes<'a, 'input> { signed_info_node: Node<'a, 'input>, @@ -2938,6 +2950,48 @@ mod tests { assert!(matches!(result.status, DsigStatus::Valid)); } + #[test] + fn verify_context_applies_digest_policy_to_manifest_references() { + // Manifest results are authenticated extension data and must obey the + // same digest allowlist as SignedInfo references. + let policy = crate::policy::VerificationPolicy { + process_manifests: true, + digest_algorithms: Some(HashSet::from([DigestAlgorithm::Sha1])), + ..crate::policy::VerificationPolicy::default() + }; + let xml = signature_with_manifest_xml_with_manifest_mutation(true, |mut xml| { + let legacy = "http://www.w3.org/2000/09/xmldsig#sha1"; + let offset = xml + .rfind(legacy) + .expect("Manifest DigestMethod must be present"); + xml.replace_range(offset..offset + legacy.len(), DigestAlgorithm::Sha256.uri()); + let value_start = xml[offset..] + .find("") + .map(|relative| offset + relative + "".len()) + .expect("Manifest DigestValue must be present"); + let value_end = xml[value_start..] + .find("") + .map(|relative| value_start + relative) + .expect("Manifest DigestValue must be closed"); + xml.replace_range( + value_start..value_end, + &base64::engine::general_purpose::STANDARD.encode([0_u8; 32]), + ); + xml + }); + let result = VerifyContext::new() + .key(&AcceptingKey) + .policy(policy) + .verify(&xml) + .expect("a disallowed Manifest digest is a per-reference result"); + + assert!(matches!(result.status, DsigStatus::Valid)); + assert!(matches!( + result.manifest_references[0].status, + DsigStatus::Invalid(FailureReason::ReferencePolicyViolation { ref_index: 0 }) + )); + } + #[test] fn verify_context_skips_manifest_uri_work_when_signature_is_invalid() { // Missing Manifest URIs remain unauthenticated until SignatureValue @@ -3126,6 +3180,32 @@ mod tests { )); } + #[test] + fn configured_reference_limit_is_shared_with_manifests() { + // Lowering the operation policy must lower the aggregate SignedInfo and + // Manifest capacity rather than falling back to the crate hard limit. + let policy = crate::policy::VerificationPolicy { + process_manifests: true, + resources: crate::policy::ResourcePolicy { + max_references: 1, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::VerificationPolicy::default() + }; + + let error = VerifyContext::new() + .key(&AcceptingKey) + .policy(policy) + .verify(&signature_with_manifest_xml(true)) + .expect_err("Manifest must exceed the caller-selected aggregate limit"); + assert!(matches!( + error, + SignatureVerificationPipelineError::InvalidStructure { + reason: "signed Manifests exceed the per-signature Reference limit" + } + )); + } + #[test] fn retrieval_method_materializes_single_x509_data_subtree() { for uri in [ diff --git a/src/xmlenc/decrypt.rs b/src/xmlenc/decrypt.rs index 22406161..720810e5 100644 --- a/src/xmlenc/decrypt.rs +++ b/src/xmlenc/decrypt.rs @@ -103,6 +103,27 @@ impl<'a> DecryptContext<'a> { } .into()); } + let digest = + parse_oaep_digest(encrypted_key.encryption_method.oaep_digest.as_deref())?; + let mgf_digest = if transport == KeyTransportAlgorithm::RsaOaepMgf1p { + OaepDigestAlgorithm::Sha1 + } else { + parse_oaep_mgf_digest(encrypted_key.encryption_method.mgf_algorithm.as_deref())? + }; + for selected in [digest, mgf_digest] { + if self + .policy + .oaep_digests + .as_ref() + .is_some_and(|allowed| !allowed.contains(&selected)) + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "decryption", + algorithm: selected.uri().to_owned(), + } + .into()); + } + } } else if let Ok(wrap) = KeyWrapAlgorithm::from_uri(uri) && self .policy @@ -117,6 +138,14 @@ impl<'a> DecryptContext<'a> { .into()); } } + let ciphertext = STANDARD + .decode(&encrypted.cipher_data.value) + .map_err(|error| XmlEncError::Base64(error.to_string()))?; + validate_possible_plaintext_len( + algorithm, + ciphertext.len(), + self.policy.resources.max_encryption_plaintext_bytes, + )?; let key = resolve_content_key( self.provider, algorithm, @@ -124,13 +153,14 @@ impl<'a> DecryptContext<'a> { self.resolver, )?; validate_key_len(algorithm, &key)?; - let ciphertext = STANDARD - .decode(&encrypted.cipher_data.value) - .map_err(|error| XmlEncError::Base64(error.to_string()))?; let plaintext = self .provider .decrypt_data(algorithm, &key, &ciphertext) .map_err(|error| map_data_decryption_error(algorithm, ciphertext.len(), error))?; + validate_plaintext_len( + plaintext.len(), + self.policy.resources.max_encryption_plaintext_bytes, + )?; match encrypted.encrypted_type.as_ref() { Some(EncryptedDataType::Element | EncryptedDataType::Content) => { Ok(DecryptedContent::Xml(String::from_utf8(plaintext)?)) @@ -235,9 +265,9 @@ impl DecryptionKeyResolver for KekDecryptor { } } crate::provider::ProviderError::AuthenticationFailed - | crate::provider::ProviderError::InvalidInput("AES key wrap framing") => { - XmlEncError::KeyWrapIntegrity - } + | crate::provider::ProviderError::InvalidInput( + crate::provider::ProviderInputError::AesKeyWrapFraming, + ) => XmlEncError::KeyWrapIntegrity, error => XmlEncError::Provider(error), })?; validate_key_len(algorithm, &key)?; @@ -356,7 +386,11 @@ fn recover_rsa_oaep( .recover_key(key, parameters, wrapped) .map_err(|error| match error { crate::provider::ProviderError::Random(message) => XmlEncError::Rng(message), - error => XmlEncError::Rsa(error.to_string()), + error @ (crate::provider::ProviderError::AuthenticationFailed + | crate::provider::ProviderError::InvalidInput(_)) => { + XmlEncError::Rsa(error.to_string()) + } + error => XmlEncError::Provider(error), }) } @@ -547,6 +581,27 @@ fn validate_key_len(algorithm: DataEncryptionAlgorithm, key: &[u8]) -> Result<() } } +fn validate_possible_plaintext_len( + algorithm: DataEncryptionAlgorithm, + ciphertext_len: usize, + maximum: usize, +) -> Result<(), XmlEncError> { + let framing = match algorithm { + // CBC always contains a 16-byte IV and at least one padding byte. + DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc => 17, + DataEncryptionAlgorithm::Aes128Gcm | DataEncryptionAlgorithm::Aes256Gcm => 28, + }; + validate_plaintext_len(ciphertext_len.saturating_sub(framing), maximum) +} + +fn validate_plaintext_len(actual: usize, maximum: usize) -> Result<(), XmlEncError> { + if actual <= maximum { + Ok(()) + } else { + Err(XmlEncError::PlaintextTooLarge { maximum, actual }) + } +} + fn map_data_decryption_error( algorithm: DataEncryptionAlgorithm, ciphertext_len: usize, @@ -561,7 +616,7 @@ fn map_data_decryption_error( ) => XmlEncError::AeadAuthenticationFailed, ( DataEncryptionAlgorithm::Aes128Gcm | DataEncryptionAlgorithm::Aes256Gcm, - ProviderError::InvalidInput("AES-GCM framing"), + ProviderError::InvalidInput(crate::provider::ProviderInputError::AesGcmFraming), ) => XmlEncError::DataTooShort { algorithm: "AES-GCM", minimum: 28, @@ -569,7 +624,7 @@ fn map_data_decryption_error( }, ( DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc, - ProviderError::InvalidInput("AES-CBC framing"), + ProviderError::InvalidInput(crate::provider::ProviderInputError::AesCbcFraming), ) if ciphertext_len < 32 => XmlEncError::DataTooShort { algorithm: "AES-CBC", minimum: 32, @@ -577,13 +632,15 @@ fn map_data_decryption_error( }, ( DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc, - ProviderError::InvalidInput("AES-CBC framing"), + ProviderError::InvalidInput(crate::provider::ProviderInputError::AesCbcFraming), ) => XmlEncError::InvalidCbcCiphertextLength(ciphertext_len.saturating_sub(16)), ( DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc, - ProviderError::InvalidInput("XMLEnc CBC padding"), + ProviderError::InvalidInput(crate::provider::ProviderInputError::XmlEncCbcPadding { + pad_len, + }), ) => XmlEncError::InvalidPadding { - pad_len: 0, + pad_len, block_size: 16, }, (_, error) => XmlEncError::Provider(error), @@ -772,9 +829,33 @@ mod tests { &[0_u8; 27], ), Err(crate::provider::ProviderError::InvalidInput( - "AES-GCM framing" + crate::provider::ProviderInputError::AesGcmFraming )) )); + let truncated = EncryptedData { + id: None, + encrypted_type: None, + key_name: None, + encryption_method: super::super::EncryptionMethod { + algorithm: DataEncryptionAlgorithm::Aes128Gcm.uri().into(), + key_size_bits: None, + oaep_digest: None, + mgf_algorithm: None, + oaep_params: None, + }, + encrypted_keys: Vec::new(), + cipher_data: super::super::CipherData { + value: STANDARD.encode([0_u8; 27]), + }, + }; + assert!(matches!( + DecryptContext::new(&SymmetricKeyDecryptor::new([0_u8; 16])).decrypt_data(&truncated), + Err(XmlEncError::DataTooShort { + algorithm: "AES-GCM", + actual: 27, + .. + }) + )); let encrypted_key = EncryptedKey { id: None, recipient: None, @@ -993,6 +1074,86 @@ mod tests { )); } + #[test] + fn decryption_policy_enforces_oaep_digest_and_plaintext_limits() { + // Algorithm and allocation policies are checked before key resolution + // or plaintext materialization, including the document-declared MGF. + let encrypted_key = EncryptedKey { + id: None, + recipient: None, + key_name: None, + encryption_method: super::super::EncryptionMethod { + algorithm: KeyTransportAlgorithm::RsaOaep11.uri().into(), + key_size_bits: None, + oaep_digest: Some(OaepDigestAlgorithm::Sha256.uri().into()), + mgf_algorithm: Some("http://www.w3.org/2009/xmlenc11#mgf1sha1".into()), + oaep_params: None, + }, + cipher_data: super::super::CipherData { + value: STANDARD.encode([0_u8; 256]), + }, + reference_list: None, + carried_key_name: None, + }; + let encrypted = EncryptedData { + id: None, + encrypted_type: None, + key_name: None, + encryption_method: super::super::EncryptionMethod { + algorithm: DataEncryptionAlgorithm::Aes128Gcm.uri().into(), + key_size_bits: None, + oaep_digest: None, + mgf_algorithm: None, + oaep_params: None, + }, + encrypted_keys: vec![encrypted_key], + cipher_data: super::super::CipherData { + value: STANDARD.encode([0_u8; 28]), + }, + }; + let policy = crate::policy::DecryptionPolicy { + oaep_digests: Some(std::collections::HashSet::from([ + OaepDigestAlgorithm::Sha256, + ])), + ..crate::policy::DecryptionPolicy::default() + }; + assert!(matches!( + DecryptContext::new(&SymmetricKeyDecryptor::new([0_u8; 16])) + .policy(policy) + .decrypt_data(&encrypted), + Err(XmlEncError::Policy( + crate::policy::PolicyViolation::Algorithm { .. } + )) + )); + + let ciphertext = crate::provider::default_provider() + .encrypt_data(DataEncryptionAlgorithm::Aes128Gcm, &[0_u8; 16], b"four") + .expect("test encryption must succeed"); + let bounded = EncryptedData { + encrypted_keys: Vec::new(), + cipher_data: super::super::CipherData { + value: STANDARD.encode(ciphertext), + }, + ..encrypted + }; + let policy = crate::policy::DecryptionPolicy { + resources: crate::policy::ResourcePolicy { + max_encryption_plaintext_bytes: 3, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::DecryptionPolicy::default() + }; + assert!(matches!( + DecryptContext::new(&SymmetricKeyDecryptor::new([0_u8; 16])) + .policy(policy) + .decrypt_data(&bounded), + Err(XmlEncError::PlaintextTooLarge { + maximum: 3, + actual: 4 + }) + )); + } + #[test] fn replaces_element_and_content_in_caller_owned_documents() { // Element plaintext replaces the encrypted node itself, while Content diff --git a/src/xmlenc/encrypt.rs b/src/xmlenc/encrypt.rs index 7f9017b9..2c6e06e5 100644 --- a/src/xmlenc/encrypt.rs +++ b/src/xmlenc/encrypt.rs @@ -122,6 +122,7 @@ impl EncryptedDataBuilder { /// Encrypt one complete XML element or an XML content fragment. pub fn encrypt_xml(&self, xml: &str) -> Result { + self.policy.resources.validate()?; self.validate_plaintext_len(xml.len())?; validate_xml_plaintext(xml, &self.encrypted_type)?; self.encrypt_payload(xml.as_bytes(), Some(self.encrypted_type.clone())) @@ -129,6 +130,7 @@ impl EncryptedDataBuilder { /// Encrypt opaque bytes without an XML `Type` attribute. pub fn encrypt_binary(&self, data: &[u8]) -> Result { + self.policy.resources.validate()?; self.encrypt_payload(data, None) } @@ -138,9 +140,10 @@ impl EncryptedDataBuilder { xml: &str, options: DocumentEncryptionOptions<'_>, ) -> Result { + self.policy.resources.validate()?; self.validate_document_len(xml.len())?; let parsing_options = ParsingOptions { - allow_dtd: self.policy.xml.allow_internal_dtd, + allow_dtd: self.policy.xml.allow_internal_dtd && options.allow_dtd, entity_resolver: None, ..ParsingOptions::default() }; @@ -499,7 +502,7 @@ fn wrap_rsa_oaep( .map_err(|error| match error { crate::provider::ProviderError::Random(message) => XmlEncError::Rng(message), crate::provider::ProviderError::InvalidInput(reason) => { - XmlEncError::InvalidEncryptionConfig(reason.into()) + XmlEncError::InvalidEncryptionConfig(reason.to_string()) } error => XmlEncError::RsaEncrypt(error.to_string()), }) @@ -967,6 +970,60 @@ mod tests { )); } + #[test] + fn document_dtd_requires_policy_and_per_call_opt_in() { + // Internal DTD parsing is a two-party decision: operation policy sets + // the ceiling and the call site must opt in for this document. + let document = "]>"; + let mut policy = crate::policy::EncryptionPolicy::default(); + policy.xml.allow_internal_dtd = true; + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key([0_u8; 16]) + .policy(policy.clone()) + .encrypt_document( + document, + DocumentEncryptionOptions { + element_id: None, + allow_dtd: true, + }, + ) + .expect("both DTD controls should permit parsing"); + assert!(matches!( + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key([0_u8; 16]) + .policy(policy) + .encrypt_document(document, DocumentEncryptionOptions::default()), + Err(XmlEncError::XmlParse(_)) + )); + } + + #[test] + fn invalid_resource_policy_is_rejected_at_every_entry_point() { + // Entry points must reject an invalid snapshot before parsing or using + // any caller-selected limit derived from it. + let mut policy = crate::policy::EncryptionPolicy::default(); + policy.resources.max_encryption_plaintext_bytes = + crate::hard_limits::ENCRYPTION_PLAINTEXT_BYTE_CEILING + 1; + let builder = || { + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key([0_u8; 16]) + .policy(policy.clone()) + }; + + assert!(matches!( + builder().encrypt_xml(""), + Err(XmlEncError::Policy(_)) + )); + assert!(matches!( + builder().encrypt_binary(b"x"), + Err(XmlEncError::Policy(_)) + )); + assert!(matches!( + builder().encrypt_document("", DocumentEncryptionOptions::default()), + Err(XmlEncError::Policy(_)) + )); + } + #[test] fn element_plaintext_enforces_replacement_node_contract() { // Element ciphertext must be safe for the reciprocal document replacement: diff --git a/src/xmlenc/types.rs b/src/xmlenc/types.rs index db6a5acb..30a6bfd6 100644 --- a/src/xmlenc/types.rs +++ b/src/xmlenc/types.rs @@ -354,7 +354,10 @@ pub struct EncryptionResult { pub struct DocumentEncryptionOptions<'a> { /// Select an element by `Id`, `ID`, or `id`; `None` selects the document root. pub element_id: Option<&'a str>, - /// Permit an internal DTD subset while parsing the caller's document. + /// Request internal-DTD parsing for this call. + /// + /// The operation policy must also permit internal DTDs; either control can + /// deny parsing, so a permissive caller option cannot weaken policy. pub allow_dtd: bool, } diff --git a/tests/donor_full_verification_suite.rs b/tests/donor_full_verification_suite.rs index e089d951..bff03568 100644 --- a/tests/donor_full_verification_suite.rs +++ b/tests/donor_full_verification_suite.rs @@ -131,35 +131,13 @@ fn donor_full_verification_suite_accepts_every_supported_case() { compatibility_policy.key_trust.allow_legacy_rsa_sha1 = true; for case in cases() { - match case.expectation { - Expectation::Embedded => { - let xml = read_fixture(&root.join(case.xml_path)); - let resolver = DefaultKeyResolver::default(); - match VerifyContext::new() - .policy(compatibility_policy.clone()) - .key_resolver(&resolver) - .verify(&xml) - { - Ok(result) if matches!(result.status, DsigStatus::Valid) => { - passed += 1; - } - Ok(result) => { - failed.push(format!( - "{}: expected Valid, got {:?}", - case.name, result.status - )); - } - Err(err) => { - failed.push(format!("{}: verification error {err}", case.name)); - } - } - } + let resolver = match case.expectation { + Expectation::Embedded => DefaultKeyResolver::default(), Expectation::Named { key_name, key_path, algorithm, } => { - let xml = read_fixture(&root.join(case.xml_path)); let mut config = KeyResolverConfig::default(); config.named_keys.insert( key_name.into(), @@ -170,49 +148,19 @@ fn donor_full_verification_suite_accepts_every_supported_case() { name: Some(key_name.into()), }, ); - let resolver = DefaultKeyResolver::new(config); - match VerifyContext::new() - .policy(compatibility_policy.clone()) - .key_resolver(&resolver) - .verify(&xml) - { - Ok(result) if matches!(result.status, DsigStatus::Valid) => passed += 1, - Ok(result) => failed.push(format!( - "{}: expected Valid, got {:?}", - case.name, result.status - )), - Err(err) => { - failed.push(format!("{}: verification error {err}", case.name)); - } - } + DefaultKeyResolver::new(config) } Expectation::Selected { certificate_paths } => { - let xml = read_fixture(&root.join(case.xml_path)); - let resolver = DefaultKeyResolver::new(KeyResolverConfig { + DefaultKeyResolver::new(KeyResolverConfig { lookup_certs: certificate_paths .iter() .map(|path| read_pem_der(&root.join(path), "CERTIFICATE")) .collect(), ..KeyResolverConfig::default() - }); - match VerifyContext::new() - .policy(compatibility_policy.clone()) - .key_resolver(&resolver) - .verify(&xml) - { - Ok(result) if matches!(result.status, DsigStatus::Valid) => passed += 1, - Ok(result) => failed.push(format!( - "{}: expected Valid, got {:?}", - case.name, result.status - )), - Err(err) => { - failed.push(format!("{}: verification error {err}", case.name)); - } - } + }) } Expectation::Chain { trust_anchor_path } => { - let xml = read_fixture(&root.join(case.xml_path)); - let resolver = DefaultKeyResolver::new(KeyResolverConfig { + DefaultKeyResolver::new(KeyResolverConfig { trusted_certs: vec![read_pem_der(&root.join(trust_anchor_path), "CERTIFICATE")], trust: KeyTrustPolicy { verify_x509_chains: true, @@ -223,22 +171,21 @@ fn donor_full_verification_suite_accepts_every_supported_case() { ..KeyTrustPolicy::default() }, ..KeyResolverConfig::default() - }); - match VerifyContext::new() - .policy(compatibility_policy.clone()) - .key_resolver(&resolver) - .verify(&xml) - { - Ok(result) if matches!(result.status, DsigStatus::Valid) => passed += 1, - Ok(result) => failed.push(format!( - "{}: expected Valid, got {:?}", - case.name, result.status - )), - Err(err) => { - failed.push(format!("{}: verification error {err}", case.name)); - } - } + }) } + }; + let xml = read_fixture(&root.join(case.xml_path)); + match VerifyContext::new() + .policy(compatibility_policy.clone()) + .key_resolver(&resolver) + .verify(&xml) + { + Ok(result) if matches!(result.status, DsigStatus::Valid) => passed += 1, + Ok(result) => failed.push(format!( + "{}: expected Valid, got {:?}", + case.name, result.status + )), + Err(err) => failed.push(format!("{}: verification error {err}", case.name)), } } diff --git a/tests/merlin_interop.rs b/tests/merlin_interop.rs index 8370802a..cd737100 100644 --- a/tests/merlin_interop.rs +++ b/tests/merlin_interop.rs @@ -287,20 +287,21 @@ fn verifies_all_merlin_documents_with_upstream_expectations() { } let expected_manifest = [ - ("http://www.w3.org/TR/xml-stylesheet", true), - ("#reference-1", true), - ("#notaries", false), + ("http://www.w3.org/TR/xml-stylesheet", DsigStatus::Valid), + ("#reference-1", DsigStatus::Valid), + ( + "#notaries", + // The donor uses an XSLT transform, which this pure-Rust profile + // intentionally does not execute; failure occurs before digest comparison. + DsigStatus::Invalid(FailureReason::ReferenceProcessingFailure { ref_index: 2 }), + ), ]; assert_eq!(result.manifest_references.len(), expected_manifest.len()); - for (reference, (expected_uri, expected_valid)) in + for (reference, (expected_uri, expected_status)) in result.manifest_references.iter().zip(expected_manifest) { assert_eq!(reference.uri, expected_uri); - assert_eq!( - reference.status == DsigStatus::Valid, - expected_valid, - "{expected_uri}" - ); + assert_eq!(reference.status, expected_status, "{expected_uri}"); } } diff --git a/tests/signing_digest.rs b/tests/signing_digest.rs index 218c5c31..b81ee672 100644 --- a/tests/signing_digest.rs +++ b/tests/signing_digest.rs @@ -1,4 +1,7 @@ +use std::collections::HashSet; + use xml_sec::c14n::{C14nAlgorithm, C14nMode}; +use xml_sec::policy::SigningPolicy; 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; @@ -268,6 +271,66 @@ fn computes_enveloped_signature_digest_for_whole_document() { assert_reference_digests_verify(&filled); } +#[test] +fn signing_policy_rejects_disallowed_reference_transform() { + // A signing policy is an execution boundary, not advisory metadata: every + // template transform must be accepted before any digest work runs. + 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 template = template_with_reference( + ReferenceBuilder::new(DigestAlgorithm::Sha256) + .uri("") + .transform(Transform::Enveloped), + ); + let xml = + append_signature_to_root("", &template).expect("append signature"); + let policy = SigningPolicy { + transforms: Some(HashSet::from([exclusive_c14n().uri().to_owned()])), + ..SigningPolicy::default() + }; + + assert!(matches!( + SignContext::new(&private_key) + .policy(policy) + .sign_template(&xml), + Err(SigningError::Digest(SigningDigestError::Policy(_))) + )); +} + +#[test] +fn signing_policy_shares_canonicalization_budget_with_signed_info() { + // Reference transforms and SignedInfo consume one operation-wide C14N + // allowance, preventing a template from multiplying the configured cap. + 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 template = template_with_reference( + ReferenceBuilder::new(DigestAlgorithm::Sha256) + .uri("#payload") + .transform(Transform::C14n(exclusive_c14n())), + ); + let xml = append_signature_to_root( + "canonicalized bytes", + &template, + ) + .expect("append signature"); + let policy = SigningPolicy { + resources: xml_sec::policy::ResourcePolicy { + max_canonicalized_bytes: 32, + ..xml_sec::policy::ResourcePolicy::default() + }, + ..SigningPolicy::default() + }; + + assert!(matches!( + SignContext::new(&private_key) + .policy(policy) + .sign_template(&xml), + Err(SigningError::Digest(SigningDigestError::Transform(_))) + )); +} + #[test] fn fills_only_signed_info_reference_digest_values() { // Manifests can contain their own DigestValue elements inside the same From 4fbc2be0a7f52b3c8fe5d74dd57708862cd6d430 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sat, 8 Aug 2026 10:11:22 +0300 Subject: [PATCH 26/26] fix(security): enforce operation policy bounds - validate only selected encryption-key candidates and hide CBC padding details - bound implicit and SignedInfo canonicalization under compiled policy - enforce trust-prefix and Manifest limits with regression coverage - document the resulting XMLDSig and XMLEnc contracts --- README.md | 4 +- docs/xmldsig.md | 8 + docs/xmlenc.md | 9 ++ src/provider.rs | 18 ++- src/xmldsig/keys.rs | 66 ++++++--- src/xmldsig/sign.rs | 54 ++++++- src/xmldsig/transforms.rs | 45 ++++-- src/xmldsig/verify.rs | 60 +++++++- src/xmlenc/decrypt.rs | 194 ++++++++++++++++--------- src/xmlenc/types.rs | 14 +- tests/donor_full_verification_suite.rs | 17 +-- tests/signing_digest.rs | 71 +++++++-- 12 files changed, 413 insertions(+), 147 deletions(-) diff --git a/README.md b/README.md index 8872d589..17a6237c 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Currently implemented (core paths): - XMLDSig parsing, same-document URI dereference, enveloped/C14N/Base64/XPath 1.0/XPath Filter 2.0 transform chains, and digest verification - XMLDSig full verify pipeline (`SignedInfo` canonicalization + `SignatureValue` verification) - XMLDSig template signing pipeline (`DigestValue` fill + `SignedInfo` canonicalization + `SignatureValue` fill), including enveloped SAML Response templates +- Typed signing and verification policy covers explicit transforms, implicit reference canonicalization, and `SignedInfo` canonicalization under shared work limits - XMLDSig signing KeyInfo writer for embedded X.509 certificates - 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 @@ -49,7 +50,8 @@ Currently implemented (core paths): - Caller-supplied, bounded external references and X.509 `RetrievalMethod` resolution without implicit I/O - XMLEnc AES-128/256-CBC and AES-128/256-GCM encryption/decryption with direct keys, RSA-OAEP key transport, AES-128/256-KW, multiple recipients, and - Element/Content document replacement + Element/Content document replacement; recipient policy is evaluated only for + candidate keys and CBC failures expose no decrypted padding details Still in progress: - XMLDSig DSA-SHA256, broader HMAC verification/signing, and RSA-PSS algorithms diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 30dd56e5..75c0b62a 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -22,6 +22,12 @@ The signing and verification contexts share the same reference-transform impleme interoperating with legacy libxmlsec1 `here()` behavior can explicitly select [`XPathHereSemantics::XmlSecLegacy`] on both contexts. +`SigningPolicy::transforms` applies to every canonicalization algorithm the signing pipeline +executes, including the default C14N 1.0 coercion when a reference transform chain ends as a node +set and the declared canonicalization method for ``. Reference output and +`` serialization consume one bounded canonicalization budget, so policy rejection +occurs during rendering rather than after an oversized buffer has already been allocated. + ## Verification Policy For production verification, configure `KeyResolverConfig::lookup_certs` with untrusted @@ -45,6 +51,8 @@ that the input contained no Manifest. `VerifyContext::process_manifests(false)` because Manifests were not processed; core validation failures and unsigned, unreferenced, or structurally excluded Manifest blocks can also produce an empty list. Callers must distinguish the disabled state from an enabled pass with no authenticated Manifest references. +Manifest references obey the same per-reference transform-count ceiling and transform allowlist as +`` references; a violation is recorded in that Manifest reference's independent status. Malformed XMLDSig structure, unsupported algorithms, disallowed reference URIs, and inconsistent `KeyInfo` metadata are processing errors rather than validity statuses. Treat both diff --git a/docs/xmlenc.md b/docs/xmlenc.md index 00dd28c9..00e1cdbd 100644 --- a/docs/xmlenc.md +++ b/docs/xmlenc.md @@ -72,6 +72,15 @@ unwraps AES-KW values. RSA PKCS#1 v1.5 transport, `CipherReference`, and unauthe resource loading are rejected; only inline `CipherValue` is accepted. Encryption inputs and recipient counts are bounded before allocation. +For multiple recipients, `DecryptContext` validates transport, wrap, digest, and MGF policy as +each `EncryptedKey` becomes a resolver candidate. A malformed or disallowed key for another +recipient therefore cannot suppress a later matching candidate. A resolver that supplies a direct +symmetric key remains authoritative and does not consult unrelated embedded key hints. + +AES-CBC framing is bounded before decryption and the exact plaintext bound is checked again after +padding removal. Invalid padding is reported only as `XmlEncError::InvalidPadding`; neither the +provider error nor the public error exposes the final decrypted octet or derived padding length. + Use `decrypt_document` to replace one typed `EncryptedData` in a complete XML string. Pass its `Id` when the document contains multiple encrypted regions. DTD parsing remains disabled by default; legacy documents that need an internal DTD can opt in through diff --git a/src/provider.rs b/src/provider.rs index 9b118aea..0dbc0653 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -70,11 +70,11 @@ pub enum ProviderInputError { #[error("empty AES-CBC plaintext")] AesCbcPlaintext, /// XMLEnc CBC padding length is outside the valid block range. - #[error("invalid XMLEnc CBC padding length {pad_len}")] - XmlEncCbcPadding { - /// Last plaintext octet interpreted as the padding length. - pad_len: u8, - }, + /// + /// This variant deliberately carries no decrypted bytes: provider errors + /// may cross a trust boundary and must not become a padding oracle. + #[error("invalid XMLEnc CBC padding")] + XmlEncCbcPadding, /// AES-GCM input does not contain a nonce and authentication tag. #[error("invalid AES-GCM framing")] AesGcmFraming, @@ -578,7 +578,7 @@ mod rustcrypto { let padding_bytes = usize::from(pad_len); if !(1..=16).contains(&padding_bytes) || padding_bytes > plaintext.len() { return Err(ProviderError::InvalidInput( - ProviderInputError::XmlEncCbcPadding { pad_len }, + ProviderInputError::XmlEncCbcPadding, )); } plaintext.truncate(plaintext.len() - padding_bytes); @@ -899,5 +899,11 @@ mod tests { ProviderInputError::LegacyRsaOaepMgf )) )); + assert!(matches!( + RUST_CRYPTO_PROVIDER.transport_key(&key.to_public_key(), ¶meters, &[0_u8; 16]), + Err(ProviderError::InvalidInput( + ProviderInputError::LegacyRsaOaepMgf + )) + )); } } diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 43c0a909..56571e66 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -293,13 +293,23 @@ impl DefaultKeyResolver { crls: info.crls.clone(), ..X509DataInfo::default() }; - for certificate in self - .config - .trusted_certs - .iter() - .chain(&self.config.lookup_certs) - .chain(&info.certificates) - { + let mut trusted_prefix_len = 0; + for certificate in &self.config.trusted_certs { + if available + .certificates + .iter() + .any(|known| known == certificate) + { + continue; + } + available.parsed_certificates.push( + parse_x509_certificate(certificate) + .map_err(|_| KeyResolutionError::InvalidCertificate)?, + ); + available.certificates.push(certificate.clone()); + trusted_prefix_len += 1; + } + for certificate in self.config.lookup_certs.iter().chain(&info.certificates) { if available .certificates .iter() @@ -318,7 +328,7 @@ impl DefaultKeyResolver { .iter() .position(|certificate| certificate == signing_der) .ok_or(KeyResolutionError::InvalidCertificate)?; - self.select_valid_x509_path(&mut available, signing_index, trust)?; + self.select_valid_x509_path(&mut available, signing_index, trusted_prefix_len, trust)?; Ok(available) } @@ -326,12 +336,13 @@ impl DefaultKeyResolver { &self, available: &mut X509DataInfo, signing_index: usize, + trusted_prefix_len: usize, trust: &crate::policy::KeyTrustPolicy, ) -> Result<(), KeyResolutionError> { let candidates = build_x509_certificate_paths_to_trusted_prefix( available, signing_index, - self.config.trusted_certs.len(), + trusted_prefix_len, trust.max_x509_chain_depth, trust.max_x509_candidate_paths, ) @@ -444,7 +455,12 @@ impl DefaultKeyResolver { if signing_index < self.config.trusted_certs.len() || !trust.verify_x509_chains { vec![signing_index] } else { - self.select_valid_x509_path(&mut available, signing_index, trust)?; + self.select_valid_x509_path( + &mut available, + signing_index, + self.config.trusted_certs.len(), + trust, + )?; available.certificate_chain.clone() }; if trust.verify_x509_chains && signing_index < self.config.trusted_certs.len() { @@ -1258,20 +1274,25 @@ mod tests { } #[test] - fn embedded_leaf_uses_configured_lookup_intermediate() { - // lookup_certs are untrusted path-building material for every X509Data - // source, including an embedded leaf and raw-certificate retrieval. - let root = rcgen::CertifiedIssuer::self_signed( - generated_certificate_params("embedded root", true), + fn embedded_leaf_uses_lookup_intermediate_with_duplicate_anchor() { + // Deduplicating repeated trust anchors must not shift an untrusted + // lookup intermediate into the trusted prefix used by path building. + let trusted_root = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("unrelated trusted root", true), rcgen::KeyPair::generate().expect("root key generation should succeed"), ) .expect("root should be self-signable"); + let issuer_root = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("untrusted issuer root", true), + rcgen::KeyPair::generate().expect("issuer root key generation should succeed"), + ) + .expect("issuer root should be self-signable"); let intermediate = rcgen::CertifiedIssuer::signed_by( generated_certificate_params("embedded intermediate", true), rcgen::KeyPair::generate().expect("intermediate key generation should succeed"), - &root, + &issuer_root, ) - .expect("root should sign the intermediate"); + .expect("issuer root should sign the intermediate"); let leaf = generated_certificate_params("embedded leaf", false) .signed_by( &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), @@ -1286,16 +1307,17 @@ mod tests { }; let resolver = DefaultKeyResolver::new(KeyResolverConfig { lookup_certs: vec![intermediate.der().to_vec()], - trusted_certs: vec![root.der().to_vec()], + trusted_certs: vec![trusted_root.der().to_vec(), trusted_root.der().to_vec()], trust: chain_policy(), ..KeyResolverConfig::default() }); - let resolved = resolver - .resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) - .expect("embedded leaf should chain through the configured lookup intermediate"); + let error = match resolver.resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) { + Ok(_) => panic!("an untrusted lookup intermediate must not become a trust anchor"), + Err(error) => error, + }; - assert!(resolved.is_some()); + assert!(matches!(error, DsigError::KeyResolution(_))); } #[test] diff --git a/src/xmldsig/sign.rs b/src/xmldsig/sign.rs index 48558fd9..5d7c5aa4 100644 --- a/src/xmldsig/sign.rs +++ b/src/xmldsig/sign.rs @@ -20,7 +20,7 @@ use sha2::{Sha256, Sha384, Sha512}; use std::collections::HashSet; use x509_parser::prelude::FromDer; -use crate::c14n::canonicalize; +use crate::c14n::{canonicalize_bounded, is_output_limit_error}; use super::builder::{SignatureBuilder, SignatureBuilderError}; use super::digest::DigestAlgorithm; @@ -32,9 +32,9 @@ use super::parse::{ MAX_REFERENCES_PER_SIGNATURE, SignatureAlgorithm, XMLDSIG_NS, parse_signed_info, }; use super::transforms::{ - Transform, TransformExecutionBudget, TransformOptions, XPathHereSemantics, - XPathSignatureParseBudget, execute_transforms_with_options_and_budget, - parse_transforms_with_budget, + DEFAULT_IMPLICIT_C14N_URI, Transform, TransformExecutionBudget, TransformOptions, + XPathHereSemantics, XPathSignatureParseBudget, execute_transforms_with_options_and_budget, + parse_transforms_with_budget, transform_chain_produces_binary, }; use super::types::TransformError; use super::uri::UriReferenceResolver; @@ -556,7 +556,8 @@ impl<'a> SignContext<'a> { self.provider, &execution_budget, )?; - let (algorithm, canonical_signed_info) = canonicalize_signed_info(&with_digests)?; + let (algorithm, canonical_signed_info) = + canonicalize_signed_info(&with_digests, &self.policy, &execution_budget)?; execution_budget .charge_c14n_output(canonical_signed_info.len()) .map_err(SigningDigestError::Transform)?; @@ -664,6 +665,16 @@ fn compute_reference_digest_values_with_options( .into()); } } + let initial_binary = !reference.uri.is_empty() && !reference.uri.starts_with('#'); + if !transform_chain_produces_binary(initial_binary, &reference.transforms) + && !allowed.contains(DEFAULT_IMPLICIT_C14N_URI) + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "signing transform", + algorithm: DEFAULT_IMPLICIT_C14N_URI.to_owned(), + } + .into()); + } } } } @@ -747,23 +758,50 @@ fn fill_reference_digest_values_with_options( Ok(fill_signed_info_digest_values(xml, digest_values)?) } -fn canonicalize_signed_info(xml: &str) -> Result<(SignatureAlgorithm, Vec), SigningError> { +fn canonicalize_signed_info( + xml: &str, + policy: &crate::policy::SigningPolicy, + execution_budget: &TransformExecutionBudget, +) -> Result<(SignatureAlgorithm, Vec), SigningError> { let doc = Document::parse(xml).map_err(SigningDigestError::XmlParse)?; let signature = find_signing_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)?; + if policy + .transforms + .as_ref() + .is_some_and(|allowed| !allowed.contains(signed_info.c14n_method.uri())) + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "SignedInfo canonicalization", + algorithm: signed_info.c14n_method.uri().to_owned(), + } + .into()); + } let signed_info_subtree: HashSet<_> = signed_info_node .descendants() .map(|node: Node<'_, '_>| node.id()) .collect(); let mut canonical_signed_info = Vec::new(); - canonicalize( + canonicalize_bounded( &doc, Some(&|node| signed_info_subtree.contains(&node.id())), &signed_info.c14n_method, + execution_budget.remaining_c14n_output(), &mut canonical_signed_info, - )?; + ) + .map_err(|error| { + if is_output_limit_error(&error) { + SigningError::Digest(SigningDigestError::Transform( + TransformError::C14nOutputTooLarge { + max_bytes: execution_budget.c14n_output_limit(), + }, + )) + } else { + SigningError::Canonicalization(error) + } + })?; Ok((signed_info.signature_method, canonical_signed_info)) } diff --git a/src/xmldsig/transforms.rs b/src/xmldsig/transforms.rs index 323c3642..24a82345 100644 --- a/src/xmldsig/transforms.rs +++ b/src/xmldsig/transforms.rs @@ -243,6 +243,14 @@ impl TransformExecutionBudget { self.c14n.charge(bytes) } + pub(crate) fn remaining_c14n_output(&self) -> usize { + self.c14n.remaining() + } + + pub(crate) fn c14n_output_limit(&self) -> usize { + self.c14n.max_bytes + } + pub(crate) fn node_set_materialization(&self) -> &NodeSetMaterializationBudget { &self.node_set_materialization } @@ -612,7 +620,7 @@ fn apply_transform_with_options_and_state<'s, 'd>( budget.c14n.remaining(), &mut output, ) - .map_err(map_c14n_limit_error)?; + .map_err(|error| map_c14n_limit_error(error, budget.c14n.max_bytes))?; budget.c14n.charge(output.len())?; Ok(TransformData::Binary(output)) } @@ -880,7 +888,7 @@ fn execute_transform_chain<'s, 'e, 'd>( context.budget.c14n.remaining(), &mut output, ) - .map_err(map_c14n_limit_error)?; + .map_err(|error| map_c14n_limit_error(error, context.budget.c14n.max_bytes))?; context.budget.c14n.charge(output.len())?; return execute_transform_chain( source_signature, @@ -995,23 +1003,30 @@ fn finalize_transform_data( c14n_budget.remaining(), &mut output, ) - .map_err(map_c14n_limit_error)?; + .map_err(|error| map_c14n_limit_error(error, c14n_budget.max_bytes))?; c14n_budget.charge(output.len())?; Ok(output) } } } -fn map_c14n_limit_error(error: c14n::C14nError) -> TransformError { +fn map_c14n_limit_error(error: c14n::C14nError, max_bytes: usize) -> TransformError { if c14n::is_output_limit_error(&error) { - TransformError::C14nOutputTooLarge { - max_bytes: MAX_C14N_OUTPUT_BYTES, - } + TransformError::C14nOutputTooLarge { max_bytes } } else { TransformError::C14n(error) } } +pub(crate) fn transform_chain_produces_binary( + initial_binary: bool, + transforms: &[Transform], +) -> bool { + transforms.iter().fold(initial_binary, |_, transform| { + matches!(transform, Transform::C14n(_) | Transform::Base64Decode) + }) +} + /// Parse a `` element into a `Vec`. /// /// Reads each `` child element and constructs @@ -1828,6 +1843,13 @@ mod tests { ); let transforms = [Transform::XPath(XPathExpression::new("true()"))]; + execute_transforms( + signature_document.root_element(), + TransformData::Binary(b"".to_vec()), + &transforms, + ) + .expect("external XML below the node ceiling must parse and transform"); + let error = execute_transforms( signature_document.root_element(), TransformData::Binary(xml.into_bytes()), @@ -1835,7 +1857,10 @@ mod tests { ) .expect_err("external XML exceeding the node ceiling must fail during parse"); - assert!(matches!(error, TransformError::XmlParse(_))); + assert!(matches!( + error, + TransformError::XmlParse(message) if message == "nodes limit reached" + )); } #[test] @@ -1886,9 +1911,7 @@ mod tests { assert!(matches!( error, - TransformError::C14nOutputTooLarge { - max_bytes: MAX_C14N_OUTPUT_BYTES - } + TransformError::C14nOutputTooLarge { max_bytes: 64 } )); } } diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index 1a58ae4e..4cbc605d 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -40,6 +40,7 @@ use super::transforms::{BASE64_TRANSFORM_URI, XPATH_TRANSFORM_URI}; use super::transforms::{ DEFAULT_IMPLICIT_C14N_URI, Transform, TransformExecutionBudget, TransformOptions, XPathHereSemantics, XPathSignatureParseBudget, execute_transforms_with_options_and_budget, + transform_chain_produces_binary, }; use super::uri::{UriReferenceResolver, same_document_reference_id}; use super::whitespace::{is_xml_whitespace_only, normalize_xml_base64_bytes}; @@ -1427,6 +1428,14 @@ fn process_manifest_references( } results.reserve(manifest_references.len()); for (index, reference, reference_node_id) in &manifest_references { + if reference.transforms.len() > ctx.policy.resources.max_transforms_per_reference { + results.push(manifest_reference_invalid_result( + reference, + *index, + FailureReason::ReferencePolicyViolation { ref_index: *index }, + )); + continue; + } if ctx .policy .digest_algorithms @@ -1712,10 +1721,10 @@ fn enforce_reference_policies( // whether the caller supplied the resource. Every transform then // determines the next type, including implicit binary-to-node-set // adapters before XML-level transforms. - let mut produces_binary = classify_uri(uri) == UriClass::External; - for transform in &reference.transforms { - produces_binary = matches!(transform, Transform::C14n(_) | Transform::Base64Decode); - } + let produces_binary = transform_chain_produces_binary( + classify_uri(uri) == UriClass::External, + &reference.transforms, + ); if !produces_binary && !allowed.contains(DEFAULT_IMPLICIT_C14N_URI) { return Err(SignatureVerificationPipelineError::DisallowedTransform { algorithm: DEFAULT_IMPLICIT_C14N_URI.to_owned(), @@ -2992,6 +3001,49 @@ mod tests { )); } + #[test] + fn verify_context_applies_transform_count_policy_to_manifest_references() { + // Authenticated Manifest references share the caller's per-reference + // transform ceiling and fail before transform execution when exceeded. + let policy = crate::policy::VerificationPolicy { + process_manifests: true, + resources: crate::policy::ResourcePolicy { + max_transforms_per_reference: 1, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::VerificationPolicy::default() + }; + let xml = signature_with_manifest_xml_with_manifest_mutation(true, |mut xml| { + let manifest_start = xml + .find("", + concat!( + "", + "", + "", + "", + "" + ), + 1, + ); + xml.replace_range(manifest_start.., &manifest); + xml + }); + let result = VerifyContext::new() + .key(&AcceptingKey) + .policy(policy) + .verify(&xml) + .expect("Manifest transform policy is a per-reference result"); + + assert!(matches!(result.status, DsigStatus::Valid)); + assert!(matches!( + result.manifest_references[0].status, + DsigStatus::Invalid(FailureReason::ReferencePolicyViolation { ref_index: 0 }) + )); + } + #[test] fn verify_context_skips_manifest_uri_work_when_signature_is_invalid() { // Missing Manifest URIs remain unauthenticated until SignatureValue diff --git a/src/xmlenc/decrypt.rs b/src/xmlenc/decrypt.rs index 720810e5..a64521f2 100644 --- a/src/xmlenc/decrypt.rs +++ b/src/xmlenc/decrypt.rs @@ -88,56 +88,6 @@ impl<'a> DecryptContext<'a> { } .into()); } - for encrypted_key in &encrypted.encrypted_keys { - let uri = &encrypted_key.encryption_method.algorithm; - if let Ok(transport) = KeyTransportAlgorithm::from_uri(uri) { - if self - .policy - .key_transport_algorithms - .as_ref() - .is_some_and(|allowed| !allowed.contains(&transport)) - { - return Err(crate::policy::PolicyViolation::Algorithm { - operation: "decryption", - algorithm: uri.clone(), - } - .into()); - } - let digest = - parse_oaep_digest(encrypted_key.encryption_method.oaep_digest.as_deref())?; - let mgf_digest = if transport == KeyTransportAlgorithm::RsaOaepMgf1p { - OaepDigestAlgorithm::Sha1 - } else { - parse_oaep_mgf_digest(encrypted_key.encryption_method.mgf_algorithm.as_deref())? - }; - for selected in [digest, mgf_digest] { - if self - .policy - .oaep_digests - .as_ref() - .is_some_and(|allowed| !allowed.contains(&selected)) - { - return Err(crate::policy::PolicyViolation::Algorithm { - operation: "decryption", - algorithm: selected.uri().to_owned(), - } - .into()); - } - } - } else if let Ok(wrap) = KeyWrapAlgorithm::from_uri(uri) - && self - .policy - .key_wrap_algorithms - .as_ref() - .is_some_and(|allowed| !allowed.contains(&wrap)) - { - return Err(crate::policy::PolicyViolation::Algorithm { - operation: "decryption", - algorithm: uri.clone(), - } - .into()); - } - } let ciphertext = STANDARD .decode(&encrypted.cipher_data.value) .map_err(|error| XmlEncError::Base64(error.to_string()))?; @@ -151,6 +101,7 @@ impl<'a> DecryptContext<'a> { algorithm, &encrypted.encrypted_keys, self.resolver, + &self.policy, )?; validate_key_len(algorithm, &key)?; let plaintext = self @@ -552,6 +503,7 @@ fn resolve_content_key( algorithm: DataEncryptionAlgorithm, encrypted_keys: &[EncryptedKey], resolver: &dyn DecryptionKeyResolver, + policy: &crate::policy::DecryptionPolicy, ) -> Result, XmlEncError> { match resolver.resolve_key(provider, algorithm, None) { Ok(key) => return Ok(key), @@ -561,6 +513,10 @@ fn resolve_content_key( let mut last_error = None; for encrypted_key in encrypted_keys { + if let Err(error) = validate_encrypted_key_policy(encrypted_key, policy) { + last_error = Some(error); + continue; + } match resolver.resolve_key(provider, algorithm, Some(encrypted_key)) { Ok(key) => return Ok(key), Err(error) => last_error = Some(error), @@ -569,6 +525,57 @@ fn resolve_content_key( Err(last_error.unwrap_or(XmlEncError::KeyNotFound)) } +fn validate_encrypted_key_policy( + encrypted_key: &EncryptedKey, + policy: &crate::policy::DecryptionPolicy, +) -> Result<(), XmlEncError> { + let uri = &encrypted_key.encryption_method.algorithm; + if let Ok(transport) = KeyTransportAlgorithm::from_uri(uri) { + if policy + .key_transport_algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(&transport)) + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "decryption", + algorithm: uri.clone(), + } + .into()); + } + let digest = parse_oaep_digest(encrypted_key.encryption_method.oaep_digest.as_deref())?; + let mgf_digest = if transport == KeyTransportAlgorithm::RsaOaepMgf1p { + OaepDigestAlgorithm::Sha1 + } else { + parse_oaep_mgf_digest(encrypted_key.encryption_method.mgf_algorithm.as_deref())? + }; + for selected in [digest, mgf_digest] { + if policy + .oaep_digests + .as_ref() + .is_some_and(|allowed| !allowed.contains(&selected)) + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "decryption", + algorithm: selected.uri().to_owned(), + } + .into()); + } + } + } else if let Ok(wrap) = KeyWrapAlgorithm::from_uri(uri) + && policy + .key_wrap_algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(&wrap)) + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "decryption", + algorithm: uri.clone(), + } + .into()); + } + Ok(()) +} + fn validate_key_len(algorithm: DataEncryptionAlgorithm, key: &[u8]) -> Result<(), XmlEncError> { if key.len() == algorithm.key_len() { Ok(()) @@ -587,8 +594,9 @@ fn validate_possible_plaintext_len( maximum: usize, ) -> Result<(), XmlEncError> { let framing = match algorithm { - // CBC always contains a 16-byte IV and at least one padding byte. - DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc => 17, + // CBC always contains a 16-byte IV and at least one complete padded + // block, so this is the greatest plaintext length possible on success. + DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc => 32, DataEncryptionAlgorithm::Aes128Gcm | DataEncryptionAlgorithm::Aes256Gcm => 28, }; validate_plaintext_len(ciphertext_len.saturating_sub(framing), maximum) @@ -636,13 +644,8 @@ fn map_data_decryption_error( ) => XmlEncError::InvalidCbcCiphertextLength(ciphertext_len.saturating_sub(16)), ( DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc, - ProviderError::InvalidInput(crate::provider::ProviderInputError::XmlEncCbcPadding { - pad_len, - }), - ) => XmlEncError::InvalidPadding { - pad_len, - block_size: 16, - }, + ProviderError::InvalidInput(crate::provider::ProviderInputError::XmlEncCbcPadding), + ) => XmlEncError::InvalidPadding, (_, error) => XmlEncError::Provider(error), } } @@ -751,20 +754,25 @@ mod tests { #[test] fn decrypts_with_the_matching_recipient_key() { // Multi-recipient KeyInfo must retain document order and continue after a - // resolver declines an unrelated key before accepting the intended one. + // malformed unrelated key before accepting the intended one. let key = [0x29_u8; 16]; let plaintext = "recipient-specific plaintext"; let encrypted = encrypted_gcm_element("", plaintext, None, true, &key); - let recipient_key = |recipient: &str| { + let recipient_key = |recipient: &str, method: &str| { format!( - "YQ==" + "{}YQ==", + if recipient == "alice" { + "" + } else { + "" + } ) }; let key_info = format!( "{}{}", crate::xmlenc::types::XMLDSIG_NS, - recipient_key("alice"), - recipient_key("bob") + recipient_key("alice", KeyTransportAlgorithm::RsaOaep11.uri()), + recipient_key("bob", "urn:test:recipient-key") ); let xml = encrypted.replacen( "", @@ -1080,7 +1088,7 @@ mod tests { // or plaintext materialization, including the document-declared MGF. let encrypted_key = EncryptedKey { id: None, - recipient: None, + recipient: Some("selected".into()), key_name: None, encryption_method: super::super::EncryptionMethod { algorithm: KeyTransportAlgorithm::RsaOaep11.uri().into(), @@ -1118,9 +1126,12 @@ mod tests { ..crate::policy::DecryptionPolicy::default() }; assert!(matches!( - DecryptContext::new(&SymmetricKeyDecryptor::new([0_u8; 16])) - .policy(policy) - .decrypt_data(&encrypted), + DecryptContext::new(&RecipientKeyResolver { + recipient: "selected", + key: vec![0_u8; 16], + }) + .policy(policy) + .decrypt_data(&encrypted), Err(XmlEncError::Policy( crate::policy::PolicyViolation::Algorithm { .. } )) @@ -1152,6 +1163,53 @@ mod tests { actual: 4 }) )); + + let cbc_ciphertext = crate::provider::default_provider() + .encrypt_data(DataEncryptionAlgorithm::Aes128Cbc, &[0_u8; 16], b"four") + .expect("test CBC encryption must succeed"); + let bounded_cbc = EncryptedData { + encryption_method: super::super::EncryptionMethod { + algorithm: DataEncryptionAlgorithm::Aes128Cbc.uri().into(), + key_size_bits: None, + oaep_digest: None, + mgf_algorithm: None, + oaep_params: None, + }, + encrypted_keys: Vec::new(), + cipher_data: super::super::CipherData { + value: STANDARD.encode(cbc_ciphertext), + }, + ..bounded + }; + let policy = crate::policy::DecryptionPolicy { + resources: crate::policy::ResourcePolicy { + max_encryption_plaintext_bytes: 4, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::DecryptionPolicy::default() + }; + assert_eq!( + DecryptContext::new(&SymmetricKeyDecryptor::new([0_u8; 16])) + .policy(policy) + .decrypt_data(&bounded_cbc) + .expect("CBC plaintext at the configured limit must decrypt"), + DecryptedContent::Bytes(b"four".to_vec()) + ); + } + + #[test] + fn cbc_padding_errors_do_not_expose_decrypted_octets() { + // Public decryption errors must not reveal the attacker-controlled + // final CBC plaintext byte used during padding validation. + let error = map_data_decryption_error( + DataEncryptionAlgorithm::Aes128Cbc, + 32, + crate::provider::ProviderError::InvalidInput( + crate::provider::ProviderInputError::XmlEncCbcPadding, + ), + ); + + assert_eq!(error.to_string(), "invalid XMLEnc padding"); } #[test] diff --git a/src/xmlenc/types.rs b/src/xmlenc/types.rs index 30a6bfd6..e854dd45 100644 --- a/src/xmlenc/types.rs +++ b/src/xmlenc/types.rs @@ -477,14 +477,12 @@ pub enum XmlEncError { /// CBC ciphertext is not a non-empty multiple of the AES block size. #[error("AES-CBC ciphertext length must be a non-zero multiple of 16 bytes, got {0}")] InvalidCbcCiphertextLength(usize), - /// XMLEnc's final random-padding length byte is invalid. - #[error("invalid XMLEnc padding length {pad_len} for {block_size}-byte block")] - InvalidPadding { - /// Padding length from plaintext's final byte. - pad_len: u8, - /// Cipher block size. - block_size: usize, - }, + /// XMLEnc random padding is invalid. + /// + /// No decrypted padding details are exposed because they would provide a + /// CBC padding oracle to callers processing attacker-controlled input. + #[error("invalid XMLEnc padding")] + InvalidPadding, /// GCM authentication failed. #[error("AES-GCM authentication failed")] AeadAuthenticationFailed, diff --git a/tests/donor_full_verification_suite.rs b/tests/donor_full_verification_suite.rs index bff03568..3d0fba61 100644 --- a/tests/donor_full_verification_suite.rs +++ b/tests/donor_full_verification_suite.rs @@ -5,7 +5,7 @@ use std::{ time::{Duration, SystemTime}, }; -use xml_sec::policy::{KeyTrustPolicy, VerificationPolicy}; +use xml_sec::policy::VerificationPolicy; use xml_sec::xmldsig::{ DefaultKeyResolver, DsigStatus, KeyResolverConfig, SignatureAlgorithm, VerificationKey, VerifyContext, @@ -131,6 +131,7 @@ fn donor_full_verification_suite_accepts_every_supported_case() { compatibility_policy.key_trust.allow_legacy_rsa_sha1 = true; for case in cases() { + let mut operation_policy = compatibility_policy.clone(); let resolver = match case.expectation { Expectation::Embedded => DefaultKeyResolver::default(), Expectation::Named { @@ -160,23 +161,19 @@ fn donor_full_verification_suite_accepts_every_supported_case() { }) } Expectation::Chain { trust_anchor_path } => { + operation_policy.key_trust.verify_x509_chains = true; + // 2027-01-15 UTC, inside the donor chain's 2026-2126 validity window. + operation_policy.key_trust.verification_time = + Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000)); DefaultKeyResolver::new(KeyResolverConfig { trusted_certs: vec![read_pem_der(&root.join(trust_anchor_path), "CERTIFICATE")], - trust: KeyTrustPolicy { - verify_x509_chains: true, - // 2027-01-15 UTC, inside the donor chain's 2026-2126 validity window. - verification_time: Some( - SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000), - ), - ..KeyTrustPolicy::default() - }, ..KeyResolverConfig::default() }) } }; let xml = read_fixture(&root.join(case.xml_path)); match VerifyContext::new() - .policy(compatibility_policy.clone()) + .policy(operation_policy) .key_resolver(&resolver) .verify(&xml) { diff --git a/tests/signing_digest.rs b/tests/signing_digest.rs index b81ee672..a4d2bb0b 100644 --- a/tests/signing_digest.rs +++ b/tests/signing_digest.rs @@ -7,11 +7,12 @@ 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::{ - DefaultKeyResolver, DigestAlgorithm, DsigStatus, EcdsaP256SigningKey, EcdsaP384SigningKey, - KeyInfoWriter, ReferenceBuilder, RsaSigningKey, SignContext, SignatureAlgorithm, - SignatureBuilder, SigningDigestError, SigningError, SigningKey, SigningKeyError, - SigningPublicKeyInfo, Transform, X509CertificateKeyInfoWriter, compute_reference_digest_values, - fill_reference_digest_values, parse_key_info, verify_signature_with_pem_key, + DEFAULT_IMPLICIT_C14N_URI, DefaultKeyResolver, DigestAlgorithm, DsigStatus, + EcdsaP256SigningKey, EcdsaP384SigningKey, KeyInfoWriter, ReferenceBuilder, RsaSigningKey, + SignContext, SignatureAlgorithm, SignatureBuilder, SigningDigestError, SigningError, + SigningKey, SigningKeyError, SigningPublicKeyInfo, Transform, X509CertificateKeyInfoWriter, + compute_reference_digest_values, fill_reference_digest_values, parse_key_info, + verify_signature_with_pem_key, }; fn exclusive_c14n() -> C14nAlgorithm { @@ -311,13 +312,15 @@ fn signing_policy_shares_canonicalization_budget_with_signed_info() { .transform(Transform::C14n(exclusive_c14n())), ); let xml = append_signature_to_root( - "canonicalized bytes", + "x", &template, ) .expect("append signature"); - let policy = SigningPolicy { + let constrained = SigningPolicy { resources: xml_sec::policy::ResourcePolicy { - max_canonicalized_bytes: 32, + // The reference serializes below this bound; SignedInfo pushes the + // operation-wide total over it. + max_canonicalized_bytes: 64, ..xml_sec::policy::ResourcePolicy::default() }, ..SigningPolicy::default() @@ -325,10 +328,60 @@ fn signing_policy_shares_canonicalization_budget_with_signed_info() { assert!(matches!( SignContext::new(&private_key) - .policy(policy) + .policy(constrained) .sign_template(&xml), Err(SigningError::Digest(SigningDigestError::Transform(_))) )); + + let sufficient = SigningPolicy { + resources: xml_sec::policy::ResourcePolicy { + max_canonicalized_bytes: 4_096, + ..xml_sec::policy::ResourcePolicy::default() + }, + ..SigningPolicy::default() + }; + SignContext::new(&private_key) + .policy(sufficient) + .sign_template(&xml) + .expect("the same reference must sign when the combined budget fits"); +} + +#[test] +fn signing_policy_covers_implicit_and_signed_info_canonicalization() { + // The transform allowlist covers algorithms executed implicitly by the + // pipeline, not only explicit Reference/Transforms children. + 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 template = + template_with_reference(ReferenceBuilder::new(DigestAlgorithm::Sha256).uri("#payload")); + let xml = append_signature_to_root( + "x", + &template, + ) + .expect("append signature"); + + let implicit_disallowed = SigningPolicy { + transforms: Some(HashSet::from([exclusive_c14n().uri().to_owned()])), + ..SigningPolicy::default() + }; + assert!(matches!( + SignContext::new(&private_key) + .policy(implicit_disallowed) + .sign_template(&xml), + Err(SigningError::Digest(SigningDigestError::Policy(_))) + )); + + let signed_info_disallowed = SigningPolicy { + transforms: Some(HashSet::from([DEFAULT_IMPLICIT_C14N_URI.to_owned()])), + ..SigningPolicy::default() + }; + assert!(matches!( + SignContext::new(&private_key) + .policy(signed_info_disallowed) + .sign_template(&xml), + Err(SigningError::Policy(_)) + )); } #[test]