From 164a9bb4e98dea9c7e484af404bddad90314c4b0 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Tue, 4 Aug 2026 16:14:55 +0300 Subject: [PATCH 01/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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] From ab7abf89e986f6a9992194c43e38479eb19a9a44 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sat, 8 Aug 2026 16:07:16 +0300 Subject: [PATCH 27/63] fix(policy): enforce operation-wide limits - propagate signing XML policy through every parse and mutation stage - bound decryption documents and recipients before allocation and resolution - deduplicate configured X.509 certificates while preserving trust - add regressions and synchronize public documentation --- README.md | 6 +- docs/xmldsig.md | 10 ++- docs/xmlenc.md | 8 +- src/xmldsig/keys.rs | 84 ++++++++++++++----- src/xmldsig/mutation.rs | 119 +++++++++++++++++++++------ src/xmldsig/sign.rs | 47 +++++++++-- src/xmlenc/decrypt.rs | 177 +++++++++++++++++++++++++++++++++++----- src/xmlenc/parse.rs | 58 +++++++++++-- tests/signing_digest.rs | 32 ++++++++ 9 files changed, 454 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index 17a6237c..95394d77 100644 --- a/README.md +++ b/README.md @@ -39,7 +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 +- Typed signing and verification policy covers XML parsing, 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 @@ -50,8 +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; recipient policy is evaluated only for - candidate keys and CBC failures expose no decrypted padding details + Element/Content document replacement; document, node, and aggregate recipient + limits are enforced before expensive work, 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 75c0b62a..fa2ef6a9 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -27,6 +27,10 @@ executes, including the default C14N 1.0 coercion when a reference transform cha 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. +The same immutable policy controls every signing parse and mutation reparse, including source +validation in `sign_with_builder`, digest filling, `SignedInfo` parsing, signature filling, and +optional `KeyInfo` filling. An internal-DTD opt-in and XML node ceiling therefore cannot be lost +between stages. ## Verification Policy @@ -36,7 +40,8 @@ certificates that selector-only `X509Data` may address or use as path intermedia 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 -``. +``. Exact DER duplicates across configured pools are evaluated once; when the same +certificate is present in both pools, its explicit trusted classification is retained. `VerifyResult::status` reports core validation: `Valid` means the cryptographic signature and every `` reference succeeded. `Invalid(reason)` means core validation completed but @@ -74,7 +79,8 @@ 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 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 +the same policy with `TransformOptions::allow_internal_dtd(true)`. Signing uses the corresponding +`SigningPolicy::xml.allow_internal_dtd` decision across its complete pipeline. 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. diff --git a/docs/xmlenc.md b/docs/xmlenc.md index 00e1cdbd..430cee81 100644 --- a/docs/xmlenc.md +++ b/docs/xmlenc.md @@ -70,7 +70,8 @@ fn example(encrypted_xml: &str) -> Result<(), Box> { `PrivateKeyDecryptor` unwraps embedded RSA-OAEP `EncryptedKey` values and `KekDecryptor` unwraps AES-KW values. RSA PKCS#1 v1.5 transport, `CipherReference`, and unauthenticated external resource loading are rejected; only inline `CipherValue` is accepted. Encryption inputs and -recipient counts are bounded before allocation. +recipient counts are bounded before allocation. Decryption applies the same aggregate recipient +ceiling while parsing and rechecks caller-constructed `EncryptedData` before key resolution. 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 @@ -82,7 +83,10 @@ padding removal. Invalid padding is reported only as `XmlEncError::InvalidPaddin 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 +`Id` when the document contains multiple encrypted regions. The compiled decryption policy checks +the caller-owned document byte ceiling before DOM allocation and applies its XML node ceiling to +the initial document, replacement-boundary validation, and final output reparse. The projected +output byte length is checked before constructing the replacement. DTD parsing remains disabled by default; legacy documents that need an internal DTD can opt in through `decrypt_document_with_options` and `DocumentDecryptionOptions`. That API never installs an external entity resolver. diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 56571e66..c50ec549 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -383,12 +383,26 @@ impl DefaultKeyResolver { ..X509DataInfo::default() }; let mut matches = Vec::new(); - for certificate_der in self + let mut trusted_prefix_len = 0usize; + for (trusted, certificate_der) in self .config .trusted_certs .iter() - .chain(&self.config.lookup_certs) + .map(|certificate| (true, certificate)) + .chain( + self.config + .lookup_certs + .iter() + .map(|certificate| (false, certificate)), + ) { + if available + .certificates + .iter() + .any(|available_der| available_der == certificate_der) + { + continue; + } let parsed = parse_x509_certificate(certificate_der) .map_err(|_| KeyResolutionError::InvalidCertificate)?; let is_match = x509_certificate_matches_any_selector(info, &parsed, certificate_der) @@ -403,6 +417,9 @@ impl DefaultKeyResolver { } available.certificates.push(certificate_der.clone()); available.parsed_certificates.push(parsed); + if trusted { + trusted_prefix_len += 1; + } } let matched_chain = X509DataInfo { @@ -451,19 +468,15 @@ 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() || !trust.verify_x509_chains { - vec![signing_index] - } else { - 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() { + available.certificate_chain = if signing_index < trusted_prefix_len + || !trust.verify_x509_chains + { + vec![signing_index] + } else { + self.select_valid_x509_path(&mut available, signing_index, trusted_prefix_len, trust)?; + available.certificate_chain.clone() + }; + if trust.verify_x509_chains && signing_index < trusted_prefix_len { self.verify_x509_policy(&available, trust)?; } Ok(Some(available)) @@ -1568,17 +1581,48 @@ mod tests { } #[test] - fn ambiguous_x509_selector_fails_closed() { - // Duplicate configured certificates must not make key selection order-dependent. + fn overlapping_trusted_and_lookup_certificate_preserves_trust() { + // One physical certificate appearing in both pools is one candidate; + // deduplication must retain the stronger trusted classification. let certificate = certificate_der(RSA_4096_CERTIFICATE); let resolver = DefaultKeyResolver::new(KeyResolverConfig { - lookup_certs: vec![certificate.clone(), certificate], + trusted_certs: vec![certificate.clone()], + lookup_certs: vec![certificate], ..KeyResolverConfig::default() }); - let error = super::super::VerifyContext::new() + let result = super::super::VerifyContext::new() .key_resolver(&resolver) .verify(&x509_signature_with_leaf_subject()) - .expect_err("ambiguous X509 selector lookup must fail closed"); + .expect("trusted/lookup overlap must resolve as one trusted candidate"); + + assert_eq!(result.status, super::super::DsigStatus::Valid); + } + + #[test] + fn distinct_x509_selector_matches_remain_ambiguous() { + // Deduplication is identity-based, not selector-based: two distinct + // certificates with the same subject remain separate candidates. + let certificate = || { + generated_certificate_params("ambiguous selector", false) + .self_signed( + &rcgen::KeyPair::generate().expect("test key generation should succeed"), + ) + .expect("test certificate should be self-signable") + .der() + .to_vec() + }; + let xml = replace_unprefixed_key_info( + X509_DIGEST_SIGNATURE, + "CN=ambiguous selector", + ); + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + lookup_certs: vec![certificate(), certificate()], + ..KeyResolverConfig::default() + }); + let error = super::super::VerifyContext::new() + .key_resolver(&resolver) + .verify(&xml) + .expect_err("distinct selector matches must fail closed"); assert!(matches!( error, diff --git a/src/xmldsig/mutation.rs b/src/xmldsig/mutation.rs index 82a2df3f..c837032a 100644 --- a/src/xmldsig/mutation.rs +++ b/src/xmldsig/mutation.rs @@ -12,6 +12,24 @@ use quick_xml::{Reader, Writer}; use super::parse::XMLDSIG_NS; +fn parse_with_options<'a>( + xml: &'a str, + policy: Option<&crate::policy::SigningPolicy>, +) -> Result, roxmltree::Error> { + let Some(policy) = policy else { + return roxmltree::Document::parse(xml); + }; + roxmltree::Document::parse_with_options( + xml, + roxmltree::ParsingOptions { + allow_dtd: policy.xml.allow_internal_dtd, + nodes_limit: u32::try_from(policy.resources.max_xml_nodes) + .unwrap_or(crate::hard_limits::XML_DOCUMENT_NODE_CEILING), + entity_resolver: None, + }, + ) +} + /// Errors produced by XMLDSig XML mutation helpers. #[derive(Debug, thiserror::Error)] pub enum XmlMutationError { @@ -50,9 +68,17 @@ pub enum XmlMutationError { pub fn append_signature_to_root( xml: &str, signature_template: &str, +) -> Result { + append_signature_to_root_with_options(xml, signature_template, None) +} + +pub(super) fn append_signature_to_root_with_options( + xml: &str, + signature_template: &str, + policy: Option<&crate::policy::SigningPolicy>, ) -> Result { validate_signature_template(signature_template)?; - let source = roxmltree::Document::parse(xml)?; + let source = parse_with_options(xml, policy)?; if !source.root().children().any(|node| node.is_element()) { return Err(XmlMutationError::MissingRootElement); } @@ -100,7 +126,7 @@ pub fn append_signature_to_root( } let output = String::from_utf8(writer.into_inner())?; - roxmltree::Document::parse(&output)?; + parse_with_options(&output, policy)?; Ok(output) } @@ -118,6 +144,18 @@ pub fn fill_signed_info_digest_values( xml: &str, values: I, ) -> Result +where + I: IntoIterator, + S: AsRef, +{ + fill_signed_info_digest_values_with_options(xml, values, None) +} + +pub(super) fn fill_signed_info_digest_values_with_options( + xml: &str, + values: I, + policy: Option<&crate::policy::SigningPolicy>, +) -> Result where I: IntoIterator, S: AsRef, @@ -126,8 +164,8 @@ where .into_iter() .map(|value| value.as_ref().to_owned()) .collect(); - let target_signature = last_signature_index(xml)?; - let expected = count_signed_info_digest_values(xml)?; + let target_signature = last_signature_index(xml, policy)?; + let expected = count_signed_info_digest_values(xml, policy)?; if expected != values.len() { return Err(XmlMutationError::ValueCountMismatch { element: "DigestValue", @@ -136,7 +174,7 @@ where }); } - fill_dsig_values_matching(xml, "DigestValue", values, |stack, namespace| { + fill_dsig_values_matching(xml, "DigestValue", values, policy, |stack, namespace| { is_signed_info_reference_context(stack, namespace, target_signature) }) } @@ -152,8 +190,16 @@ where /// Fill the direct `/` child for a signing template. pub fn fill_signature_value(xml: &str, value: &str) -> Result { - let target_signature = last_signature_index(xml)?; - let expected = count_direct_signature_values(xml)?; + fill_signature_value_with_options(xml, value, None) +} + +pub(super) fn fill_signature_value_with_options( + xml: &str, + value: &str, + policy: Option<&crate::policy::SigningPolicy>, +) -> Result { + let target_signature = last_signature_index(xml, policy)?; + let expected = count_direct_signature_values(xml, policy)?; if expected != 1 { return Err(XmlMutationError::ValueCountMismatch { element: "SignatureValue", @@ -166,14 +212,23 @@ pub fn fill_signature_value(xml: &str, value: &str) -> Result/` child with XML child content. pub fn fill_key_info(xml: &str, key_info_content: &str) -> Result { - let target_signature = last_signature_index(xml)?; - let expected = count_direct_key_infos(xml)?; + fill_key_info_with_options(xml, key_info_content, None) +} + +pub(super) fn fill_key_info_with_options( + xml: &str, + key_info_content: &str, + policy: Option<&crate::policy::SigningPolicy>, +) -> Result { + let target_signature = last_signature_index(xml, policy)?; + let expected = count_direct_key_infos(xml, policy)?; if expected != 1 { return Err(XmlMutationError::ValueCountMismatch { element: "KeyInfo", @@ -182,9 +237,13 @@ pub fn fill_key_info(xml: &str, key_info_content: &str) -> Result( @@ -209,13 +268,14 @@ where }); } - fill_dsig_values_matching(xml, local_name, values, |_, _| true) + fill_dsig_values_matching(xml, local_name, values, None, |_, _| true) } fn fill_dsig_values_matching( xml: &str, local_name: &'static str, values: Vec, + policy: Option<&crate::policy::SigningPolicy>, mut should_replace: impl FnMut(&[(bool, Vec, Option)], &ResolveResult<'_>) -> bool, ) -> Result { let mut reader = NsReader::from_str(xml); @@ -318,7 +378,7 @@ fn fill_dsig_values_matching( } let output = String::from_utf8(writer.into_inner())?; - roxmltree::Document::parse(&output)?; + parse_with_options(&output, policy)?; Ok(output) } @@ -326,6 +386,7 @@ fn fill_dsig_element_raw_matching( xml: &str, local_name: &'static str, content: &str, + policy: Option<&crate::policy::SigningPolicy>, mut should_replace: impl FnMut(&[(bool, Vec, Option)], &ResolveResult<'_>) -> bool, ) -> Result { let mut reader = NsReader::from_str(xml); @@ -417,7 +478,7 @@ fn fill_dsig_element_raw_matching( } let output = String::from_utf8(writer.into_inner())?; - roxmltree::Document::parse(&output)?; + parse_with_options(&output, policy)?; Ok(output) } @@ -443,8 +504,11 @@ fn count_dsig_elements(xml: &str, local_name: &str) -> Result Result { - let document = roxmltree::Document::parse(xml)?; +fn count_signed_info_digest_values( + xml: &str, + policy: Option<&crate::policy::SigningPolicy>, +) -> Result { + let document = parse_with_options(xml, policy)?; let Some(signature) = last_signature_node(&document) else { return Ok(0); }; @@ -454,8 +518,11 @@ fn count_signed_info_digest_values(xml: &str) -> Result .count()) } -fn count_direct_signature_values(xml: &str) -> Result { - let document = roxmltree::Document::parse(xml)?; +fn count_direct_signature_values( + xml: &str, + policy: Option<&crate::policy::SigningPolicy>, +) -> Result { + let document = parse_with_options(xml, policy)?; let Some(signature) = last_signature_node(&document) else { return Ok(0); }; @@ -470,8 +537,11 @@ fn count_direct_signature_values(xml: &str) -> Result { .count()) } -fn count_direct_key_infos(xml: &str) -> Result { - let document = roxmltree::Document::parse(xml)?; +fn count_direct_key_infos( + xml: &str, + policy: Option<&crate::policy::SigningPolicy>, +) -> Result { + let document = parse_with_options(xml, policy)?; let Some(signature) = last_signature_node(&document) else { return Ok(0); }; @@ -494,8 +564,11 @@ fn last_signature_node<'a>( .rfind(|node| is_dsig_node(*node, "Signature")) } -fn last_signature_index(xml: &str) -> Result { - let document = roxmltree::Document::parse(xml)?; +fn last_signature_index( + xml: &str, + policy: Option<&crate::policy::SigningPolicy>, +) -> Result { + let document = parse_with_options(xml, policy)?; document .descendants() .filter(|node| is_dsig_node(*node, "Signature")) diff --git a/src/xmldsig/sign.rs b/src/xmldsig/sign.rs index 5d7c5aa4..7559e8cb 100644 --- a/src/xmldsig/sign.rs +++ b/src/xmldsig/sign.rs @@ -10,7 +10,7 @@ use getrandom::SysRng; use p256::ecdsa::{Signature as P256Signature, SigningKey as P256SigningKey}; use p256::pkcs8::{DecodePrivateKey, EncodePublicKey}; use p384::ecdsa::{Signature as P384Signature, SigningKey as P384SigningKey}; -use roxmltree::{Document, Node}; +use roxmltree::{Document, Node, ParsingOptions}; use rsa::RsaPrivateKey; use rsa::pkcs1v15::Signature as RsaPkcs1v15Signature; use rsa::pkcs1v15::SigningKey as RsaPkcs1v15SigningKey; @@ -25,8 +25,9 @@ use crate::c14n::{canonicalize_bounded, is_output_limit_error}; use super::builder::{SignatureBuilder, SignatureBuilderError}; use super::digest::DigestAlgorithm; use super::mutation::{ - XmlMutationError, append_signature_to_root, fill_key_info, fill_signature_value, - fill_signed_info_digest_values, + XmlMutationError, append_signature_to_root_with_options, fill_key_info_with_options, + fill_signature_value_with_options, fill_signed_info_digest_values, + fill_signed_info_digest_values_with_options, }; use super::parse::{ MAX_REFERENCES_PER_SIGNATURE, SignatureAlgorithm, XMLDSIG_NS, parse_signed_info, @@ -578,10 +579,15 @@ impl<'a> SignContext<'a> { 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)?; + let signed = + fill_signature_value_with_options(&with_digests, &signature_b64, Some(&self.policy))?; if let Some(writer) = self.key_info_writer { let key_info_content = writer.write_key_info(self.signing_key)?; - Ok(fill_key_info(&signed, &key_info_content)?) + Ok(fill_key_info_with_options( + &signed, + &key_info_content, + Some(&self.policy), + )?) } else { Ok(signed) } @@ -593,8 +599,9 @@ impl<'a> SignContext<'a> { xml: &str, builder: &SignatureBuilder, ) -> Result { + self.policy.resources.validate()?; let template = builder.build_template()?; - let templated = append_signature_to_root(xml, &template)?; + let templated = append_signature_to_root_with_options(xml, &template, Some(&self.policy))?; self.sign_template(&templated) } } @@ -632,7 +639,7 @@ fn compute_reference_digest_values_with_options( provider: &dyn crate::provider::CryptoProvider, execution_budget: &TransformExecutionBudget, ) -> Result, SigningDigestError> { - let doc = Document::parse(xml)?; + let doc = parse_signing_document(xml, policy)?; let signature = find_signing_signature_node(&doc)?; let signed_info = find_required_child(signature, "SignedInfo")?; let references = parse_signing_references(signed_info)?; @@ -755,7 +762,11 @@ fn fill_reference_digest_values_with_options( )? .into_iter() .map(|digest| digest.digest_value); - Ok(fill_signed_info_digest_values(xml, digest_values)?) + Ok(if let Some(policy) = policy { + fill_signed_info_digest_values_with_options(xml, digest_values, Some(policy))? + } else { + fill_signed_info_digest_values(xml, digest_values)? + }) } fn canonicalize_signed_info( @@ -763,7 +774,7 @@ fn canonicalize_signed_info( policy: &crate::policy::SigningPolicy, execution_budget: &TransformExecutionBudget, ) -> Result<(SignatureAlgorithm, Vec), SigningError> { - let doc = Document::parse(xml).map_err(SigningDigestError::XmlParse)?; + let doc = parse_signing_document(xml, Some(policy)).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)?; @@ -805,6 +816,24 @@ fn canonicalize_signed_info( Ok((signed_info.signature_method, canonical_signed_info)) } +fn parse_signing_document<'a>( + xml: &'a str, + policy: Option<&crate::policy::SigningPolicy>, +) -> Result, roxmltree::Error> { + let Some(policy) = policy else { + return Document::parse(xml); + }; + Document::parse_with_options( + xml, + ParsingOptions { + allow_dtd: policy.xml.allow_internal_dtd, + nodes_limit: u32::try_from(policy.resources.max_xml_nodes) + .unwrap_or(crate::hard_limits::XML_DOCUMENT_NODE_CEILING), + entity_resolver: None, + }, + ) +} + fn parse_private_key_pem(private_key_pem: &str) -> Result, SigningKeyError> { let (rest, pem) = x509_parser::pem::parse_x509_pem(private_key_pem.as_bytes()) .map_err(|_| SigningKeyError::InvalidKeyPem)?; diff --git a/src/xmlenc/decrypt.rs b/src/xmlenc/decrypt.rs index a64521f2..38e5ac9b 100644 --- a/src/xmlenc/decrypt.rs +++ b/src/xmlenc/decrypt.rs @@ -6,14 +6,17 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; use roxmltree::{Document, ParsingOptions}; use rsa::RsaPrivateKey; -use super::parse::parse_encrypted_data_node; +use super::parse::{parse_encrypted_data_node_with_limit, parse_encrypted_data_with_policy}; use super::types::XMLENC_NS; use super::{ DataEncryptionAlgorithm, DecryptedContent, EncryptedData, EncryptedDataType, EncryptedKey, KeyTransportAlgorithm, KeyWrapAlgorithm, OaepDigestAlgorithm, RsaOaepParameters, XmlEncError, - has_single_element_with_boundary_trivia, parse_encrypted_data, + has_single_element_with_boundary_trivia, }; +#[cfg(test)] +use super::parse_encrypted_data; + /// Supplies a content-encryption key for parsed XMLEnc data. pub trait DecryptionKeyResolver { /// Resolve the symmetric key for `algorithm`, optionally unwrapping `encrypted_key`. @@ -68,13 +71,17 @@ impl<'a> DecryptContext<'a> { /// Parse and decrypt a standalone `EncryptedData` XML fragment. pub fn decrypt(&self, xml: &str) -> Result { - let encrypted = parse_encrypted_data(xml)?; + let encrypted = parse_encrypted_data_with_policy(xml, &self.policy)?; self.decrypt_data(&encrypted) } /// Decrypt an already parsed `EncryptedData` value. pub fn decrypt_data(&self, encrypted: &EncryptedData) -> Result { self.policy.resources.validate()?; + validate_recipient_count( + encrypted.encrypted_keys.len(), + self.policy.resources.max_encryption_recipients, + )?; let algorithm = DataEncryptionAlgorithm::from_uri(&encrypted.encryption_method.algorithm)?; if self .policy @@ -392,11 +399,9 @@ fn decrypt_document_with_context( encrypted_data_id: Option<&str>, context: &DecryptContext<'_>, ) -> Result { - let parsing_options = || ParsingOptions { - allow_dtd: context.policy.xml.allow_internal_dtd, - entity_resolver: None, - ..ParsingOptions::default() - }; + context.policy.resources.validate()?; + validate_encryption_document_len(xml.len(), &context.policy)?; + let parsing_options = || decryption_parsing_options(&context.policy); let document = Document::parse_with_options(xml, parsing_options())?; let mut matches = document.descendants().filter(|node| { node.has_tag_name((XMLENC_NS, "EncryptedData")) @@ -408,21 +413,26 @@ fn decrypt_document_with_context( } let range = selected.range(); - let encrypted = parse_encrypted_data_node(selected)?; + let encrypted = parse_encrypted_data_node_with_limit( + selected, + context.policy.resources.max_encryption_recipients, + )?; let DecryptedContent::Xml(plaintext) = context.decrypt_data(&encrypted)? else { return Err(XmlEncError::ReplacementRequiresXml); }; + let output_len = xml.len() - range.len() + plaintext.len(); + validate_encryption_document_len(output_len, &context.policy)?; validate_plaintext_fragment( xml, range.start, range.end, &plaintext, encrypted.encrypted_type.as_ref(), - context.policy.xml.allow_internal_dtd, + &context.policy, )?; - let mut output = String::with_capacity(xml.len() - range.len() + plaintext.len()); + let mut output = String::with_capacity(output_len); output.push_str(&xml[..range.start]); output.push_str(&plaintext); output.push_str(&xml[range.end..]); @@ -436,7 +446,7 @@ fn validate_plaintext_fragment( replacement_end: usize, plaintext: &str, encrypted_type: Option<&EncryptedDataType>, - allow_dtd: bool, + policy: &crate::policy::DecryptionPolicy, ) -> Result<(), XmlEncError> { const WRAPPER_NS: &str = "urn:structured-world:xml-sec:decrypted-fragment"; const WRAPPER_START: &str = ""; @@ -456,14 +466,7 @@ fn validate_plaintext_fragment( wrapped.push_str(WRAPPER_END); wrapped.push_str(&xml[replacement_end..]); - let document = Document::parse_with_options( - &wrapped, - ParsingOptions { - allow_dtd, - entity_resolver: None, - ..ParsingOptions::default() - }, - )?; + let document = Document::parse_with_options(&wrapped, decryption_parsing_options(policy))?; let wrapper = document .descendants() .find(|node| { @@ -490,6 +493,42 @@ fn validate_plaintext_fragment( Ok(()) } +fn decryption_parsing_options<'a>(policy: &crate::policy::DecryptionPolicy) -> ParsingOptions<'a> { + ParsingOptions { + allow_dtd: policy.xml.allow_internal_dtd, + nodes_limit: u32::try_from(policy.resources.max_xml_nodes) + .unwrap_or(crate::hard_limits::XML_DOCUMENT_NODE_CEILING), + entity_resolver: None, + } +} + +fn validate_encryption_document_len( + actual: usize, + policy: &crate::policy::DecryptionPolicy, +) -> Result<(), XmlEncError> { + if actual > policy.resources.max_encryption_document_bytes { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: "encryption document", + maximum: policy.resources.max_encryption_document_bytes, + actual, + } + .into()); + } + Ok(()) +} + +fn validate_recipient_count(actual: usize, maximum: usize) -> Result<(), XmlEncError> { + if actual > maximum { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: "encryption recipients", + maximum, + actual, + } + .into()); + } + Ok(()) +} + /// Decrypt an already parsed `EncryptedData` value. pub fn decrypt_data( encrypted: &EncryptedData, @@ -790,6 +829,58 @@ mod tests { ); } + #[test] + fn decryption_policy_bounds_recipients_before_key_resolution() { + // Both XML parsing and caller-constructed typed input must reject an + // oversized recipient set before any resolver can inspect candidates. + let key = [0x29_u8; 16]; + let encrypted = encrypted_gcm_element("", "bounded recipients", None, true, &key); + let recipient_key = |recipient: &str| { + format!( + "YQ==" + ) + }; + let key_info = format!( + "{}{}", + crate::xmlenc::types::XMLDSIG_NS, + recipient_key("alice"), + recipient_key("bob") + ); + let xml = encrypted.replacen( + "", + &format!("{key_info}"), + 1, + ); + let parsed = parse_encrypted_data(&xml).expect("default parser accepts two recipients"); + let policy = crate::policy::DecryptionPolicy { + resources: crate::policy::ResourcePolicy { + max_encryption_recipients: 1, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::DecryptionPolicy::default() + }; + let resolver = SymmetricKeyDecryptor::new(key); + let context = DecryptContext::new(&resolver).policy(policy); + + for error in [ + context + .decrypt(&xml) + .expect_err("XML recipient collection must be bounded"), + context + .decrypt_data(&parsed) + .expect_err("typed recipient collection must be bounded"), + ] { + assert!(matches!( + error, + XmlEncError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: "encryption recipients", + maximum: 1, + actual: 2, + }) + )); + } + } + #[test] fn decrypts_session_key_wrapped_with_aes_kw() { // RFC 3394 unwrap must recover exactly the content algorithm's key length. @@ -1424,6 +1515,52 @@ mod tests { } } + #[test] + fn document_decryption_applies_byte_and_node_policy_before_parsing() { + // Caller-owned XML must meet the compiled resource policy before the + // initial DOM allocation; reparsed output uses the same node ceiling. + let key = [0x38_u8; 16]; + let encrypted = encrypted_gcm_element( + "http://www.w3.org/2001/04/xmlenc#Content", + "plaintext", + None, + false, + &key, + ); + let document = format!("{encrypted}"); + let byte_policy = crate::policy::DecryptionPolicy { + resources: crate::policy::ResourcePolicy { + max_encryption_document_bytes: document.len() - 1, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::DecryptionPolicy::default() + }; + assert!(matches!( + DecryptContext::new(&SymmetricKeyDecryptor::new(key)) + .policy(byte_policy) + .decrypt_document(&document, None), + Err(XmlEncError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: "encryption document", + maximum, + actual, + })) if maximum == document.len() - 1 && actual == document.len() + )); + + let node_policy = crate::policy::DecryptionPolicy { + resources: crate::policy::ResourcePolicy { + max_xml_nodes: 3, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::DecryptionPolicy::default() + }; + assert!(matches!( + DecryptContext::new(&SymmetricKeyDecryptor::new(key)) + .policy(node_policy) + .decrypt_document(&document, None), + Err(XmlEncError::XmlParse(error)) if error.to_string() == "nodes limit reached" + )); + } + #[test] fn validates_replacement_plaintext_in_its_namespace_context() { // Decrypted fragments inherit namespaces from the encrypted node's diff --git a/src/xmlenc/parse.rs b/src/xmlenc/parse.rs index e33e1fe5..2946c5bc 100644 --- a/src/xmlenc/parse.rs +++ b/src/xmlenc/parse.rs @@ -1,7 +1,7 @@ //! Strict parsing for the subset of XMLEnc needed by the decryption API. use base64::{Engine as _, engine::general_purpose::STANDARD}; -use roxmltree::{Document, Node}; +use roxmltree::{Document, Node, ParsingOptions}; use super::types::{ CipherData, EncryptedData, EncryptedDataType, EncryptedKey, EncryptionMethod, @@ -15,20 +15,51 @@ struct ParsedKeyInfo { /// Parse one `xenc:EncryptedData` document fragment. pub fn parse_encrypted_data(xml: &str) -> Result { - let document = Document::parse(xml)?; - parse_encrypted_data_node(document.root_element()) + parse_encrypted_data_with_policy(xml, &crate::policy::DecryptionPolicy::default()) } -pub(super) fn parse_encrypted_data_node(node: Node<'_, '_>) -> Result { +pub(super) fn parse_encrypted_data_with_policy( + xml: &str, + policy: &crate::policy::DecryptionPolicy, +) -> Result { + policy.resources.validate()?; + if xml.len() > policy.resources.max_encryption_document_bytes { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: "encryption document", + maximum: policy.resources.max_encryption_document_bytes, + actual: xml.len(), + } + .into()); + } + let document = Document::parse_with_options( + xml, + ParsingOptions { + allow_dtd: policy.xml.allow_internal_dtd, + nodes_limit: u32::try_from(policy.resources.max_xml_nodes) + .unwrap_or(crate::hard_limits::XML_DOCUMENT_NODE_CEILING), + entity_resolver: None, + }, + )?; + parse_encrypted_data_node_with_limit( + document.root_element(), + policy.resources.max_encryption_recipients, + ) +} + +pub(super) fn parse_encrypted_data_node_with_limit( + node: Node<'_, '_>, + max_encryption_recipients: usize, +) -> Result { require_element(node, XMLENC_NS, "EncryptedData")?; let mut children = element_children(node); let encryption_method = parse_encryption_method(next_required(&mut children, "EncryptionMethod")?)?; let key_info = match children.peek() { - Some(child) if child.has_tag_name((XMLDSIG_NS, "KeyInfo")) => { - parse_key_info(next_required(&mut children, "KeyInfo")?)? - } + Some(child) if child.has_tag_name((XMLDSIG_NS, "KeyInfo")) => parse_key_info( + next_required(&mut children, "KeyInfo")?, + max_encryption_recipients, + )?, _ => ParsedKeyInfo { key_name: None, encrypted_keys: Vec::new(), @@ -53,7 +84,10 @@ pub(super) fn parse_encrypted_data_node(node: Node<'_, '_>) -> Result) -> Result { +fn parse_key_info( + node: Node<'_, '_>, + max_encryption_recipients: usize, +) -> Result { require_element(node, XMLDSIG_NS, "KeyInfo")?; let mut key_name = None; let mut encrypted_keys = Vec::new(); @@ -67,6 +101,14 @@ fn parse_key_info(node: Node<'_, '_>) -> Result { } key_name = Some(parse_key_name(child)?); } else if child.has_tag_name((XMLENC_NS, "EncryptedKey")) { + if encrypted_keys.len() == max_encryption_recipients { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: "encryption recipients", + maximum: max_encryption_recipients, + actual: encrypted_keys.len() + 1, + } + .into()); + } encrypted_keys.push(parse_encrypted_key(child)?); } else if child.has_tag_name((XMLENC_NS, "AgreementMethod")) { // Agreement methods require a separate key-derivation trust boundary. diff --git a/tests/signing_digest.rs b/tests/signing_digest.rs index a4d2bb0b..f17f2064 100644 --- a/tests/signing_digest.rs +++ b/tests/signing_digest.rs @@ -384,6 +384,38 @@ fn signing_policy_covers_implicit_and_signed_info_canonicalization() { )); } +#[test] +fn signing_internal_dtd_policy_reaches_builder_and_mutation_reparses() { + // The parser opt-in is operation-wide: source validation, digest mutation, + // SignedInfo canonicalization, and final mutation must agree on DTD policy. + 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 builder = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::RsaSha256) + .add_reference( + ReferenceBuilder::new(DigestAlgorithm::Sha256) + .uri("#payload") + .transform(Transform::C14n(exclusive_c14n())), + ); + let xml = "]>&value;"; + + assert!(matches!( + SignContext::new(&private_key).sign_with_builder(xml, &builder), + Err(SigningError::XmlMutation( + xml_sec::xmldsig::mutation::XmlMutationError::XmlParse(roxmltree::Error::DtdDetected) + )) + )); + + let mut policy = SigningPolicy::default(); + policy.xml.allow_internal_dtd = true; + let signed = SignContext::new(&private_key) + .policy(policy) + .sign_with_builder(xml, &builder) + .expect("internal-DTD opt-in must reach every signing parse and reparse"); + assert!(!signed.contains("")); + assert!(!signed.contains("")); +} + #[test] fn fills_only_signed_info_reference_digest_values() { // Manifests can contain their own DigestValue elements inside the same From 9cb97ce7fe3cb626900088f4afa38e5b65a3384f Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sat, 8 Aug 2026 20:20:30 +0300 Subject: [PATCH 28/63] fix(policy): enforce operation resource limits - propagate XML node and canonicalization ceilings through signing, verification, encryption, and detached transforms - bound XMLEnc metadata for parsed and caller-constructed inputs - tighten trust tests and document unauthenticated CBC behavior --- README.md | 3 +- docs/xmlenc.md | 7 +- src/provider.rs | 5 +- src/xmldsig/keys.rs | 8 +- src/xmldsig/mutation.rs | 2 +- src/xmldsig/sign.rs | 19 +- src/xmldsig/transforms.rs | 45 ++++- src/xmldsig/types.rs | 4 + src/xmldsig/verify.rs | 74 +++++++- src/xmlenc/decrypt.rs | 63 ++++++- src/xmlenc/encrypt.rs | 57 +++++- src/xmlenc/parse.rs | 386 +++++++++++++++++++++++++++++++------- src/xmlenc/types.rs | 4 +- 13 files changed, 554 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index 95394d77..183823af 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,8 @@ Currently implemented (core paths): - 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; document, node, and aggregate recipient - limits are enforced before expensive work, and CBC failures expose no decrypted padding details + limits are enforced before expensive work. CBC failures expose no decrypted + padding details, but CBC remains unauthenticated and can be excluded by policy Still in progress: - XMLDSig DSA-SHA256, broader HMAC verification/signing, and RSA-PSS algorithms diff --git a/docs/xmlenc.md b/docs/xmlenc.md index 430cee81..98670efc 100644 --- a/docs/xmlenc.md +++ b/docs/xmlenc.md @@ -71,7 +71,8 @@ fn example(encrypted_xml: &str) -> Result<(), Box> { unwraps AES-KW values. RSA PKCS#1 v1.5 transport, `CipherReference`, and unauthenticated external resource loading are rejected; only inline `CipherValue` is accepted. Encryption inputs and recipient counts are bounded before allocation. Decryption applies the same aggregate recipient -ceiling while parsing and rechecks caller-constructed `EncryptedData` before key resolution. +ceiling while parsing, bounds each retained identifier, algorithm URI, key name, OAEP label, and +reference URI, and rechecks caller-constructed `EncryptedData` before key resolution. 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 @@ -81,6 +82,10 @@ symmetric key remains authoritative and does not consult unrelated embedded key 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. +That uniform diagnostic does not authenticate CBC or remove the success-versus-failure signal. +Applications processing attacker-controlled ciphertext must authenticate the enclosing protocol +before acting on plaintext, or exclude AES-CBC with `EncryptionPolicy::data_algorithms` and use +AES-GCM. Use `decrypt_document` to replace one typed `EncryptedData` in a complete XML string. Pass its `Id` when the document contains multiple encrypted regions. The compiled decryption policy checks diff --git a/src/provider.rs b/src/provider.rs index 0dbc0653..099ef262 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -71,8 +71,9 @@ pub enum ProviderInputError { AesCbcPlaintext, /// XMLEnc CBC padding length is outside the valid block range. /// - /// This variant deliberately carries no decrypted bytes: provider errors - /// may cross a trust boundary and must not become a padding oracle. + /// This variant deliberately carries no decrypted bytes or padding length. + /// CBC remains unauthenticated, so callers must authenticate ciphertexts + /// before acting on decryption results or reject CBC through operation policy. #[error("invalid XMLEnc CBC padding")] XmlEncCbcPadding, /// AES-GCM input does not contain a nonce and authentication tag. diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index c50ec549..982cf5f1 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -1330,7 +1330,12 @@ mod tests { Err(error) => error, }; - assert!(matches!(error, DsigError::KeyResolution(_))); + assert!(matches!( + error, + DsigError::KeyResolution(KeyResolutionError::Chain( + super::super::X509ChainError::UntrustedRoot + )) + )); } #[test] @@ -1588,6 +1593,7 @@ mod tests { let resolver = DefaultKeyResolver::new(KeyResolverConfig { trusted_certs: vec![certificate.clone()], lookup_certs: vec![certificate], + trust: chain_policy_at(fixture_certificate_time()), ..KeyResolverConfig::default() }); let result = super::super::VerifyContext::new() diff --git a/src/xmldsig/mutation.rs b/src/xmldsig/mutation.rs index c837032a..49b3aa0d 100644 --- a/src/xmldsig/mutation.rs +++ b/src/xmldsig/mutation.rs @@ -12,7 +12,7 @@ use quick_xml::{Reader, Writer}; use super::parse::XMLDSIG_NS; -fn parse_with_options<'a>( +pub(super) fn parse_with_options<'a>( xml: &'a str, policy: Option<&crate::policy::SigningPolicy>, ) -> Result, roxmltree::Error> { diff --git a/src/xmldsig/sign.rs b/src/xmldsig/sign.rs index 7559e8cb..a9a655b4 100644 --- a/src/xmldsig/sign.rs +++ b/src/xmldsig/sign.rs @@ -10,7 +10,7 @@ use getrandom::SysRng; use p256::ecdsa::{Signature as P256Signature, SigningKey as P256SigningKey}; use p256::pkcs8::{DecodePrivateKey, EncodePublicKey}; use p384::ecdsa::{Signature as P384Signature, SigningKey as P384SigningKey}; -use roxmltree::{Document, Node, ParsingOptions}; +use roxmltree::{Document, Node}; use rsa::RsaPrivateKey; use rsa::pkcs1v15::Signature as RsaPkcs1v15Signature; use rsa::pkcs1v15::SigningKey as RsaPkcs1v15SigningKey; @@ -544,9 +544,7 @@ 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 execution_budget = TransformExecutionBudget::from_resources(&self.policy.resources); let transform_options = TransformOptions::default() .allow_internal_dtd(self.policy.xml.allow_internal_dtd) .xpath_here_semantics(self.policy.xpath_here_semantics); @@ -820,18 +818,7 @@ fn parse_signing_document<'a>( xml: &'a str, policy: Option<&crate::policy::SigningPolicy>, ) -> Result, roxmltree::Error> { - let Some(policy) = policy else { - return Document::parse(xml); - }; - Document::parse_with_options( - xml, - ParsingOptions { - allow_dtd: policy.xml.allow_internal_dtd, - nodes_limit: u32::try_from(policy.resources.max_xml_nodes) - .unwrap_or(crate::hard_limits::XML_DOCUMENT_NODE_CEILING), - entity_resolver: None, - }, - ) + super::mutation::parse_with_options(xml, policy) } fn parse_private_key_pem(private_key_pem: &str) -> Result, SigningKeyError> { diff --git a/src/xmldsig/transforms.rs b/src/xmldsig/transforms.rs index 24a82345..6d14b1f3 100644 --- a/src/xmldsig/transforms.rs +++ b/src/xmldsig/transforms.rs @@ -96,13 +96,26 @@ pub struct TransformOptions { allow_internal_dtd: bool, } -#[derive(Default)] pub(crate) struct TransformExecutionBudget { xpath: XPathWorkBudget, base64: Base64WorkBudget, c14n: C14nOutputBudget, node_filter: NodeFilterWorkBudget, node_set_materialization: NodeSetMaterializationBudget, + xml_node_limit: u32, +} + +impl Default for TransformExecutionBudget { + fn default() -> Self { + Self { + xpath: XPathWorkBudget::default(), + base64: Base64WorkBudget::default(), + c14n: C14nOutputBudget::default(), + node_filter: NodeFilterWorkBudget::default(), + node_set_materialization: NodeSetMaterializationBudget::default(), + xml_node_limit: XML_DOCUMENT_NODE_CEILING, + } + } } struct NodeFilterWorkBudget { @@ -205,6 +218,7 @@ impl TransformExecutionBudget { c14n: C14nOutputBudget::default(), node_filter: NodeFilterWorkBudget::default(), node_set_materialization: NodeSetMaterializationBudget::default(), + xml_node_limit: XML_DOCUMENT_NODE_CEILING, } } @@ -217,6 +231,7 @@ impl TransformExecutionBudget { remaining: Cell::new(limit), }, node_set_materialization: NodeSetMaterializationBudget::default(), + xml_node_limit: XML_DOCUMENT_NODE_CEILING, } } @@ -227,17 +242,27 @@ impl TransformExecutionBudget { c14n: C14nOutputBudget::default(), node_filter: NodeFilterWorkBudget::default(), node_set_materialization: NodeSetMaterializationBudget::with_limit(limit), + xml_node_limit: XML_DOCUMENT_NODE_CEILING, } } -} -impl TransformExecutionBudget { pub(crate) fn with_c14n_limit(max_bytes: usize) -> Self { Self { c14n: C14nOutputBudget::with_limit(max_bytes), ..Self::default() } } +} + +impl TransformExecutionBudget { + pub(crate) fn from_resources(resources: &crate::policy::ResourcePolicy) -> Self { + Self { + c14n: C14nOutputBudget::with_limit(resources.max_canonicalized_bytes), + xml_node_limit: u32::try_from(resources.max_xml_nodes) + .unwrap_or(XML_DOCUMENT_NODE_CEILING), + ..Self::default() + } + } pub(crate) fn charge_c14n_output(&self, bytes: usize) -> Result<(), TransformError> { self.c14n.charge(bytes) @@ -821,11 +846,14 @@ fn execute_transform_chain<'s, 'e, 'd>( &xml, roxmltree::ParsingOptions { allow_dtd: context.options.internal_dtd_allowed(), - nodes_limit: XML_DOCUMENT_NODE_CEILING, + nodes_limit: context.budget.xml_node_limit, entity_resolver: None, }, ) - .map_err(|error| TransformError::XmlParse(error.to_string()))?; + .map_err(|error| match error { + roxmltree::Error::NodesLimitReached => TransformError::XmlNodeLimit, + other => TransformError::XmlParse(other.to_string()), + })?; context.state.document_reparsed(); let nodes = super::types::NodeSet::entire_document_with_comments_with_budget( &document, @@ -1022,7 +1050,7 @@ pub(crate) fn transform_chain_produces_binary( initial_binary: bool, transforms: &[Transform], ) -> bool { - transforms.iter().fold(initial_binary, |_, transform| { + transforms.last().map_or(initial_binary, |transform| { matches!(transform, Transform::C14n(_) | Transform::Base64Decode) }) } @@ -1857,10 +1885,7 @@ mod tests { ) .expect_err("external XML exceeding the node ceiling must fail during parse"); - assert!(matches!( - error, - TransformError::XmlParse(message) if message == "nodes limit reached" - )); + assert!(matches!(error, TransformError::XmlNodeLimit)); } #[test] diff --git a/src/xmldsig/types.rs b/src/xmldsig/types.rs index 89f2c1e2..12728420 100644 --- a/src/xmldsig/types.rs +++ b/src/xmldsig/types.rs @@ -710,6 +710,10 @@ pub enum TransformError { #[error("XML transform input parse error: {0}")] XmlParse(String), + /// XML octets exceeded the operation policy's document node ceiling. + #[error("XML transform input exceeds the configured node limit")] + XmlNodeLimit, + /// The Signature node passed to the enveloped transform belongs to a /// different `Document` than the input `NodeSet`. #[error("enveloped-signature transform: invalid Signature node for this document")] diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index 4cbc605d..07d46133 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -1060,7 +1060,7 @@ fn verify_signature_with_context( } else { RetrievalMaterialization::default() }; - let execution_budget = TransformExecutionBudget::default(); + let execution_budget = TransformExecutionBudget::from_resources(&ctx.policy.resources); let canonicalized_data_budget = CanonicalizedDataBudget::with_limit(ctx.policy.resources.max_canonicalized_bytes); let execution = ReferenceExecutionContext { @@ -1969,6 +1969,7 @@ fn verify_with_algorithm( mod tests { use super::*; use crate::c14n::C14nAlgorithm; + use crate::xmldsig::TransformError; use crate::xmldsig::digest::DigestAlgorithm; use crate::xmldsig::parse::{Reference, parse_signed_info}; use crate::xmldsig::transforms::Transform; @@ -2122,6 +2123,77 @@ mod tests { )); } + #[test] + fn verification_policy_bounds_reference_canonicalization() { + // Reference transforms and SignedInfo canonicalization are one operation; + // references must not fall back to the transform hard-limit budget. + let digest = base64::engine::general_purpose::STANDARD.encode([0_u8; 32]); + let xml = format!( + r#"{}{digest}AQ=="#, + "payload".repeat(16) + ); + let policy = crate::policy::VerificationPolicy { + resources: crate::policy::ResourcePolicy { + max_canonicalized_bytes: 64, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::VerificationPolicy::default() + }; + + let error = VerifyContext::new() + .key(&AcceptingKey) + .policy(policy) + .verify(&xml) + .expect_err("reference canonicalization must consume the policy budget"); + + assert!( + matches!( + error, + SignatureVerificationPipelineError::Reference(ReferenceProcessingError::Transform( + TransformError::C14nOutputTooLarge { max_bytes: 64 } + )) + ), + "unexpected error: {error:?}" + ); + } + + #[test] + fn verification_policy_bounds_detached_xml_nodes() { + // Caller-owned detached octets become a second XML document during a + // node-set transform and must inherit the same operation node ceiling. + let detached = format!("{}", "".repeat(32)); + let digest = base64::engine::general_purpose::STANDARD.encode([0_u8; 32]); + let xml = format!( + r#"true(){digest}AQ=="# + ); + let resources = HashMap::from([("urn:detached-nodes".to_owned(), detached.into_bytes())]); + let policy = crate::policy::VerificationPolicy { + reference_uri_types: UriTypeSet::ALL, + resources: crate::policy::ResourcePolicy { + max_xml_nodes: 24, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::VerificationPolicy::default() + }; + + let error = VerifyContext::new() + .key(&AcceptingKey) + .policy(policy) + .external_resources(&resources) + .verify(&xml) + .expect_err("detached XML must inherit the policy node ceiling"); + + assert!( + matches!( + error, + SignatureVerificationPipelineError::Reference(ReferenceProcessingError::Transform( + TransformError::XmlNodeLimit + )) + ), + "unexpected error: {error:?}" + ); + } + #[test] fn query_only_reference_resolves_against_relative_xml_base() { // A query-only URI replaces the inherited base query without changing diff --git a/src/xmlenc/decrypt.rs b/src/xmlenc/decrypt.rs index 38e5ac9b..89617529 100644 --- a/src/xmlenc/decrypt.rs +++ b/src/xmlenc/decrypt.rs @@ -6,7 +6,10 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; use roxmltree::{Document, ParsingOptions}; use rsa::RsaPrivateKey; -use super::parse::{parse_encrypted_data_node_with_limit, parse_encrypted_data_with_policy}; +use super::parse::{ + parse_encrypted_data_node_with_policy, parse_encrypted_data_with_policy, + validate_encrypted_data_metadata, +}; use super::types::XMLENC_NS; use super::{ DataEncryptionAlgorithm, DecryptedContent, EncryptedData, EncryptedDataType, EncryptedKey, @@ -78,6 +81,7 @@ impl<'a> DecryptContext<'a> { /// Decrypt an already parsed `EncryptedData` value. pub fn decrypt_data(&self, encrypted: &EncryptedData) -> Result { self.policy.resources.validate()?; + validate_encrypted_data_metadata(encrypted, &self.policy)?; validate_recipient_count( encrypted.encrypted_keys.len(), self.policy.resources.max_encryption_recipients, @@ -413,10 +417,7 @@ fn decrypt_document_with_context( } let range = selected.range(); - let encrypted = parse_encrypted_data_node_with_limit( - selected, - context.policy.resources.max_encryption_recipients, - )?; + let encrypted = parse_encrypted_data_node_with_policy(selected, &context.policy)?; let DecryptedContent::Xml(plaintext) = context.decrypt_data(&encrypted)? else { return Err(XmlEncError::ReplacementRequiresXml); }; @@ -634,7 +635,8 @@ fn validate_possible_plaintext_len( ) -> Result<(), XmlEncError> { let framing = match algorithm { // CBC always contains a 16-byte IV and at least one complete padded - // block, so this is the greatest plaintext length possible on success. + // block. Subtracting that minimum framing gives the greatest plaintext + // length possible on success and therefore a safe pre-decryption bound. DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc => 32, DataEncryptionAlgorithm::Aes128Gcm | DataEncryptionAlgorithm::Aes256Gcm => 28, }; @@ -1288,10 +1290,53 @@ mod tests { ); } + #[test] + fn typed_decryption_input_cannot_bypass_metadata_policy() { + // Callers may construct EncryptedData directly instead of using the XML + // parser, so the operation boundary must enforce the same metadata cap. + let ciphertext = crate::provider::default_provider() + .encrypt_data(DataEncryptionAlgorithm::Aes128Gcm, &[0_u8; 16], b"data") + .expect("test encryption must succeed"); + let encrypted = EncryptedData { + id: Some("oversized".into()), + 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(ciphertext), + }, + }; + let policy = crate::policy::DecryptionPolicy { + resources: crate::policy::ResourcePolicy { + max_encryption_metadata_bytes: 8, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::DecryptionPolicy::default() + }; + + assert!(matches!( + DecryptContext::new(&SymmetricKeyDecryptor::new([0_u8; 16])) + .policy(policy) + .decrypt_data(&encrypted), + Err(XmlEncError::EncryptionMetadataTooLarge { + field: "EncryptedData Id", + maximum: 8, + actual: 9, + }) + )); + } + #[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. + // The error contract hides padding details, but callers still need an + // authenticated envelope or a policy that rejects unauthenticated CBC. let error = map_data_decryption_error( DataEncryptionAlgorithm::Aes128Cbc, 32, @@ -1557,7 +1602,7 @@ mod tests { DecryptContext::new(&SymmetricKeyDecryptor::new(key)) .policy(node_policy) .decrypt_document(&document, None), - Err(XmlEncError::XmlParse(error)) if error.to_string() == "nodes limit reached" + Err(XmlEncError::XmlParse(roxmltree::Error::NodesLimitReached)) )); } diff --git a/src/xmlenc/encrypt.rs b/src/xmlenc/encrypt.rs index 2c6e06e5..9777d49e 100644 --- a/src/xmlenc/encrypt.rs +++ b/src/xmlenc/encrypt.rs @@ -124,7 +124,7 @@ impl EncryptedDataBuilder { 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)?; + validate_xml_plaintext(xml, &self.encrypted_type, &self.policy)?; self.encrypt_payload(xml.as_bytes(), Some(self.encrypted_type.clone())) } @@ -142,11 +142,10 @@ impl EncryptedDataBuilder { ) -> Result { self.policy.resources.validate()?; self.validate_document_len(xml.len())?; - let parsing_options = ParsingOptions { - allow_dtd: self.policy.xml.allow_internal_dtd && options.allow_dtd, - entity_resolver: None, - ..ParsingOptions::default() - }; + let parsing_options = encryption_parsing_options( + &self.policy, + self.policy.xml.allow_internal_dtd && options.allow_dtd, + ); let document = Document::parse_with_options(xml, parsing_options)?; let selected = select_encryption_target(&document, options.element_id)?; let range = selected.range(); @@ -626,10 +625,12 @@ fn write_event(writer: &mut Writer>, event: Event<'_>) -> Result<(), Xml fn validate_xml_plaintext( xml: &str, encrypted_type: &EncryptedDataType, + policy: &crate::policy::EncryptionPolicy, ) -> Result<(), XmlEncError> { + let parsing_options = || encryption_parsing_options(policy, policy.xml.allow_internal_dtd); match encrypted_type { EncryptedDataType::Element => { - let document = Document::parse(xml)?; + let document = Document::parse_with_options(xml, parsing_options())?; if !has_single_element_with_boundary_trivia(document.root()) { return Err(XmlEncError::InvalidStructure( "Element plaintext must contain exactly one element".into(), @@ -639,7 +640,7 @@ fn validate_xml_plaintext( } EncryptedDataType::Content => { let wrapped = format!("{xml}"); - let _ = Document::parse(&wrapped)?; + let _ = Document::parse_with_options(&wrapped, parsing_options())?; Ok(()) } EncryptedDataType::Other(_) => Err(XmlEncError::InvalidEncryptionConfig( @@ -648,6 +649,18 @@ fn validate_xml_plaintext( } } +fn encryption_parsing_options<'a>( + policy: &crate::policy::EncryptionPolicy, + allow_dtd: bool, +) -> ParsingOptions<'a> { + ParsingOptions { + allow_dtd, + nodes_limit: u32::try_from(policy.resources.max_xml_nodes) + .unwrap_or(crate::hard_limits::XML_DOCUMENT_NODE_CEILING), + entity_resolver: None, + } +} + fn select_encryption_target<'a, 'input>( document: &'a Document<'input>, id: Option<&str>, @@ -970,6 +983,34 @@ mod tests { )); } + #[test] + fn encryption_policy_bounds_xml_nodes_at_both_parse_entry_points() { + // XML plaintext and whole-document encryption are separate parser paths; + // both must consume the same immutable operation-policy node ceiling. + let policy = crate::policy::EncryptionPolicy { + resources: crate::policy::ResourcePolicy { + max_xml_nodes: 4, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::EncryptionPolicy::default() + }; + let builder = || { + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key([0_u8; 16]) + .policy(policy.clone()) + }; + let xml = ""; + + assert!(matches!( + builder().encrypt_xml(xml), + Err(XmlEncError::XmlParse(roxmltree::Error::NodesLimitReached)) + )); + assert!(matches!( + builder().encrypt_document(xml, DocumentEncryptionOptions::default()), + Err(XmlEncError::XmlParse(roxmltree::Error::NodesLimitReached)) + )); + } + #[test] fn document_dtd_requires_policy_and_per_call_opt_in() { // Internal DTD parsing is a two-party decision: operation policy sets diff --git a/src/xmlenc/parse.rs b/src/xmlenc/parse.rs index 2946c5bc..a3084e82 100644 --- a/src/xmlenc/parse.rs +++ b/src/xmlenc/parse.rs @@ -40,26 +40,24 @@ pub(super) fn parse_encrypted_data_with_policy( entity_resolver: None, }, )?; - parse_encrypted_data_node_with_limit( - document.root_element(), - policy.resources.max_encryption_recipients, - ) + parse_encrypted_data_node_with_policy(document.root_element(), policy) } -pub(super) fn parse_encrypted_data_node_with_limit( +pub(super) fn parse_encrypted_data_node_with_policy( node: Node<'_, '_>, - max_encryption_recipients: usize, + policy: &crate::policy::DecryptionPolicy, ) -> Result { require_element(node, XMLENC_NS, "EncryptedData")?; let mut children = element_children(node); - let encryption_method = - parse_encryption_method(next_required(&mut children, "EncryptionMethod")?)?; + let encryption_method = parse_encryption_method_with_limit( + next_required(&mut children, "EncryptionMethod")?, + policy.resources.max_encryption_metadata_bytes, + )?; let key_info = match children.peek() { - Some(child) if child.has_tag_name((XMLDSIG_NS, "KeyInfo")) => parse_key_info( - next_required(&mut children, "KeyInfo")?, - max_encryption_recipients, - )?, + Some(child) if child.has_tag_name((XMLDSIG_NS, "KeyInfo")) => { + parse_key_info(next_required(&mut children, "KeyInfo")?, policy)? + } _ => ParsedKeyInfo { key_name: None, encrypted_keys: Vec::new(), @@ -74,19 +72,21 @@ pub(super) fn parse_encrypted_data_node_with_limit( )); } - Ok(EncryptedData { - id: node.attribute("Id").map(str::to_owned), - encrypted_type: parse_type(node.attribute("Type")), + let encrypted = EncryptedData { + id: bounded_attribute(node, "Id", "EncryptedData Id", policy)?, + encrypted_type: parse_type_bounded(node.attribute("Type"), policy)?, key_name: key_info.key_name, encryption_method, encrypted_keys: key_info.encrypted_keys, cipher_data, - }) + }; + validate_encrypted_data_metadata(&encrypted, policy)?; + Ok(encrypted) } fn parse_key_info( node: Node<'_, '_>, - max_encryption_recipients: usize, + policy: &crate::policy::DecryptionPolicy, ) -> Result { require_element(node, XMLDSIG_NS, "KeyInfo")?; let mut key_name = None; @@ -99,17 +99,17 @@ fn parse_key_info( "KeyInfo contains more than one direct KeyName".into(), )); } - key_name = Some(parse_key_name(child)?); + key_name = Some(parse_key_name(child, policy)?); } else if child.has_tag_name((XMLENC_NS, "EncryptedKey")) { - if encrypted_keys.len() == max_encryption_recipients { + if encrypted_keys.len() == policy.resources.max_encryption_recipients { return Err(crate::policy::PolicyViolation::ResourceLimit { resource: "encryption recipients", - maximum: max_encryption_recipients, + maximum: policy.resources.max_encryption_recipients, actual: encrypted_keys.len() + 1, } .into()); } - encrypted_keys.push(parse_encrypted_key(child)?); + encrypted_keys.push(parse_encrypted_key(child, policy)?); } else if child.has_tag_name((XMLENC_NS, "AgreementMethod")) { // Agreement methods require a separate key-derivation trust boundary. // Keep the URI as a fallback error while allowing another advertised @@ -119,6 +119,11 @@ fn parse_key_info( .ok_or(XmlEncError::MissingRequired( "AgreementMethod Algorithm attribute", ))?; + validate_metadata_len( + "AgreementMethod Algorithm", + algorithm.len(), + policy.resources.max_encryption_metadata_bytes, + )?; unsupported_agreement.get_or_insert_with(|| algorithm.to_owned()); } } @@ -134,16 +139,21 @@ fn parse_key_info( }) } -fn parse_encrypted_key(node: Node<'_, '_>) -> Result { +fn parse_encrypted_key( + node: Node<'_, '_>, + policy: &crate::policy::DecryptionPolicy, +) -> Result { require_element(node, XMLENC_NS, "EncryptedKey")?; let mut children = element_children(node); - let encryption_method = - parse_encryption_method(next_required(&mut children, "EncryptionMethod")?)?; + let encryption_method = parse_encryption_method_with_limit( + next_required(&mut children, "EncryptionMethod")?, + policy.resources.max_encryption_metadata_bytes, + )?; let key_name = if children .peek() .is_some_and(|child| child.has_tag_name((XMLDSIG_NS, "KeyInfo"))) { - parse_key_name_hint(next_required(&mut children, "KeyInfo")?)? + parse_key_name_hint(next_required(&mut children, "KeyInfo")?, policy)? } else { None }; @@ -153,10 +163,10 @@ fn parse_encrypted_key(node: Node<'_, '_>) -> Result .peek() .is_some_and(|child| child.has_tag_name((XMLENC_NS, "ReferenceList"))) { - Some(parse_reference_list(next_required( - &mut children, - "ReferenceList", - )?)?) + Some(parse_reference_list( + next_required(&mut children, "ReferenceList")?, + policy, + )?) } else { None }; @@ -164,10 +174,10 @@ fn parse_encrypted_key(node: Node<'_, '_>) -> Result .peek() .is_some_and(|child| child.has_tag_name((XMLENC_NS, "CarriedKeyName"))) { - Some(parse_carried_key_name(next_required( - &mut children, - "CarriedKeyName", - )?)?) + Some(parse_carried_key_name( + next_required(&mut children, "CarriedKeyName")?, + policy, + )?) } else { None }; @@ -177,8 +187,8 @@ fn parse_encrypted_key(node: Node<'_, '_>) -> Result )); } Ok(EncryptedKey { - id: node.attribute("Id").map(str::to_owned), - recipient: node.attribute("Recipient").map(str::to_owned), + id: bounded_attribute(node, "Id", "EncryptedKey Id", policy)?, + recipient: bounded_attribute(node, "Recipient", "EncryptedKey Recipient", policy)?, key_name, encryption_method, cipher_data, @@ -187,9 +197,12 @@ fn parse_encrypted_key(node: Node<'_, '_>) -> Result }) } -fn parse_carried_key_name(node: Node<'_, '_>) -> Result { +fn parse_carried_key_name( + node: Node<'_, '_>, + policy: &crate::policy::DecryptionPolicy, +) -> Result { require_element(node, XMLENC_NS, "CarriedKeyName")?; - let value = simple_text(node, "CarriedKeyName")?; + let value = bounded_simple_text(node, "CarriedKeyName", policy)?; if value.is_empty() { return Err(XmlEncError::InvalidStructure( "CarriedKeyName is empty".into(), @@ -198,7 +211,10 @@ fn parse_carried_key_name(node: Node<'_, '_>) -> Result { Ok(value) } -fn parse_key_name_hint(node: Node<'_, '_>) -> Result, XmlEncError> { +fn parse_key_name_hint( + node: Node<'_, '_>, + policy: &crate::policy::DecryptionPolicy, +) -> Result, XmlEncError> { require_element(node, XMLDSIG_NS, "KeyInfo")?; let mut key_names = node .children() @@ -211,18 +227,24 @@ fn parse_key_name_hint(node: Node<'_, '_>) -> Result, XmlEncError "EncryptedKey KeyInfo contains more than one direct KeyName".into(), )); } - parse_key_name(key_name).map(Some) + parse_key_name(key_name, policy).map(Some) } -fn parse_key_name(node: Node<'_, '_>) -> Result { - let value = simple_text(node, "KeyName")?; +fn parse_key_name( + node: Node<'_, '_>, + policy: &crate::policy::DecryptionPolicy, +) -> Result { + let value = bounded_simple_text(node, "KeyName", policy)?; if value.is_empty() { return Err(XmlEncError::InvalidStructure("KeyName is empty".into())); } Ok(value) } -fn parse_reference_list(node: Node<'_, '_>) -> Result { +fn parse_reference_list( + node: Node<'_, '_>, + policy: &crate::policy::DecryptionPolicy, +) -> Result { require_element(node, XMLENC_NS, "ReferenceList")?; let mut data_references = Vec::new(); let mut key_references = Vec::new(); @@ -230,8 +252,13 @@ fn parse_reference_list(node: Node<'_, '_>) -> Result data_references.push(uri), (Some(XMLENC_NS), "KeyReference") => key_references.push(uri), @@ -266,14 +293,27 @@ where } } +#[cfg(test)] fn parse_encryption_method(node: Node<'_, '_>) -> Result { + parse_encryption_method_with_limit(node, crate::hard_limits::ENCRYPTION_METADATA_BYTE_CEILING) +} + +fn parse_encryption_method_with_limit( + node: Node<'_, '_>, + metadata_limit: usize, +) -> Result { require_element(node, XMLENC_NS, "EncryptionMethod")?; let algorithm = node .attribute("Algorithm") .ok_or(XmlEncError::MissingRequired( "EncryptionMethod Algorithm attribute", - ))? - .to_owned(); + ))?; + validate_metadata_len( + "EncryptionMethod Algorithm", + algorithm.len(), + metadata_limit, + )?; + let algorithm = algorithm.to_owned(); let mut oaep_digest = None; let mut mgf_algorithm = None; @@ -290,25 +330,23 @@ fn parse_encryption_method(node: Node<'_, '_>) -> Result { - oaep_params = Some(decode_base64_text(&simple_text(child, "OAEPparams")?)?); + oaep_params = Some(decode_bounded_base64_text(child, metadata_limit)?); } (Some(XMLDSIG_NS), "DigestMethod") if oaep_digest.is_none() => { - oaep_digest = Some( - child - .attribute("Algorithm") - .ok_or(XmlEncError::MissingRequired( - "DigestMethod Algorithm attribute", - ))? - .to_owned(), - ); + let digest = child + .attribute("Algorithm") + .ok_or(XmlEncError::MissingRequired( + "DigestMethod Algorithm attribute", + ))?; + validate_metadata_len("DigestMethod Algorithm", digest.len(), metadata_limit)?; + oaep_digest = Some(digest.to_owned()); } (Some(XMLENC11_NS), "MGF") if mgf_algorithm.is_none() => { - mgf_algorithm = Some( - child - .attribute("Algorithm") - .ok_or(XmlEncError::MissingRequired("MGF Algorithm attribute"))? - .to_owned(), - ); + let mgf = child + .attribute("Algorithm") + .ok_or(XmlEncError::MissingRequired("MGF Algorithm attribute"))?; + validate_metadata_len("MGF Algorithm", mgf.len(), metadata_limit)?; + mgf_algorithm = Some(mgf.to_owned()); } _ => { return Err(XmlEncError::InvalidStructure(format!( @@ -405,20 +443,176 @@ fn simple_text(node: Node<'_, '_>, element_name: &str) -> Result) -> Option { - match value { - None => None, - Some("http://www.w3.org/2001/04/xmlenc#Element") => Some(EncryptedDataType::Element), - Some("http://www.w3.org/2001/04/xmlenc#Content") => Some(EncryptedDataType::Content), - Some(other) => Some(EncryptedDataType::Other(other.to_owned())), +fn bounded_simple_text( + node: Node<'_, '_>, + field: &'static str, + policy: &crate::policy::DecryptionPolicy, +) -> Result { + if node.children().any(|child| child.is_element()) { + return Err(XmlEncError::InvalidStructure(format!( + "{field} must not contain element children" + ))); } + let maximum = policy.resources.max_encryption_metadata_bytes; + let mut value = String::new(); + for text in node + .children() + .filter(Node::is_text) + .filter_map(|child| child.text()) + { + let actual = value.len().saturating_add(text.len()); + validate_metadata_len(field, actual, maximum)?; + value.push_str(text); + } + Ok(value) } -fn decode_base64_text(value: &str) -> Result, XmlEncError> { - let normalized = normalize_base64_with_empty(value, true)?; - STANDARD +fn bounded_attribute( + node: Node<'_, '_>, + attribute: &str, + field: &'static str, + policy: &crate::policy::DecryptionPolicy, +) -> Result, XmlEncError> { + let Some(value) = node.attribute(attribute) else { + return Ok(None); + }; + validate_metadata_len( + field, + value.len(), + policy.resources.max_encryption_metadata_bytes, + )?; + Ok(Some(value.to_owned())) +} + +fn validate_metadata_len( + field: &'static str, + actual: usize, + maximum: usize, +) -> Result<(), XmlEncError> { + if actual <= maximum { + Ok(()) + } else { + Err(XmlEncError::EncryptionMetadataTooLarge { + field, + maximum, + actual, + }) + } +} + +pub(super) fn validate_encrypted_data_metadata( + encrypted: &EncryptedData, + policy: &crate::policy::DecryptionPolicy, +) -> Result<(), XmlEncError> { + let maximum = policy.resources.max_encryption_metadata_bytes; + let validate = |field, value: Option<&str>| { + validate_metadata_len(field, value.map_or(0, str::len), maximum) + }; + validate("EncryptedData Id", encrypted.id.as_deref())?; + if let Some(encrypted_type) = encrypted.encrypted_type.as_ref() { + let value = match encrypted_type { + EncryptedDataType::Element => "http://www.w3.org/2001/04/xmlenc#Element", + EncryptedDataType::Content => "http://www.w3.org/2001/04/xmlenc#Content", + EncryptedDataType::Other(value) => value, + }; + validate("EncryptedData Type", Some(value))?; + } + validate("direct KeyName", encrypted.key_name.as_deref())?; + validate_encryption_method_metadata(&encrypted.encryption_method, maximum)?; + for key in &encrypted.encrypted_keys { + validate("EncryptedKey Id", key.id.as_deref())?; + validate("EncryptedKey Recipient", key.recipient.as_deref())?; + validate("EncryptedKey KeyName", key.key_name.as_deref())?; + validate("CarriedKeyName", key.carried_key_name.as_deref())?; + validate_encryption_method_metadata(&key.encryption_method, maximum)?; + if let Some(references) = key.reference_list.as_ref() { + for uri in references + .data_references + .iter() + .chain(&references.key_references) + { + validate("Reference URI", Some(uri))?; + } + } + } + Ok(()) +} + +fn validate_encryption_method_metadata( + method: &EncryptionMethod, + maximum: usize, +) -> Result<(), XmlEncError> { + validate_metadata_len( + "EncryptionMethod Algorithm", + method.algorithm.len(), + maximum, + )?; + if let Some(value) = method.oaep_digest.as_deref() { + validate_metadata_len("DigestMethod Algorithm", value.len(), maximum)?; + } + if let Some(value) = method.mgf_algorithm.as_deref() { + validate_metadata_len("MGF Algorithm", value.len(), maximum)?; + } + if let Some(value) = method.oaep_params.as_deref() { + validate_metadata_len("OAEPparams", value.len(), maximum)?; + } + Ok(()) +} + +fn parse_type_bounded( + value: Option<&str>, + policy: &crate::policy::DecryptionPolicy, +) -> Result, XmlEncError> { + let Some(value) = value else { + return Ok(None); + }; + validate_metadata_len( + "EncryptedData Type", + value.len(), + policy.resources.max_encryption_metadata_bytes, + )?; + Ok(Some(match value { + "http://www.w3.org/2001/04/xmlenc#Element" => EncryptedDataType::Element, + "http://www.w3.org/2001/04/xmlenc#Content" => EncryptedDataType::Content, + other => EncryptedDataType::Other(other.to_owned()), + })) +} + +fn decode_bounded_base64_text(node: Node<'_, '_>, maximum: usize) -> Result, XmlEncError> { + if node.children().any(|child| child.is_element()) { + return Err(XmlEncError::InvalidStructure( + "OAEPparams must not contain element children".into(), + )); + } + let encoded_limit = maximum.div_ceil(3).saturating_mul(4); + let mut normalized = String::with_capacity(encoded_limit); + for character in node + .children() + .filter(Node::is_text) + .filter_map(|child| child.text()) + .flat_map(str::chars) + { + if !character.is_ascii() { + return Err(XmlEncError::Base64( + "OAEPparams contains non-ASCII data".into(), + )); + } + if !character.is_ascii_whitespace() { + if normalized.len() == encoded_limit { + return Err(XmlEncError::EncryptionMetadataTooLarge { + field: "OAEPparams", + maximum, + actual: maximum.saturating_add(1), + }); + } + normalized.push(character); + } + } + let decoded = STANDARD .decode(normalized) - .map_err(|error| XmlEncError::Base64(error.to_string())) + .map_err(|error| XmlEncError::Base64(error.to_string()))?; + validate_metadata_len("OAEPparams", decoded.len(), maximum)?; + Ok(decoded) } /// Normalize XML base64 whitespace while applying a pre-allocation bound. @@ -638,6 +832,26 @@ mod tests { )); } + #[test] + fn bounds_oaep_parameters_before_base64_allocation() { + // OAEP labels are retained as decoded metadata. The parser must cap the + // normalized lexical form before either String or decoded Vec can grow. + let xml = format!( + "{}", + STANDARD.encode([0_u8; 65]) + ); + let document = Document::parse(&xml).expect("test method must be XML"); + + assert!(matches!( + parse_encryption_method_with_limit(document.root_element(), 64), + Err(XmlEncError::EncryptionMetadataTooLarge { + field: "OAEPparams", + maximum: 64, + actual: 65, + }) + )); + } + #[test] fn validates_explicit_key_size_for_supported_aes_methods() { // KeySize is valid for every EncryptionMethod, but fixed-size AES URIs @@ -798,6 +1012,36 @@ mod tests { )); } + #[test] + fn policy_bounds_copied_encryption_metadata() { + // Every retained metadata field must be rejected before it can bypass + // the configured per-field ceiling through the XML parser entry point. + let policy = crate::policy::DecryptionPolicy { + resources: crate::policy::ResourcePolicy { + max_encryption_metadata_bytes: 64, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::DecryptionPolicy::default() + }; + let oversized = "x".repeat(65); + for xml in [ + DATA.replace("", + &format!("{oversized}"), + ), + ] { + assert!(matches!( + parse_encrypted_data_with_policy(&xml, &policy), + Err(XmlEncError::EncryptionMetadataTooLarge { + maximum: 64, + actual: 65, + .. + }) + )); + } + } + #[test] fn rejects_non_ascii_base64_before_it_can_cross_the_byte_bound() { // Base64 is ASCII-only. Rejecting Unicode before insertion also prevents a diff --git a/src/xmlenc/types.rs b/src/xmlenc/types.rs index e854dd45..27326003 100644 --- a/src/xmlenc/types.rs +++ b/src/xmlenc/types.rs @@ -479,8 +479,8 @@ pub enum XmlEncError { InvalidCbcCiphertextLength(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. + /// No decrypted padding details are exposed. This does not authenticate CBC + /// ciphertexts or make success/failure safe to expose to an attacker. #[error("invalid XMLEnc padding")] InvalidPadding, /// GCM authentication failed. From 219ca57dbb1a00c178ee5dafa9e388da293775bc Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sat, 8 Aug 2026 20:52:31 +0300 Subject: [PATCH 29/63] fix: enforce crypto operation boundaries - Bound typed XMLEnc ciphertext and generated documents - Reject unknown wrapped-key algorithms before resolution - Route RSA blinding randomness through the selected provider - Document the operation-boundary contracts --- README.md | 3 +- docs/xmldsig.md | 6 ++ docs/xmlenc.md | 10 +- src/provider.rs | 148 +++++++++++++++++++++++++- src/xmldsig/sign.rs | 35 +++++- src/xmlenc/decrypt.rs | 241 +++++++++++++++++++++++++++++++++++++++--- src/xmlenc/encrypt.rs | 71 +++++++++++++ 7 files changed, 493 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 183823af..190c0ad3 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,8 @@ Currently implemented (core paths): - 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; document, node, and aggregate recipient - limits are enforced before expensive work. CBC failures expose no decrypted + limits cover caller-constructed ciphertext and generated replacement output + before expensive work. CBC failures expose no decrypted padding details, but CBC remains unauthenticated and can be excluded by policy Still in progress: diff --git a/docs/xmldsig.md b/docs/xmldsig.md index fa2ef6a9..bb946bb1 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -32,6 +32,12 @@ validation in `sign_with_builder`, digest filling, `SignedInfo` parsing, signatu optional `KeyInfo` filling. An internal-DTD opt-in and XML node ceiling therefore cannot be lost between stages. +`SignContext::provider` selects both digest primitives and operation randomness. Built-in RSA +signing routes its blinding randomness through that provider rather than acquiring operating-system +randomness behind the provider boundary. Custom `SigningKey` implementations whose primitive uses +randomness must implement `SigningKey::sign_with_provider`; deterministic and externally managed +keys can use the default implementation. + ## Verification Policy For production verification, configure `KeyResolverConfig::lookup_certs` with untrusted diff --git a/docs/xmlenc.md b/docs/xmlenc.md index 98670efc..b2fd3e6b 100644 --- a/docs/xmlenc.md +++ b/docs/xmlenc.md @@ -72,12 +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. Decryption applies the same aggregate recipient ceiling while parsing, bounds each retained identifier, algorithm URI, key name, OAEP label, and -reference URI, and rechecks caller-constructed `EncryptedData` before key resolution. +reference URI, and rechecks caller-constructed `EncryptedData` before decoding or key resolution. +That typed-input check bounds both encoded and projected decoded `CipherValue` sizes, so callers +cannot bypass parser allocation limits by constructing the public model directly. 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. +Unknown key transport and wrap URIs fail closed before an application resolver is invoked. 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 @@ -95,3 +98,8 @@ output byte length is checked before constructing the replacement. DTD parsing r default; legacy documents that need an internal DTD can opt in through `decrypt_document_with_options` and `DocumentDecryptionOptions`. That API never installs an external entity resolver. + +`encrypt_document` also checks the exact projected document length after cipher framing, base64, +and `EncryptedData` serialization but before allocating the replacement document. This keeps +generated Element and Content output within the same document policy accepted by reciprocal +decryption. diff --git a/src/provider.rs b/src/provider.rs index 099ef262..a82989bc 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -5,7 +5,7 @@ //! opaque behind the operation-specific key traits exposed by `xmldsig` and //! `xmlenc`; this provider owns stateless primitives and randomness. -#[cfg(feature = "xmlenc")] +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] use getrandom::rand_core::TryCryptoRng; use getrandom::{SysRng, rand_core::TryRng}; @@ -134,6 +134,10 @@ pub trait CryptoProvider: Send + Sync { fn digest(&self, algorithm: DigestAlgorithm, data: &[u8]) -> Result, ProviderError>; /// Sign bytes with an opaque key handle. + /// + /// Providers that delegate primitive signing to the supplied key must call + /// [`crate::xmldsig::SigningKey::sign_with_provider`] so randomized + /// primitives consume this provider's randomness. #[cfg(feature = "xmldsig")] fn sign( &self, @@ -221,10 +225,10 @@ pub fn default_provider() -> &'static dyn CryptoProvider { } /// Adapter used when a RustCrypto primitive requires a fallible RNG object. -#[cfg(feature = "xmlenc")] +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] pub(crate) struct ProviderRng<'a>(pub(crate) &'a dyn CryptoProvider); -#[cfg(feature = "xmlenc")] +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] impl TryRng for ProviderRng<'_> { type Error = ProviderError; @@ -245,7 +249,7 @@ impl TryRng for ProviderRng<'_> { } } -#[cfg(feature = "xmlenc")] +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] impl TryCryptoRng for ProviderRng<'_> {} impl CryptoProvider for RustCryptoProvider { @@ -310,7 +314,7 @@ impl CryptoProvider for RustCryptoProvider { data: &[u8], ) -> Result, crate::xmldsig::SigningKeyError> { self.require(ProviderOperation::Sign, Some(algorithm.uri()))?; - key.sign(algorithm, data) + key.sign_with_provider(self, algorithm, data) } #[cfg(feature = "xmldsig")] @@ -850,8 +854,119 @@ mod rustcrypto { #[cfg(test)] mod tests { + #[cfg(feature = "xmldsig")] + use std::sync::atomic::{AtomicUsize, Ordering}; + use super::*; + #[cfg(feature = "xmldsig")] + struct CountingRandomProvider { + random_calls: AtomicUsize, + } + + #[cfg(feature = "xmldsig")] + impl CryptoProvider for CountingRandomProvider { + fn name(&self) -> &'static str { + "counting-random" + } + + fn supports(&self, query: CapabilityQuery<'_>) -> bool { + RUST_CRYPTO_PROVIDER.supports(query) + } + + fn fill_random(&self, output: &mut [u8]) -> Result<(), ProviderError> { + self.random_calls.fetch_add(1, Ordering::Relaxed); + RUST_CRYPTO_PROVIDER.fill_random(output) + } + + fn digest( + &self, + algorithm: DigestAlgorithm, + data: &[u8], + ) -> Result, ProviderError> { + RUST_CRYPTO_PROVIDER.digest(algorithm, data) + } + + fn sign( + &self, + key: &dyn crate::xmldsig::SigningKey, + algorithm: crate::xmldsig::SignatureAlgorithm, + data: &[u8], + ) -> Result, crate::xmldsig::SigningKeyError> { + key.sign_with_provider(self, algorithm, data) + } + + fn verify( + &self, + key: &dyn crate::xmldsig::VerifyingKey, + algorithm: crate::xmldsig::SignatureAlgorithm, + data: &[u8], + signature: &[u8], + ) -> Result { + RUST_CRYPTO_PROVIDER.verify(key, algorithm, data, signature) + } + + #[cfg(feature = "xmlenc")] + fn encrypt_data( + &self, + algorithm: DataEncryptionAlgorithm, + key: &[u8], + plaintext: &[u8], + ) -> Result, ProviderError> { + RUST_CRYPTO_PROVIDER.encrypt_data(algorithm, key, plaintext) + } + + #[cfg(feature = "xmlenc")] + fn decrypt_data( + &self, + algorithm: DataEncryptionAlgorithm, + key: &[u8], + ciphertext: &[u8], + ) -> Result, ProviderError> { + RUST_CRYPTO_PROVIDER.decrypt_data(algorithm, key, ciphertext) + } + + #[cfg(feature = "xmlenc")] + fn wrap_key( + &self, + algorithm: KeyWrapAlgorithm, + kek: &[u8], + key: &[u8], + ) -> Result, ProviderError> { + RUST_CRYPTO_PROVIDER.wrap_key(algorithm, kek, key) + } + + #[cfg(feature = "xmlenc")] + fn unwrap_key( + &self, + algorithm: KeyWrapAlgorithm, + kek: &[u8], + wrapped: &[u8], + ) -> Result, ProviderError> { + RUST_CRYPTO_PROVIDER.unwrap_key(algorithm, kek, wrapped) + } + + #[cfg(feature = "xmlenc")] + fn transport_key( + &self, + key: &rsa::RsaPublicKey, + parameters: &RsaOaepParameters, + plaintext: &[u8], + ) -> Result, ProviderError> { + RUST_CRYPTO_PROVIDER.transport_key(key, parameters, plaintext) + } + + #[cfg(feature = "xmlenc")] + fn recover_key( + &self, + key: &rsa::RsaPrivateKey, + parameters: &RsaOaepParameters, + ciphertext: &[u8], + ) -> Result, ProviderError> { + RUST_CRYPTO_PROVIDER.recover_key(key, parameters, ciphertext) + } + } + #[test] fn capability_query_is_explicit_about_unimplemented_operations() { assert!(RUST_CRYPTO_PROVIDER.supports(CapabilityQuery { @@ -876,6 +991,29 @@ mod tests { })); } + #[cfg(feature = "xmldsig")] + #[test] + fn rsa_signing_uses_the_selected_providers_randomness() { + use crate::xmldsig::{RsaSigningKey, SignatureAlgorithm}; + + // RSA PKCS#1 v1.5 uses randomness for blinding even though its wire + // signature is deterministic; the selected provider owns that source. + let key = RsaSigningKey::from_pkcs8_pem(include_str!( + "../tests/fixtures/keys/rsa/rsa-2048-key.pem" + )) + .expect("RSA fixture must parse"); + let provider = CountingRandomProvider { + random_calls: AtomicUsize::new(0), + }; + + let signature = provider + .sign(&key, SignatureAlgorithm::RsaSha256, b"signed info") + .expect("RSA signing must succeed"); + + assert!(!signature.is_empty()); + assert!(provider.random_calls.load(Ordering::Relaxed) > 0); + } + #[cfg(feature = "xmlenc")] #[test] fn legacy_oaep_mgf_constraint_is_symmetric() { diff --git a/src/xmldsig/sign.rs b/src/xmldsig/sign.rs index a9a655b4..aa169165 100644 --- a/src/xmldsig/sign.rs +++ b/src/xmldsig/sign.rs @@ -6,7 +6,6 @@ //! must continue to reject empty or malformed stored digest values. use base64::Engine; -use getrandom::SysRng; use p256::ecdsa::{Signature as P256Signature, SigningKey as P256SigningKey}; use p256::pkcs8::{DecodePrivateKey, EncodePublicKey}; use p384::ecdsa::{Signature as P384Signature, SigningKey as P384SigningKey}; @@ -221,6 +220,20 @@ pub trait SigningKey { canonical_signed_info: &[u8], ) -> Result, SigningKeyError>; + /// Sign while sourcing any primitive randomness from the selected provider. + /// + /// Deterministic or externally managed keys can rely on this default. Keys + /// whose primitive uses randomness, including RSA blinding, must override it. + fn sign_with_provider( + &self, + provider: &dyn crate::provider::CryptoProvider, + algorithm: SignatureAlgorithm, + canonical_signed_info: &[u8], + ) -> Result, SigningKeyError> { + let _ = provider; + self.sign(algorithm, canonical_signed_info) + } + /// Return structured public key material corresponding to this signing key. fn public_key_info(&self) -> Result; } @@ -337,17 +350,33 @@ impl SigningKey for RsaSigningKey { &self, algorithm: SignatureAlgorithm, canonical_signed_info: &[u8], + ) -> Result, SigningKeyError> { + self.sign_with_provider( + crate::provider::default_provider(), + algorithm, + canonical_signed_info, + ) + } + + fn sign_with_provider( + &self, + provider: &dyn crate::provider::CryptoProvider, + algorithm: SignatureAlgorithm, + canonical_signed_info: &[u8], ) -> Result, SigningKeyError> { match algorithm { SignatureAlgorithm::RsaSha256 => sign_rsa_pkcs1v15_with_rng( + provider, RsaPkcs1v15SigningKey::::new(self.key.clone()), canonical_signed_info, ), SignatureAlgorithm::RsaSha384 => sign_rsa_pkcs1v15_with_rng( + provider, RsaPkcs1v15SigningKey::::new(self.key.clone()), canonical_signed_info, ), SignatureAlgorithm::RsaSha512 => sign_rsa_pkcs1v15_with_rng( + provider, RsaPkcs1v15SigningKey::::new(self.key.clone()), canonical_signed_info, ), @@ -372,11 +401,13 @@ impl SigningKey for RsaSigningKey { } fn sign_rsa_pkcs1v15_with_rng( + provider: &dyn crate::provider::CryptoProvider, key: impl RandomizedSigner, canonical_signed_info: &[u8], ) -> Result, SigningKeyError> { + let mut rng = crate::provider::ProviderRng(provider); let signature = key - .try_sign_with_rng(&mut SysRng, canonical_signed_info) + .try_sign_with_rng(&mut rng, canonical_signed_info) .map_err(|_| SigningKeyError::SigningFailed)?; Ok(signature.to_vec()) } diff --git a/src/xmlenc/decrypt.rs b/src/xmlenc/decrypt.rs index 89617529..ab8d8af3 100644 --- a/src/xmlenc/decrypt.rs +++ b/src/xmlenc/decrypt.rs @@ -10,7 +10,7 @@ use super::parse::{ parse_encrypted_data_node_with_policy, parse_encrypted_data_with_policy, validate_encrypted_data_metadata, }; -use super::types::XMLENC_NS; +use super::types::{MAX_CIPHER_VALUE_BASE64_LEN, XMLENC_NS}; use super::{ DataEncryptionAlgorithm, DecryptedContent, EncryptedData, EncryptedDataType, EncryptedKey, KeyTransportAlgorithm, KeyWrapAlgorithm, OaepDigestAlgorithm, RsaOaepParameters, XmlEncError, @@ -99,6 +99,11 @@ impl<'a> DecryptContext<'a> { } .into()); } + validate_typed_cipher_values( + encrypted, + algorithm, + self.policy.resources.max_encryption_plaintext_bytes, + )?; let ciphertext = STANDARD .decode(&encrypted.cipher_data.value) .map_err(|error| XmlEncError::Base64(error.to_string()))?; @@ -601,21 +606,89 @@ fn validate_encrypted_key_policy( .into()); } } - } else if let Ok(wrap) = KeyWrapAlgorithm::from_uri(uri) - && policy + } else { + let wrap = KeyWrapAlgorithm::from_uri(uri)?; + if policy .key_wrap_algorithms .as_ref() .is_some_and(|allowed| !allowed.contains(&wrap)) - { - return Err(crate::policy::PolicyViolation::Algorithm { - operation: "decryption", - algorithm: uri.clone(), + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "decryption", + algorithm: uri.clone(), + } + .into()); } - .into()); } Ok(()) } +fn validate_typed_cipher_values( + encrypted: &EncryptedData, + algorithm: DataEncryptionAlgorithm, + maximum_plaintext: usize, +) -> Result<(), XmlEncError> { + let maximum_ciphertext = match algorithm { + DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc => { + (maximum_plaintext / 16) + .saturating_add(1) + .saturating_mul(16) + .saturating_add(16) + } + DataEncryptionAlgorithm::Aes128Gcm | DataEncryptionAlgorithm::Aes256Gcm => { + maximum_plaintext.saturating_add(28) + } + }; + let projected = validate_cipher_value_len(&encrypted.cipher_data.value, maximum_ciphertext)?; + if projected > maximum_ciphertext { + return Err(XmlEncError::PlaintextTooLarge { + maximum: maximum_plaintext, + actual: projected.saturating_sub(ciphertext_framing_len(algorithm)), + }); + } + + let maximum_wrapped_key = projected_decoded_len_for_encoded_len(MAX_CIPHER_VALUE_BASE64_LEN); + for encrypted_key in &encrypted.encrypted_keys { + validate_cipher_value_len(&encrypted_key.cipher_data.value, maximum_wrapped_key)?; + } + Ok(()) +} + +fn validate_cipher_value_len(value: &str, maximum_decoded: usize) -> Result { + if value.len() > MAX_CIPHER_VALUE_BASE64_LEN { + return Err(XmlEncError::InvalidStructure(format!( + "CipherValue exceeds {MAX_CIPHER_VALUE_BASE64_LEN}-byte limit" + ))); + } + Ok(projected_decoded_len(value).min(maximum_decoded.saturating_add(1))) +} + +fn projected_decoded_len(value: &str) -> usize { + let padding = value + .as_bytes() + .iter() + .rev() + .take(2) + .take_while(|byte| **byte == b'=') + .count(); + projected_decoded_len_for_encoded_len(value.len()).saturating_sub(padding) +} + +fn projected_decoded_len_for_encoded_len(encoded_len: usize) -> usize { + encoded_len + .checked_add(3) + .map(|length| length / 4) + .and_then(|quanta| quanta.checked_mul(3)) + .unwrap_or(usize::MAX) +} + +const fn ciphertext_framing_len(algorithm: DataEncryptionAlgorithm) -> usize { + match algorithm { + DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc => 32, + DataEncryptionAlgorithm::Aes128Gcm | DataEncryptionAlgorithm::Aes256Gcm => 28, + } +} + fn validate_key_len(algorithm: DataEncryptionAlgorithm, key: &[u8]) -> Result<(), XmlEncError> { if key.len() == algorithm.key_len() { Ok(()) @@ -634,9 +707,10 @@ fn validate_possible_plaintext_len( maximum: usize, ) -> Result<(), XmlEncError> { let framing = match algorithm { - // CBC always contains a 16-byte IV and at least one complete padded - // block. Subtracting that minimum framing gives the greatest plaintext - // length possible on success and therefore a safe pre-decryption bound. + // CBC contains a 16-byte IV and 1 to 16 padding bytes. Assuming the + // largest padding yields the smallest plaintext possible on success, + // which is the safe pre-decryption lower bound checked here. The exact + // plaintext length is checked after decryption. DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc => 32, DataEncryptionAlgorithm::Aes128Gcm | DataEncryptionAlgorithm::Aes256Gcm => 28, }; @@ -693,6 +767,8 @@ fn map_data_decryption_error( #[cfg(test)] mod tests { + use std::cell::Cell; + use aes_gcm::{ Aes128Gcm, aead::{AeadInOut, KeyInit}, @@ -711,6 +787,27 @@ mod tests { key: Vec, } + struct CountingResolver { + candidate_calls: Cell, + key: Vec, + } + + impl DecryptionKeyResolver for CountingResolver { + fn resolve_key( + &self, + _provider: &dyn crate::provider::CryptoProvider, + _algorithm: DataEncryptionAlgorithm, + encrypted_key: Option<&EncryptedKey>, + ) -> Result, XmlEncError> { + if encrypted_key.is_some() { + self.candidate_calls.set(self.candidate_calls.get() + 1); + Ok(self.key.clone()) + } else { + Err(XmlEncError::KeyNotFound) + } + } + } + impl DecryptionKeyResolver for RecipientKeyResolver { fn resolve_key( &self, @@ -813,7 +910,7 @@ mod tests { "{}{}", crate::xmlenc::types::XMLDSIG_NS, recipient_key("alice", KeyTransportAlgorithm::RsaOaep11.uri()), - recipient_key("bob", "urn:test:recipient-key") + recipient_key("bob", KeyWrapAlgorithm::AesKw128.uri()) ); let xml = encrypted.replacen( "", @@ -1333,6 +1430,126 @@ mod tests { )); } + #[test] + fn typed_cipher_values_are_bounded_before_decode_or_resolution() { + // Public typed input bypasses the XML parser, so the decryption boundary + // must re-establish both content and recipient CipherValue size invariants. + let key = [0x41_u8; 16]; + let ciphertext = crate::provider::default_provider() + .encrypt_data(DataEncryptionAlgorithm::Aes128Gcm, &key, b"data") + .expect("test encryption must succeed"); + let mut 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::new(), + cipher_data: super::super::CipherData { + value: STANDARD.encode(ciphertext), + }, + }; + let policy = crate::policy::DecryptionPolicy { + resources: crate::policy::ResourcePolicy { + max_encryption_plaintext_bytes: 4, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::DecryptionPolicy::default() + }; + encrypted.cipher_data.value = "A".repeat(48); + assert!(matches!( + DecryptContext::new(&SymmetricKeyDecryptor::new(key)) + .policy(policy) + .decrypt_data(&encrypted), + Err(XmlEncError::PlaintextTooLarge { .. }) + )); + + encrypted.cipher_data.value = STANDARD.encode([0_u8; 28]); + encrypted.encrypted_keys.push(EncryptedKey { + id: None, + recipient: None, + key_name: None, + encryption_method: super::super::EncryptionMethod { + algorithm: KeyWrapAlgorithm::AesKw128.uri().into(), + key_size_bits: None, + oaep_digest: None, + mgf_algorithm: None, + oaep_params: None, + }, + cipher_data: super::super::CipherData { + value: "A".repeat(MAX_CIPHER_VALUE_BASE64_LEN + 4), + }, + reference_list: None, + carried_key_name: None, + }); + let resolver = CountingResolver { + candidate_calls: Cell::new(0), + key: key.to_vec(), + }; + assert!(matches!( + DecryptContext::new(&resolver).decrypt_data(&encrypted), + Err(XmlEncError::InvalidStructure(_)) + )); + assert_eq!(resolver.candidate_calls.get(), 0); + } + + #[test] + fn unknown_encrypted_key_algorithm_never_reaches_resolver() { + // Extension URIs cannot bypass transport/wrap allowlists by relying on + // an application resolver that happens to return usable key bytes. + let key = [0x42_u8; 16]; + let ciphertext = crate::provider::default_provider() + .encrypt_data(DataEncryptionAlgorithm::Aes128Gcm, &key, b"data") + .expect("test encryption must succeed"); + 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![EncryptedKey { + id: None, + recipient: None, + key_name: None, + encryption_method: super::super::EncryptionMethod { + algorithm: "urn:example:unknown-key-algorithm".into(), + key_size_bits: None, + oaep_digest: None, + mgf_algorithm: None, + oaep_params: None, + }, + cipher_data: super::super::CipherData { + value: STANDARD.encode([0_u8; 24]), + }, + reference_list: None, + carried_key_name: None, + }], + cipher_data: super::super::CipherData { + value: STANDARD.encode(ciphertext), + }, + }; + let resolver = CountingResolver { + candidate_calls: Cell::new(0), + key: key.to_vec(), + }; + + assert!(matches!( + DecryptContext::new(&resolver).decrypt_data(&encrypted), + Err(XmlEncError::UnsupportedAlgorithm(_)) + )); + assert_eq!(resolver.candidate_calls.get(), 0); + } + #[test] fn cbc_padding_errors_do_not_expose_decrypted_octets() { // The error contract hides padding details, but callers still need an diff --git a/src/xmlenc/encrypt.rs b/src/xmlenc/encrypt.rs index 9777d49e..03faacca 100644 --- a/src/xmlenc/encrypt.rs +++ b/src/xmlenc/encrypt.rs @@ -155,6 +155,12 @@ impl EncryptedDataBuilder { EncryptedDataType::Element => { let result = self.encrypt_payload(source.as_bytes(), Some(EncryptedDataType::Element))?; + validate_replacement_document_len( + xml.len(), + range.len(), + result.encrypted_data_xml.len(), + self.policy.resources.max_encryption_document_bytes, + )?; Ok(replace_range(xml, range, &result.encrypted_data_xml)) } EncryptedDataType::Content => { @@ -162,6 +168,28 @@ impl EncryptedDataBuilder { let plaintext = &source[boundaries.content.clone()]; let result = self.encrypt_payload(plaintext.as_bytes(), Some(EncryptedDataType::Content))?; + let (removed, inserted) = if boundaries.self_closing { + let slash = source[..boundaries.start_tag_end] + .rfind('/') + .ok_or_else(|| { + XmlEncError::InvalidStructure("self-closing tag has no slash".into()) + })?; + ( + range.len(), + slash + .saturating_add(result.encrypted_data_xml.len()) + .saturating_add(boundaries.qualified_name.len()) + .saturating_add(4), + ) + } else { + (boundaries.content.len(), result.encrypted_data_xml.len()) + }; + validate_replacement_document_len( + xml.len(), + removed, + inserted, + self.policy.resources.max_encryption_document_bytes, + )?; replace_element_content(xml, range, source, boundaries, &result.encrypted_data_xml) } EncryptedDataType::Other(_) => Err(XmlEncError::InvalidEncryptionConfig( @@ -424,6 +452,18 @@ fn validate_document_len(actual: usize, maximum: usize) -> Result<(), XmlEncErro Ok(()) } +fn validate_replacement_document_len( + document_len: usize, + removed_len: usize, + inserted_len: usize, + maximum: usize, +) -> Result<(), XmlEncError> { + let actual = document_len + .saturating_sub(removed_len) + .saturating_add(inserted_len); + validate_document_len(actual, maximum) +} + fn validate_content_key(algorithm: DataEncryptionAlgorithm, key: &[u8]) -> Result<(), XmlEncError> { if key.len() == algorithm.key_len() { Ok(()) @@ -983,6 +1023,37 @@ mod tests { )); } + #[test] + fn encrypted_replacement_must_fit_document_policy() { + // Cipher framing, base64, and EncryptedData markup expand the selected + // range; the returned document must remain valid input to decryption. + for encrypted_type in [EncryptedDataType::Element, EncryptedDataType::Content] { + let document = "x"; + let policy = crate::policy::EncryptionPolicy { + resources: crate::policy::ResourcePolicy { + max_encryption_document_bytes: document.len(), + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::EncryptionPolicy::default() + }; + + assert!(matches!( + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .encryption_type(encrypted_type) + .direct_key([0_u8; 16]) + .policy(policy) + .encrypt_document( + document, + DocumentEncryptionOptions { + element_id: Some("selected"), + allow_dtd: false, + }, + ), + Err(XmlEncError::DocumentTooLarge { .. }) + )); + } + } + #[test] fn encryption_policy_bounds_xml_nodes_at_both_parse_entry_points() { // XML plaintext and whole-document encryption are separate parser paths; From c5891bbf47c77d392a8a53fab3d73bcea75eb8eb Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sat, 8 Aug 2026 21:33:27 +0300 Subject: [PATCH 30/63] fix(xmldsig): harden X.509 resolution - bind X509Digest evaluation to the operation provider\n- validate composed trust limits and bound generated paths\n- continue self-issued rollover chains to trusted issuers\n- report only capabilities compiled into the provider --- docs/xmldsig.md | 9 + src/policy.rs | 4 +- src/provider.rs | 129 +++++++++++--- src/xmldsig/keys.rs | 383 +++++++++++++++++++++++++++++++++++++++--- src/xmldsig/parse.rs | 82 ++++++--- src/xmldsig/verify.rs | 51 +++++- 6 files changed, 579 insertions(+), 79 deletions(-) diff --git a/docs/xmldsig.md b/docs/xmldsig.md index bb946bb1..ef49f0fe 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -38,6 +38,11 @@ randomness behind the provider boundary. Custom `SigningKey` implementations who randomness must implement `SigningKey::sign_with_provider`; deterministic and externally managed keys can use the default implementation. +`VerifyContext::provider` covers every verification-time cryptographic operation, including +reference digests, signature verification, and `X509Digest` selector evaluation performed by +`DefaultKeyResolver`. Custom resolvers that evaluate cryptographic key metadata should override +`KeyResolver::resolve_with_policy_and_provider`; source-only resolvers can retain the default hook. + ## Verification Policy For production verification, configure `KeyResolverConfig::lookup_certs` with untrusted @@ -48,6 +53,10 @@ a trusted anchor. A trusted certificate selected directly remains an anchor, whi certificates provide key material and do not become trusted merely because they appear in ``. Exact DER duplicates across configured pools are evaluated once; when the same certificate is present in both pools, its explicit trusted classification is retained. +Configured chain depth and candidate-path limits are validated after resolver defaults compose with +the operation policy. Candidate-path accounting includes every generated partial path, and +self-issued rollover certificates continue toward a distinct same-name issuer when its signature +validates; neither condition can bypass the configured work bounds or trust anchor requirement. `VerifyResult::status` reports core validation: `Valid` means the cryptographic signature and every `` reference succeeded. `Invalid(reason)` means core validation completed but diff --git a/src/policy.rs b/src/policy.rs index c626388c..de61c19d 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -177,7 +177,7 @@ pub struct KeyTrustPolicy { pub verify_x509_chains: bool, /// Maximum validated path depth. pub max_x509_chain_depth: usize, - /// Maximum signature-valid candidate paths considered before validation. + /// Maximum complete or partial signature-valid path states generated. pub max_x509_candidate_paths: usize, /// Permit legacy RSA-SHA1 verification after key resolution. pub allow_legacy_rsa_sha1: bool, @@ -203,7 +203,7 @@ impl Default for KeyTrustPolicy { #[cfg(feature = "xmldsig")] impl KeyTrustPolicy { - fn validate(&self) -> Result<(), PolicyViolation> { + pub(crate) fn validate(&self) -> Result<(), PolicyViolation> { ResourcePolicy::within("X.509 chain depth", self.max_x509_chain_depth, 9)?; ResourcePolicy::within("X.509 candidate paths", self.max_x509_candidate_paths, 64) } diff --git a/src/provider.rs b/src/provider.rs index a82989bc..efe1e5d5 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -259,30 +259,80 @@ impl CryptoProvider for RustCryptoProvider { 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 => query.algorithm.is_none_or(is_supported_signing_uri), - ProviderOperation::Verify => query.algorithm.is_none_or(is_supported_signature_uri), + ProviderOperation::Digest => { + #[cfg(feature = "xmldsig")] + { + 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" + ) + }) + } + #[cfg(not(feature = "xmldsig"))] + { + false + } + } + ProviderOperation::Sign => { + #[cfg(feature = "xmldsig")] + { + query.algorithm.is_none_or(is_supported_signing_uri) + } + #[cfg(not(feature = "xmldsig"))] + { + false + } + } + ProviderOperation::Verify => { + #[cfg(feature = "xmldsig")] + { + query.algorithm.is_none_or(is_supported_signature_uri) + } + #[cfg(not(feature = "xmldsig"))] + { + false + } + } ProviderOperation::Encrypt | ProviderOperation::Decrypt => { - query.algorithm.is_none_or(is_supported_data_encryption_uri) + #[cfg(feature = "xmlenc")] + { + query.algorithm.is_none_or(is_supported_data_encryption_uri) + } + #[cfg(not(feature = "xmlenc"))] + { + false + } } ProviderOperation::KeyWrap | ProviderOperation::KeyUnwrap => { - query.algorithm.is_none_or(is_supported_key_wrap_uri) + #[cfg(feature = "xmlenc")] + { + query.algorithm.is_none_or(is_supported_key_wrap_uri) + } + #[cfg(not(feature = "xmlenc"))] + { + false + } + } + ProviderOperation::KeyTransport => { + #[cfg(feature = "xmlenc")] + { + 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" + ) + }) + } + #[cfg(not(feature = "xmlenc"))] + { + false + } } - 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, } @@ -418,6 +468,7 @@ impl RustCryptoProvider { } } +#[cfg(feature = "xmldsig")] fn is_supported_signature_uri(algorithm: &str) -> bool { matches!( algorithm, @@ -432,6 +483,7 @@ fn is_supported_signature_uri(algorithm: &str) -> bool { ) } +#[cfg(feature = "xmldsig")] fn is_supported_signing_uri(algorithm: &str) -> bool { matches!( algorithm, @@ -443,6 +495,7 @@ fn is_supported_signing_uri(algorithm: &str) -> bool { ) } +#[cfg(feature = "xmlenc")] fn is_supported_data_encryption_uri(algorithm: &str) -> bool { matches!( algorithm, @@ -453,6 +506,7 @@ fn is_supported_data_encryption_uri(algorithm: &str) -> bool { ) } +#[cfg(feature = "xmlenc")] fn is_supported_key_wrap_uri(algorithm: &str) -> bool { matches!( algorithm, @@ -991,6 +1045,41 @@ mod tests { })); } + #[cfg(not(feature = "xmlenc"))] + #[test] + fn capability_query_hides_xmlenc_operations_when_feature_is_disabled() { + // Capability discovery is a runtime API over the current build, so it + // must not advertise methods removed from CryptoProvider by cfg. + for operation in [ + ProviderOperation::Encrypt, + ProviderOperation::Decrypt, + ProviderOperation::KeyWrap, + ProviderOperation::KeyUnwrap, + ProviderOperation::KeyTransport, + ] { + assert!(!RUST_CRYPTO_PROVIDER.supports(CapabilityQuery { + operation, + algorithm: None, + })); + } + } + + #[cfg(not(feature = "xmldsig"))] + #[test] + fn capability_query_hides_xmldsig_operations_when_feature_is_disabled() { + // Digest/sign/verify methods do not exist in an xmlenc-only provider. + for operation in [ + ProviderOperation::Digest, + ProviderOperation::Sign, + ProviderOperation::Verify, + ] { + assert!(!RUST_CRYPTO_PROVIDER.supports(CapabilityQuery { + operation, + algorithm: None, + })); + } + } + #[cfg(feature = "xmldsig")] #[test] fn rsa_signing_uses_the_selected_providers_randomness() { diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 982cf5f1..ecac90f8 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -226,7 +226,8 @@ impl DefaultKeyResolver { info: &X509DataInfo, algorithm: SignatureAlgorithm, trust: &crate::policy::KeyTrustPolicy, - ) -> Result, KeyResolutionError> { + provider: &dyn crate::provider::CryptoProvider, + ) -> Result, DsigError> { let certificate_der = if let Some(&signing_index) = info.certificate_chain.first() { let certificate_der = info .certificates @@ -238,7 +239,7 @@ impl DefaultKeyResolver { } certificate_der } else { - let Some(selected) = self.resolve_configured_x509(info, trust)? else { + let Some(selected) = self.resolve_configured_x509(info, trust, provider)? else { return Ok(None); }; selected @@ -252,7 +253,7 @@ impl DefaultKeyResolver { let (rest, certificate) = X509Certificate::from_der(&certificate_der) .map_err(|_| KeyResolutionError::InvalidCertificate)?; if !rest.is_empty() { - return Err(KeyResolutionError::InvalidCertificate); + return Err(KeyResolutionError::InvalidCertificate.into()); } let public_key_bytes = certificate.public_key().raw.to_vec(); validate_spki_algorithm(&public_key_bytes, algorithm)?; @@ -369,7 +370,8 @@ impl DefaultKeyResolver { &self, info: &X509DataInfo, trust: &crate::policy::KeyTrustPolicy, - ) -> Result, KeyResolutionError> { + provider: &dyn crate::provider::CryptoProvider, + ) -> Result, DsigError> { if !x509_data_has_lookup_identifiers(info) { return Ok(None); } @@ -405,13 +407,9 @@ impl DefaultKeyResolver { } let parsed = parse_x509_certificate(certificate_der) .map_err(|_| KeyResolutionError::InvalidCertificate)?; - let is_match = x509_certificate_matches_any_selector(info, &parsed, certificate_der) - .map_err(|error| match error { - ParseError::UnsupportedAlgorithm { uri } => { - KeyResolutionError::UnsupportedDigestAlgorithm(uri) - } - _ => KeyResolutionError::InvalidCertificate, - })?; + let is_match = + x509_certificate_matches_any_selector(info, &parsed, certificate_der, provider) + .map_err(map_x509_selector_error)?; if is_match { matches.push((available.certificates.len(), parsed.clone())); } @@ -430,19 +428,18 @@ impl DefaultKeyResolver { parsed_certificates: matches.iter().map(|(_, parsed)| parsed.clone()).collect(), ..X509DataInfo::default() }; - if !x509_selector_categories_match_chain(&X509DataInfo { - subject_names: info.subject_names.clone(), - issuer_serials: info.issuer_serials.clone(), - skis: info.skis.clone(), - digests: info.digests.clone(), - ..matched_chain - }) - .map_err(|error| match error { - ParseError::UnsupportedAlgorithm { uri } => { - KeyResolutionError::UnsupportedDigestAlgorithm(uri) - } - _ => KeyResolutionError::InvalidCertificate, - })? { + if !x509_selector_categories_match_chain( + &X509DataInfo { + subject_names: info.subject_names.clone(), + issuer_serials: info.issuer_serials.clone(), + skis: info.skis.clone(), + digests: info.digests.clone(), + ..matched_chain + }, + provider, + ) + .map_err(map_x509_selector_error)? + { return Ok(None); } @@ -461,7 +458,7 @@ impl DefaultKeyResolver { .collect::>(); match leaves.as_slice() { [(index, _)] => *index, - _ => return Err(KeyResolutionError::AmbiguousCertificate), + _ => return Err(KeyResolutionError::AmbiguousCertificate.into()), } } }; @@ -538,14 +535,18 @@ impl DefaultKeyResolver { key_info: Option<&KeyInfo>, algorithm: SignatureAlgorithm, trust: &crate::policy::KeyTrustPolicy, + provider: &dyn crate::provider::CryptoProvider, ) -> Result>, DsigError> { + trust.validate()?; let Some(key_info) = key_info else { return Ok(None); }; 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, trust)?, + KeyInfoSource::X509Data(info) => { + self.resolve_x509(info, algorithm, trust, provider)? + } KeyInfoSource::DerEncodedKeyValue(public_key_bytes) => { validate_spki_algorithm(public_key_bytes, algorithm)?; Some(VerificationKey { @@ -596,7 +597,12 @@ impl KeyResolver for DefaultKeyResolver { key_info: Option<&KeyInfo>, algorithm: SignatureAlgorithm, ) -> Result>, DsigError> { - self.resolve_with_trust(key_info, algorithm, &self.config.trust) + self.resolve_with_trust( + key_info, + algorithm, + &self.config.trust, + crate::provider::default_provider(), + ) } fn resolve_with_policy<'a>( @@ -604,6 +610,21 @@ impl KeyResolver for DefaultKeyResolver { key_info: Option<&KeyInfo>, algorithm: SignatureAlgorithm, policy: &crate::policy::VerificationPolicy, + ) -> Result>, DsigError> { + self.resolve_with_policy_and_provider( + key_info, + algorithm, + policy, + crate::provider::default_provider(), + ) + } + + fn resolve_with_policy_and_provider<'a>( + &'a self, + key_info: Option<&KeyInfo>, + algorithm: SignatureAlgorithm, + policy: &crate::policy::VerificationPolicy, + provider: &dyn crate::provider::CryptoProvider, ) -> Result>, DsigError> { // Resolver defaults and operation policy compose fail-closed. X.509 // validation requirements can only become stricter, while the legacy @@ -627,7 +648,7 @@ impl KeyResolver for DefaultKeyResolver { .verification_time .or(self.config.trust.verification_time), }; - self.resolve_with_trust(key_info, algorithm, &trust) + self.resolve_with_trust(key_info, algorithm, &trust, provider) } fn consumes_document_key_info(&self) -> bool { @@ -635,6 +656,16 @@ impl KeyResolver for DefaultKeyResolver { } } +fn map_x509_selector_error(error: ParseError) -> DsigError { + match error { + ParseError::Provider(error) => DsigError::Provider(error), + ParseError::UnsupportedAlgorithm { uri } => { + KeyResolutionError::UnsupportedDigestAlgorithm(uri).into() + } + _ => KeyResolutionError::InvalidCertificate.into(), + } +} + fn rsa_key_value_to_spki_der( modulus: &[u8], exponent: &[u8], @@ -747,11 +778,126 @@ fn validate_spki_algorithm( #[cfg(test)] mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use base64::{Engine, engine::general_purpose::STANDARD}; use rsa::{pkcs8::DecodePublicKey, traits::PublicKeyParts}; use super::*; + struct RejectSecondSha512Provider { + sha512_calls: AtomicUsize, + } + + impl crate::provider::CryptoProvider for RejectSecondSha512Provider { + fn name(&self) -> &'static str { + "reject-second-sha512" + } + + 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: super::super::DigestAlgorithm, + data: &[u8], + ) -> Result, crate::provider::ProviderError> { + if algorithm == super::super::DigestAlgorithm::Sha512 + && self.sha512_calls.fetch_add(1, Ordering::Relaxed) > 0 + { + return Err(crate::provider::ProviderError::Unsupported { + operation: crate::provider::ProviderOperation::Digest, + algorithm: Some(algorithm.uri().to_owned()), + }); + } + crate::provider::default_provider().digest(algorithm, data) + } + + fn sign( + &self, + key: &dyn super::super::SigningKey, + algorithm: SignatureAlgorithm, + data: &[u8], + ) -> Result, super::super::SigningKeyError> { + crate::provider::default_provider().sign(key, algorithm, data) + } + + fn verify( + &self, + key: &dyn VerifyingKey, + algorithm: 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) + } + } + fn chain_policy() -> crate::policy::KeyTrustPolicy { crate::policy::KeyTrustPolicy { verify_x509_chains: true, @@ -893,6 +1039,40 @@ mod tests { assert_eq!(config.trust.max_x509_chain_depth, 9); } + #[test] + fn resolver_rejects_zero_composed_x509_resource_limits() { + // Resolver-local defaults tighten the operation snapshot after the + // context validates it, so the composed trust policy needs its own gate. + for trust in [ + crate::policy::KeyTrustPolicy { + verify_x509_chains: true, + max_x509_chain_depth: 0, + ..crate::policy::KeyTrustPolicy::default() + }, + crate::policy::KeyTrustPolicy { + verify_x509_chains: true, + max_x509_candidate_paths: 0, + ..crate::policy::KeyTrustPolicy::default() + }, + ] { + let certificate = certificate_der(RSA_4096_CERTIFICATE); + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + trusted_certs: vec![certificate], + trust, + ..KeyResolverConfig::default() + }); + let error = super::super::VerifyContext::new() + .key_resolver(&resolver) + .verify(&x509_signature_with_leaf_subject()) + .expect_err("zero composed X.509 limits must fail as policy errors"); + + assert!(matches!( + error, + DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit { actual: 0, .. }) + )); + } + } + #[test] fn hmac_key_rejects_empty_secret_and_wrong_algorithm() { // HMAC secrets are caller-owned and cannot be reused as asymmetric keys. @@ -1396,6 +1576,86 @@ mod tests { assert!(resolved.is_some()); } + #[test] + fn self_issued_rollover_continues_to_same_name_trusted_signer() { + // Subject/issuer name equality does not prove self-signing: rollover + // certificates may be issued by a distinct same-name trust anchor. + let root = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("rollover authority", true), + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("root should be self-signable"); + let rollover_params = generated_certificate_params("rollover authority", true); + let rollover_key = + rcgen::KeyPair::generate().expect("rollover key generation should succeed"); + let rollover_certificate = rollover_params + .signed_by(&rollover_key, &root) + .expect("root should sign the same-name rollover certificate"); + let rollover_issuer = rcgen::Issuer::from_params(&rollover_params, &rollover_key); + let leaf = generated_certificate_params("rollover leaf", false) + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &rollover_issuer, + ) + .expect("rollover key should sign the leaf"); + let leaf_metadata = + parse_x509_certificate(leaf.der()).expect("generated leaf metadata should parse"); + 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![root.der().to_vec()], + lookup_certs: vec![leaf.der().to_vec(), rollover_certificate.der().to_vec()], + trust: chain_policy(), + ..KeyResolverConfig::default() + }); + + let resolved = resolver + .resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) + .expect("same-name rollover path must reach its configured signer"); + + assert!(resolved.is_some()); + } + + #[test] + fn x509_candidate_limit_counts_generated_partial_paths() { + // A narrow DFS frontier can still generate unbounded partial paths over + // time, so the resource limit must account for every generated state. + let root = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("candidate 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("candidate intermediate", true), + rcgen::KeyPair::generate().expect("intermediate key generation should succeed"), + &root, + ) + .expect("root should sign the intermediate"); + let leaf = generated_certificate_params("candidate leaf", false) + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &intermediate, + ) + .expect("intermediate should sign the leaf"); + let info = x509_info( + vec![ + root.der().to_vec(), + intermediate.der().to_vec(), + leaf.der().to_vec(), + ], + 2, + ); + + assert!(matches!( + build_x509_certificate_paths_to_trusted_prefix(&info, 2, 1, 9, 2), + Err(X509ChainBuildError::AmbiguousIssuer) + )); + } + #[test] fn selector_resolved_leaf_disambiguates_same_subject_issuers_by_signature() { // Certificate renewal may leave multiple configured intermediates with @@ -1660,6 +1920,39 @@ mod tests { )); } + #[test] + fn x509_digest_selector_uses_operation_provider() { + // The SHA-512 selector is distinct from the SHA-256 reference digest, + // so only provider-aware key selection can surface this rejection. + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + lookup_certs: vec![certificate_der(RSA_4096_CERTIFICATE)], + trusted_certs: vec![ + certificate_der(include_str!("../../tests/fixtures/keys/ca2cert.pem")), + certificate_der(include_str!("../../tests/fixtures/keys/cacert.pem")), + ], + ..KeyResolverConfig::default() + }); + let provider = RejectSecondSha512Provider { + sha512_calls: AtomicUsize::new(0), + }; + let error = super::super::VerifyContext::new() + .key_resolver(&resolver) + .provider(&provider) + .verify(X509_DIGEST_SIGNATURE) + .expect_err("X509Digest selection must use the operation provider"); + + assert!( + matches!( + error, + DsigError::Provider(crate::provider::ProviderError::Unsupported { + operation: crate::provider::ProviderOperation::Digest, + algorithm: Some(ref uri), + }) if uri == super::super::DigestAlgorithm::Sha512.uri() + ), + "unexpected error: {error:?}" + ); + } + #[test] fn resolves_named_key_end_to_end() { // KeyName lookup must preserve the same cryptographic result as embedded X509Data. @@ -1750,6 +2043,40 @@ mod tests { )); } + #[test] + fn embedded_x509_digest_selection_uses_operation_provider() { + // Embedded certificate selection happens while KeyInfo is parsed, so + // that parser path must retain the verification operation's provider. + let certificate = certificate_der(RSA_4096_CERTIFICATE); + let digest = + super::super::compute_digest(super::super::DigestAlgorithm::Sha512, &certificate); + let xml = format!( + "{}{}", + STANDARD.encode(&certificate), + super::super::DigestAlgorithm::Sha512.uri(), + STANDARD.encode(digest), + ); + let document = roxmltree::Document::parse(&xml).expect("generated KeyInfo must be XML"); + let provider = RejectSecondSha512Provider { + sha512_calls: AtomicUsize::new(1), + }; + + let error = + super::super::parse::parse_key_info_with_provider(document.root_element(), &provider) + .expect_err("embedded X509Digest selection must use the operation provider"); + + assert!( + matches!( + error, + ParseError::Provider(crate::provider::ProviderError::Unsupported { + operation: crate::provider::ProviderOperation::Digest, + algorithm: Some(ref uri), + }) if uri == super::super::DigestAlgorithm::Sha512.uri() + ), + "unexpected error: {error:?}" + ); + } + #[test] fn generic_key_resolution_keeps_legacy_capability_source_independent() { let certificate = diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index 18657d06..653f7d60 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -25,7 +25,9 @@ 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}; +#[cfg(test)] +use super::digest::compute_digest; +use super::digest::{DigestAlgorithm, compute_digest_with_provider, constant_time_eq}; use super::transforms::{self, Transform}; use super::whitespace::{ XmlBase64NormalizeLimitedError, is_xml_whitespace_only, normalize_xml_base64_text, @@ -321,6 +323,10 @@ pub enum X509PublicKeyInfo { #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum ParseError { + /// The selected cryptographic provider could not evaluate parsed key metadata. + #[error("cryptographic provider error: {0}")] + Provider(#[from] crate::provider::ProviderError), + /// Missing required element. #[error("missing required element: <{element}>")] MissingElement { @@ -607,6 +613,13 @@ fn reference_transforms_and_digest_method<'a, 'input>( /// rejected fail-closed. /// `` may still be empty or contain only non-XMLDSig extension children. pub fn parse_key_info(key_info_node: Node) -> Result { + parse_key_info_with_provider(key_info_node, crate::provider::default_provider()) +} + +pub(crate) fn parse_key_info_with_provider( + key_info_node: Node, + provider: &dyn crate::provider::CryptoProvider, +) -> Result { verify_ds_element(key_info_node, "KeyInfo")?; ensure_no_non_whitespace_text(key_info_node, "KeyInfo")?; @@ -630,7 +643,11 @@ 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_with_budget(child, &mut x509_total_binary_len)?; + let x509 = parse_x509_data_dispatch_with_budget_and_provider( + child, + &mut x509_total_binary_len, + provider, + )?; sources.push(KeyInfoSource::X509Data(x509)); } (Some(XMLDSIG_NS), "RetrievalMethod") => { @@ -1082,9 +1099,10 @@ fn decode_crypto_binary( Ok(value) } -pub(crate) fn parse_x509_data_dispatch_with_budget( +pub(crate) fn parse_x509_data_dispatch_with_budget_and_provider( node: Node, total_binary_len: &mut usize, + provider: &dyn crate::provider::CryptoProvider, ) -> Result { verify_ds_element(node, "X509Data")?; ensure_no_non_whitespace_text(node, "X509Data")?; @@ -1147,16 +1165,19 @@ pub(crate) fn parse_x509_data_dispatch_with_budget( } } - info.certificate_chain = build_x509_certificate_chain(&info)?; + info.certificate_chain = build_x509_certificate_chain(&info, provider)?; Ok(info) } -fn build_x509_certificate_chain(info: &X509DataInfo) -> Result, ParseError> { +fn build_x509_certificate_chain( + info: &X509DataInfo, + provider: &dyn crate::provider::CryptoProvider, +) -> Result, ParseError> { if info.parsed_certificates.is_empty() { return Ok(Vec::new()); } - let signing_idx = select_x509_signing_certificate(info)?; + let signing_idx = select_x509_signing_certificate(info, provider)?; build_x509_certificate_chain_from(info, signing_idx).map_err(ParseError::from) } @@ -1270,9 +1291,13 @@ pub(crate) fn build_x509_certificate_paths_to_trusted_prefix( { return Err(X509ChainBuildError::InconsistentMetadata); } + if max_candidate_paths == 0 { + return Err(X509ChainBuildError::AmbiguousIssuer); + } let mut pending = vec![vec![signing_idx]]; let mut completed = Vec::new(); + let mut generated_paths = 1usize; let mut depth_exceeded = false; let mut issuer_cache = vec![None; info.parsed_certificates.len()]; while let Some(path) = pending.pop() { @@ -1281,9 +1306,6 @@ pub(crate) fn build_x509_certificate_paths_to_trusted_prefix( .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 { @@ -1292,9 +1314,6 @@ pub(crate) fn build_x509_certificate_paths_to_trusted_prefix( } let current = &info.parsed_certificates[current_idx]; - if distinguished_names_equal(¤t.subject_dn, ¤t.issuer_dn) { - continue; - } let issuers = issuer_cache[current_idx].get_or_insert_with(|| { info.parsed_certificates .iter() @@ -1314,9 +1333,10 @@ pub(crate) fn build_x509_certificate_paths_to_trusted_prefix( .copied() .filter(|issuer_idx| !path.contains(issuer_idx)) .collect::>(); - if pending.len().saturating_add(issuers.len()) > max_candidate_paths { + if generated_paths.saturating_add(issuers.len()) > max_candidate_paths { return Err(X509ChainBuildError::AmbiguousIssuer); } + generated_paths += issuers.len(); for issuer_idx in issuers { let mut candidate = path.clone(); candidate.push(issuer_idx); @@ -1330,7 +1350,10 @@ pub(crate) fn build_x509_certificate_paths_to_trusted_prefix( Ok(completed) } -fn select_x509_signing_certificate(info: &X509DataInfo) -> Result { +fn select_x509_signing_certificate( + info: &X509DataInfo, + provider: &dyn crate::provider::CryptoProvider, +) -> Result { let has_lookup_identifiers = x509_data_has_lookup_identifiers(info); let mut candidates = Vec::new(); if has_lookup_identifiers { @@ -1340,11 +1363,11 @@ fn select_x509_signing_certificate(info: &X509DataInfo) -> Result Result { let subject_match = info .subject_names @@ -1437,13 +1461,17 @@ pub(crate) fn x509_certificate_matches_any_selector( uri: algorithm_uri.clone(), } })?; - digest_match |= constant_time_eq(&compute_digest(algorithm, certificate_der), expected); + digest_match |= constant_time_eq( + &compute_digest_with_provider(provider, algorithm, certificate_der)?, + expected, + ); } Ok(subject_match || issuer_serial_match || ski_match || digest_match) } pub(crate) fn x509_selector_categories_match_chain( info: &X509DataInfo, + provider: &dyn crate::provider::CryptoProvider, ) -> Result { let subject_match = info.subject_names.iter().all(|subject| { info.parsed_certificates @@ -1480,10 +1508,14 @@ pub(crate) fn x509_selector_categories_match_chain( uri: algorithm_uri.clone(), } })?; - digest_match &= info - .certificates - .iter() - .any(|certificate| constant_time_eq(&compute_digest(algorithm, certificate), expected)); + let mut category_match = false; + for certificate in &info.certificates { + category_match |= constant_time_eq( + &compute_digest_with_provider(provider, algorithm, certificate)?, + expected, + ); + } + digest_match &= category_match; } Ok(subject_match && issuer_serial_match && ski_match && digest_match) @@ -2813,7 +2845,10 @@ BA== ..X509DataInfo::default() }; - assert_eq!(select_x509_signing_certificate(&info).unwrap(), 0); + assert_eq!( + select_x509_signing_certificate(&info, crate::provider::default_provider()).unwrap(), + 0 + ); assert_eq!( build_x509_certificate_chain_from(&info, 0).unwrap(), vec![0, 1, 2] @@ -3066,7 +3101,8 @@ BA== ..X509DataInfo::default() }; - let err = build_x509_certificate_chain(&info).unwrap_err(); + let err = + build_x509_certificate_chain(&info, crate::provider::default_provider()).unwrap_err(); assert!( matches!(err, ParseError::InvalidStructure(message) if message.contains("maximum depth")) ); diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index 07d46133..80a39305 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -23,13 +23,16 @@ use super::digest::compute_digest; use super::digest::{DigestAlgorithm, constant_time_eq}; #[cfg(test)] use super::parse::MAX_REFERENCES_PER_SIGNATURE; +#[cfg(test)] +use super::parse::parse_key_info; use super::parse::{ 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, - parse_x509_certificate, parse_x509_data_dispatch_with_budget, reference_digest_method, + parse_key_info_with_provider, parse_reference_with_xpath_budget, + parse_signed_info_with_xpath_budget, parse_x509_certificate, + parse_x509_data_dispatch_with_budget_and_provider, reference_digest_method, }; use super::signature::{ SignatureVerificationError, verify_dsa_signature_spki, verify_ecdsa_signature_pem, @@ -93,6 +96,21 @@ pub trait KeyResolver { self.resolve(key_info, algorithm) } + /// Resolve under both the operation policy and cryptographic provider. + /// + /// Resolvers that evaluate cryptographic key metadata, such as + /// `X509Digest`, must override this hook. The default keeps existing + /// policy-aware custom resolvers source-compatible. + fn resolve_with_policy_and_provider<'a>( + &'a self, + key_info: Option<&KeyInfo>, + algorithm: SignatureAlgorithm, + policy: &crate::policy::VerificationPolicy, + _provider: &dyn crate::provider::CryptoProvider, + ) -> Result>, DsigError> { + self.resolve_with_policy(key_info, algorithm, policy) + } + /// Return `true` when this resolver consumes document `` material. /// /// The verification pipeline uses this to decide whether malformed @@ -970,7 +988,7 @@ fn verify_signature_with_context( let mut key_info = if should_parse_key_info { signature_children .key_info_node - .map(parse_key_info) + .map(|node| parse_key_info_with_provider(node, ctx.provider)) .transpose() .map_err(SignatureVerificationPipelineError::ParseKeyInfo)? } else { @@ -1056,6 +1074,7 @@ fn verify_signature_with_context( &resolver, ctx.external_resources, ctx.policy.retrieval_uri_types, + ctx.provider, )? } else { RetrievalMaterialization::default() @@ -1205,6 +1224,7 @@ fn materialize_retrieval_methods( resolver: &UriReferenceResolver<'_>, external_resources: Option<&HashMap>>, allowed_uri_types: UriTypeSet, + provider: &dyn crate::provider::CryptoProvider, ) -> Result { let retrieval_count = key_info .sources @@ -1327,8 +1347,12 @@ fn materialize_retrieval_methods( }); } }; - let data = parse_x509_data_dispatch_with_budget(node, &mut total_binary_len) - .map_err(SignatureVerificationPipelineError::ParseKeyInfo)?; + let data = parse_x509_data_dispatch_with_budget_and_provider( + node, + &mut total_binary_len, + provider, + ) + .map_err(SignatureVerificationPipelineError::ParseKeyInfo)?; materialized.push(super::parse::KeyInfoSource::X509Data(data)); } else { materialized.push(super::parse::KeyInfoSource::RetrievalMethod { @@ -1683,7 +1707,12 @@ fn resolve_verifying_key<'k>( return Ok(Some(ResolvedVerifyingKey::Borrowed(key))); } if let Some(resolver) = ctx.key_resolver { - let resolved = resolver.resolve_with_policy(key_info, algorithm, &ctx.policy)?; + let resolved = resolver.resolve_with_policy_and_provider( + key_info, + algorithm, + &ctx.policy, + ctx.provider, + )?; return Ok(resolved.map(ResolvedVerifyingKey::Owned)); } Ok(None) @@ -3357,6 +3386,7 @@ mod tests { &resolver, None, UriTypeSet::SAME_DOCUMENT, + crate::provider::default_provider(), ) .expect("XPath filter must produce one X509Data-rooted node-set"); assert!(matches!( @@ -3388,6 +3418,7 @@ mod tests { &UriReferenceResolver::new(&document), None, UriTypeSet::SAME_DOCUMENT, + crate::provider::default_provider(), ) .expect("a direct X509Data target needs no transform"); assert!(matches!( @@ -3427,6 +3458,7 @@ mod tests { &UriReferenceResolver::new(&document), Some(&resources), UriTypeSet::ALL, + crate::provider::default_provider(), ) .expect("RetrievalMethod should resolve against inherited xml:base"); @@ -3457,6 +3489,7 @@ mod tests { &UriReferenceResolver::new(&document), None, UriTypeSet::SAME_DOCUMENT, + crate::provider::default_provider(), ) .expect_err("a wrapper target requires an explicit selection transform"); assert!(matches!( @@ -3487,6 +3520,7 @@ mod tests { &UriReferenceResolver::new(&document), None, UriTypeSet::SAME_DOCUMENT, + crate::provider::default_provider(), ) .expect_err("filter output without an X509Data root must be rejected"); assert!(matches!( @@ -3516,6 +3550,7 @@ mod tests { &UriReferenceResolver::new(&document), None, UriTypeSet::SAME_DOCUMENT, + crate::provider::default_provider(), ) .expect_err("multiple transformed X509Data roots must be rejected"); assert!(matches!( @@ -3549,6 +3584,7 @@ mod tests { &UriReferenceResolver::new(&document), None, UriTypeSet::SAME_DOCUMENT, + crate::provider::default_provider(), ) .unwrap(); assert!(matches!( @@ -3586,6 +3622,7 @@ mod tests { &UriReferenceResolver::new(&document), Some(&resources), UriTypeSet::ALL, + crate::provider::default_provider(), ) .expect_err("retrieval count must be bounded before materialization"); assert!(matches!( @@ -3622,6 +3659,7 @@ mod tests { &UriReferenceResolver::new(&document), Some(&resources), UriTypeSet::ALL, + crate::provider::default_provider(), ) .unwrap(); assert!(matches!( @@ -3655,6 +3693,7 @@ mod tests { &UriReferenceResolver::new(&document), Some(&resources), UriTypeSet::ALL, + crate::provider::default_provider(), ) .expect_err("empty URI must retain same-document semantics"); assert!(matches!( From 173ccb388bf3f1f5574967532502d11f2256a455 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 9 Aug 2026 00:19:28 +0300 Subject: [PATCH 31/63] fix(xmldsig): enforce transform policy - apply the transform allowlist to SignedInfo and key retrieval - bound RetrievalMethod Type metadata before allocation - cover policy behavior in unit and integration tests --- docs/xmldsig.md | 4 + src/policy.rs | 2 +- src/xmldsig/parse.rs | 25 ++++++- src/xmldsig/verify.rs | 104 +++++++++++++++++++++----- tests/base64_transform_integration.rs | 2 +- tests/xpath_transform_integration.rs | 14 +++- 6 files changed, 128 insertions(+), 23 deletions(-) diff --git a/docs/xmldsig.md b/docs/xmldsig.md index ef49f0fe..03169974 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -90,6 +90,10 @@ and the Merlin same-document `X509Data` XPath selection. Relative external `Refe `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. +`VerifyContext::allowed_transforms` applies to Reference transforms and implicit C14N, +the declared SignedInfo canonicalization method, and supported RetrievalMethod transforms. +Allowing XPath for signed payload processing therefore also explicitly permits the bounded +Merlin X509Data retrieval selector; omitting XPath rejects that key-retrieval path. 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/src/policy.rs b/src/policy.rs index de61c19d..f2724a9b 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -223,7 +223,7 @@ pub struct VerificationPolicy { pub reference_uri_types: UriTypeSet, /// Allowed RetrievalMethod URI classes. pub retrieval_uri_types: UriTypeSet, - /// Allowed transform URIs; `None` accepts every implemented transform. + /// Allowed transform and canonicalization URIs; `None` accepts every implemented algorithm. pub transforms: Option>, /// Whether authenticated Manifest references are processed. pub process_manifests: bool, diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index 653f7d60..81e5cbd8 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -669,7 +669,13 @@ pub(crate) fn parse_key_info_with_provider( .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 resource_type = child.attribute("Type"); + if resource_type.is_some_and(|value| value.len() > MAX_KEY_NAME_TEXT_LEN) { + return Err(ParseError::InvalidStructure( + "RetrievalMethod Type exceeds maximum length".into(), + )); + } + let resource_type = resource_type.map(str::to_owned); let transforms = if resource_type.as_deref() == Some("http://www.w3.org/2000/09/xmldsig#X509Data") { @@ -3565,6 +3571,23 @@ BA== )); } + #[test] + fn parse_key_info_rejects_oversized_retrieval_method_type() { + // Type is advisory, but retaining it must not allocate unbounded + // attacker-controlled KeyInfo metadata before resolution. + let oversized_type = "x".repeat(MAX_KEY_NAME_TEXT_LEN + 1); + let xml = format!( + r##""## + ); + let document = Document::parse(&xml).unwrap(); + + assert!(matches!( + parse_key_info(document.root_element()), + Err(ParseError::InvalidStructure(reason)) + if reason == "RetrievalMethod Type exceeds maximum length" + )); + } + #[test] fn parse_key_info_accepts_namespace_equivalent_retrieval_xpath_prefix() { let xml = r##" diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index 80a39305..f322304e 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -39,11 +39,11 @@ use super::signature::{ verify_rsa_signature_pem, }; #[cfg(test)] -use super::transforms::{BASE64_TRANSFORM_URI, XPATH_TRANSFORM_URI}; +use super::transforms::BASE64_TRANSFORM_URI; use super::transforms::{ DEFAULT_IMPLICIT_C14N_URI, Transform, TransformExecutionBudget, TransformOptions, - XPathHereSemantics, XPathSignatureParseBudget, execute_transforms_with_options_and_budget, - transform_chain_produces_binary, + XPATH_TRANSFORM_URI, 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}; @@ -316,16 +316,16 @@ impl<'a> VerifyContext<'a> { self } - /// Restrict allowed transform algorithms by URI. + /// Restrict allowed transform and canonicalization algorithms by URI. /// /// Example values: /// - `http://www.w3.org/2000/09/xmldsig#enveloped-signature` /// - `http://www.w3.org/2001/10/xml-exc-c14n#` /// - /// When a `` has no explicit canonicalization transform, XMLDSig - /// applies implicit default C14N (`http://www.w3.org/TR/2001/REC-xml-c14n-20010315`). - /// If an allowlist is configured, include that URI as well unless all - /// references use explicit `Transform::C14n(...)`. + /// The allowlist covers explicit Reference and RetrievalMethod transforms, + /// the declared SignedInfo canonicalization method, and implicit default + /// C14N (`http://www.w3.org/TR/2001/REC-xml-c14n-20010315`) when a Reference + /// transform chain ends as a node set. pub fn allowed_transforms(mut self, transforms: I) -> Self where I: IntoIterator, @@ -1037,6 +1037,7 @@ fn verify_signature_with_context( ctx.policy.reference_uri_types, ctx.allowed_transform_uris(), )?; + enforce_transform_allowed(ctx.allowed_transform_uris(), signed_info.c14n_method.uri())?; if let Some(resources) = ctx.external_resources { let mut total = 0usize; @@ -1074,6 +1075,7 @@ fn verify_signature_with_context( &resolver, ctx.external_resources, ctx.policy.retrieval_uri_types, + ctx.allowed_transform_uris(), ctx.provider, )? } else { @@ -1224,6 +1226,7 @@ fn materialize_retrieval_methods( resolver: &UriReferenceResolver<'_>, external_resources: Option<&HashMap>>, allowed_uri_types: UriTypeSet, + allowed_transforms: Option<&HashSet>, provider: &dyn crate::provider::CryptoProvider, ) -> Result { let retrieval_count = key_info @@ -1339,6 +1342,7 @@ fn materialize_retrieval_methods( }); } RetrievalMethodTransforms::X509DataNodeSetFilter => { + enforce_transform_allowed(allowed_transforms, XPATH_TRANSFORM_URI)?; select_retrieved_x509_data_root(target)? } RetrievalMethodTransforms::Unsupported => { @@ -1739,11 +1743,7 @@ fn enforce_reference_policies( if let Some(allowed) = allowed_transforms { for transform in &reference.transforms { let transform_uri = transform.algorithm_uri(); - if !allowed.contains(transform_uri) { - return Err(SignatureVerificationPipelineError::DisallowedTransform { - algorithm: transform_uri.to_owned(), - }); - } + enforce_transform_allowed(Some(allowed), transform_uri)?; } // External dereference has an octet-stream data type independent of @@ -1754,16 +1754,26 @@ fn enforce_reference_policies( 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(), - }); + if !produces_binary { + enforce_transform_allowed(Some(allowed), DEFAULT_IMPLICIT_C14N_URI)?; } } } Ok(()) } +fn enforce_transform_allowed( + allowed_transforms: Option<&HashSet>, + algorithm: &str, +) -> Result<(), SignatureVerificationPipelineError> { + if allowed_transforms.is_some_and(|allowed| !allowed.contains(algorithm)) { + return Err(SignatureVerificationPipelineError::DisallowedTransform { + algorithm: algorithm.to_owned(), + }); + } + Ok(()) +} + #[derive(Debug, Clone, Copy)] struct SignatureChildNodes<'a, 'input> { signed_info_node: Node<'a, 'input>, @@ -2596,6 +2606,56 @@ mod tests { )); } + #[test] + fn verify_context_applies_transform_allowlist_to_signed_info_c14n() { + // Reference C14N remains allowlisted; only the distinct SignedInfo + // canonicalization method should trigger this policy rejection. + let xml = signature_with_target_reference("AQ==").replacen( + "", + "", + 1, + ); + let error = VerifyContext::new() + .key(&AcceptingKey) + .allowed_transforms(["http://www.w3.org/2001/10/xml-exc-c14n#"]) + .verify(&xml) + .expect_err("SignedInfo C14N must obey the operation transform allowlist"); + + assert!(matches!( + error, + SignatureVerificationPipelineError::DisallowedTransform { ref algorithm } + if algorithm == "http://www.w3.org/TR/2001/REC-xml-c14n-20010315" + )); + } + + #[test] + fn verify_context_applies_transform_allowlist_to_key_retrieval() { + // The reference and SignedInfo both use exclusive C14N. The only XPath + // operation is document-selected key retrieval and must be rejected. + let xml = signature_with_target_reference("AQ==") + .replacen( + "", + r##"ancestor-or-self::ds:X509Data"##, + 1, + ) + .replacen( + "
", + r#"CN=leaf
"#, + 1, + ); + let error = VerifyContext::new() + .key_resolver(&ConsumingKeyInfoResolver) + .allowed_transforms(["http://www.w3.org/2001/10/xml-exc-c14n#"]) + .verify(&xml) + .expect_err("RetrievalMethod XPath must obey the operation transform allowlist"); + + assert!(matches!( + error, + SignatureVerificationPipelineError::DisallowedTransform { ref algorithm } + if algorithm == XPATH_TRANSFORM_URI + )); + } + fn signature_with_manifest_xml(valid_manifest_digest: bool) -> String { signature_with_manifest_xml_with_manifest_mutation(valid_manifest_digest, |xml| xml) } @@ -3386,6 +3446,7 @@ mod tests { &resolver, None, UriTypeSet::SAME_DOCUMENT, + None, crate::provider::default_provider(), ) .expect("XPath filter must produce one X509Data-rooted node-set"); @@ -3418,6 +3479,7 @@ mod tests { &UriReferenceResolver::new(&document), None, UriTypeSet::SAME_DOCUMENT, + None, crate::provider::default_provider(), ) .expect("a direct X509Data target needs no transform"); @@ -3458,6 +3520,7 @@ mod tests { &UriReferenceResolver::new(&document), Some(&resources), UriTypeSet::ALL, + None, crate::provider::default_provider(), ) .expect("RetrievalMethod should resolve against inherited xml:base"); @@ -3489,6 +3552,7 @@ mod tests { &UriReferenceResolver::new(&document), None, UriTypeSet::SAME_DOCUMENT, + None, crate::provider::default_provider(), ) .expect_err("a wrapper target requires an explicit selection transform"); @@ -3520,6 +3584,7 @@ mod tests { &UriReferenceResolver::new(&document), None, UriTypeSet::SAME_DOCUMENT, + None, crate::provider::default_provider(), ) .expect_err("filter output without an X509Data root must be rejected"); @@ -3550,6 +3615,7 @@ mod tests { &UriReferenceResolver::new(&document), None, UriTypeSet::SAME_DOCUMENT, + None, crate::provider::default_provider(), ) .expect_err("multiple transformed X509Data roots must be rejected"); @@ -3584,6 +3650,7 @@ mod tests { &UriReferenceResolver::new(&document), None, UriTypeSet::SAME_DOCUMENT, + None, crate::provider::default_provider(), ) .unwrap(); @@ -3622,6 +3689,7 @@ mod tests { &UriReferenceResolver::new(&document), Some(&resources), UriTypeSet::ALL, + None, crate::provider::default_provider(), ) .expect_err("retrieval count must be bounded before materialization"); @@ -3659,6 +3727,7 @@ mod tests { &UriReferenceResolver::new(&document), Some(&resources), UriTypeSet::ALL, + None, crate::provider::default_provider(), ) .unwrap(); @@ -3693,6 +3762,7 @@ mod tests { &UriReferenceResolver::new(&document), Some(&resources), UriTypeSet::ALL, + None, crate::provider::default_provider(), ) .expect_err("empty URI must retain same-document semantics"); diff --git a/tests/base64_transform_integration.rs b/tests/base64_transform_integration.rs index 36238d1c..6c22e830 100644 --- a/tests/base64_transform_integration.rs +++ b/tests/base64_transform_integration.rs @@ -65,7 +65,7 @@ fn base64_reference_round_trips_through_signing_and_verification() { let resolver = DefaultKeyResolver::default(); let verified = VerifyContext::new() .key_resolver(&resolver) - .allowed_transforms([BASE64_TRANSFORM_URI]) + .allowed_transforms([BASE64_TRANSFORM_URI, exclusive_c14n().uri()]) .store_pre_digest(true) .verify(&signed) .expect("signed Base64 reference must verify"); diff --git a/tests/xpath_transform_integration.rs b/tests/xpath_transform_integration.rs index 155e1fee..cc42f3a5 100644 --- a/tests/xpath_transform_integration.rs +++ b/tests/xpath_transform_integration.rs @@ -63,7 +63,11 @@ fn verify(signed: &str) -> Result { let resolver = DefaultKeyResolver::default(); VerifyContext::new() .key_resolver(&resolver) - .allowed_transforms([XPATH_FILTER2_TRANSFORM_URI, DEFAULT_IMPLICIT_C14N_URI]) + .allowed_transforms([ + XPATH_FILTER2_TRANSFORM_URI, + DEFAULT_IMPLICIT_C14N_URI, + exclusive_c14n().uri(), + ]) .verify(signed) } @@ -130,7 +134,7 @@ fn filter2_transform_is_enforced_by_the_allowlist() { let resolver = DefaultKeyResolver::default(); let error = VerifyContext::new() .key_resolver(&resolver) - .allowed_transforms([DEFAULT_IMPLICIT_C14N_URI]) + .allowed_transforms([DEFAULT_IMPLICIT_C14N_URI, exclusive_c14n().uri()]) .verify(&signed) .expect_err("unlisted Filter 2.0 transform must be rejected"); @@ -202,7 +206,11 @@ fn here_semantics_are_explicit_across_signing_and_verification() { .expect("standards-mode XPath document must sign"); let standard_result = VerifyContext::new() .key_resolver(&resolver) - .allowed_transforms([XPATH_TRANSFORM_URI, DEFAULT_IMPLICIT_C14N_URI]) + .allowed_transforms([ + XPATH_TRANSFORM_URI, + DEFAULT_IMPLICIT_C14N_URI, + exclusive_c14n().uri(), + ]) .verify(&standard) .expect("standards-mode signature must be processable"); assert_eq!(standard_result.status, DsigStatus::Valid); From 88b185dd3f3dc103f606a8004d081558b66e93bd Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 9 Aug 2026 10:26:14 +0300 Subject: [PATCH 32/63] fix(policy): enforce resource invariants - Allow zero-valued deny-all resource ceilings while retaining nonzero X.509 path bounds - Reject CRL checking without certificate-chain validation - Share bounded XML Base resolution across references, key retrieval, and C14N --- README.md | 4 +- docs/xmldsig.md | 10 +++- src/c14n/mod.rs | 86 ++++++++++++++++++++++++++- src/c14n/serialize.rs | 61 ++++++++++++++++---- src/c14n/xml_base.rs | 118 ++++++++++++++++++++++++++++++++++++++ src/hard_limits.rs | 15 +++++ src/lib.rs | 1 - src/policy.rs | 89 +++++++++++++++++++++++++++- src/xmldsig/keys.rs | 25 ++++++++ src/xmldsig/parse.rs | 45 +++++++++++++-- src/xmldsig/transforms.rs | 99 +++++++++++++++++++++++++------- src/xmldsig/types.rs | 18 ++++++ src/xmldsig/uri.rs | 81 ++++++++++++++++++++++---- src/xmldsig/verify.rs | 64 ++++++++++++++++++++- src/xmlenc/encrypt.rs | 20 +++++++ 15 files changed, 680 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 190c0ad3..d72f00dc 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,8 @@ Currently implemented (core paths): - 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 +- Opt-in X.509 certificate-chain validation with explicit trust anchors, validity checks, CA constraints, and authenticated CRLs +- Caller-supplied external references and X.509 `RetrievalMethod` resolution with bounded RFC 3986 `xml:base` processing and no 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; document, node, and aggregate recipient diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 03169974..f3651157 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -88,13 +88,19 @@ payloads never implicitly allows external key material. `RetrievalMethod` curren 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. +3986 before lookup, so resource-map keys must use that resolved URI. The compiled resource policy +bounds both inherited `xml:base` components and cumulative URI-resolution bytes across external +References, `RetrievalMethod`, and C14N 1.1 fixup; implementation ceilings are 64 components and +1 MiB per operation. Other retrieval transform chains fail closed instead of being ignored. `VerifyContext::allowed_transforms` applies to Reference transforms and implicit C14N, the declared SignedInfo canonicalization method, and supported RetrievalMethod transforms. Allowing XPath for signed payload processing therefore also explicitly permits the bounded Merlin X509Data retrieval selector; omitting XPath rejects that key-retrieval path. +CRL checking is meaningful only inside authenticated X.509 path validation. A policy that enables +CRLs without enabling certificate-chain validation is rejected during context construction rather +than silently accepting a control the resolver cannot enforce. + Internal DTD declarations are disabled by default and require `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 diff --git a/src/c14n/mod.rs b/src/c14n/mod.rs index 871e014b..f058aa0e 100644 --- a/src/c14n/mod.rs +++ b/src/c14n/mod.rs @@ -158,6 +158,22 @@ pub enum C14nError { /// Algorithm not yet implemented. #[error("unsupported algorithm: {0}")] UnsupportedAlgorithm(String), + /// The inherited `xml:base` chain exceeds the configured component limit. + #[error("XML Base resolution exceeds maximum of {max} inherited components: got {actual}")] + XmlBaseComponentsTooLarge { + /// Configured maximum inherited components. + max: usize, + /// Number of inherited components encountered. + actual: usize, + }, + /// Cumulative `xml:base` resolution work exceeds the configured byte limit. + #[error("XML Base resolution exceeds maximum of {max_bytes} bytes: got at least {actual}")] + XmlBaseResolutionTooLarge { + /// Configured maximum cumulative bytes. + max_bytes: usize, + /// Minimum cumulative byte count that exceeded the maximum. + actual: usize, + }, /// I/O error. #[error("I/O error: {0}")] Io(#[from] std::io::Error), @@ -287,6 +303,7 @@ pub(crate) fn canonicalize_with_visibility_and_position( algo, tracked_element, None, + None, output, ) } @@ -306,6 +323,28 @@ pub(crate) fn canonicalize_with_visibility_and_position_bounded( algo, tracked_element, Some(max_output_bytes), + None, + output, + ) +} + +#[cfg(any(feature = "xmldsig", test))] +pub(crate) fn canonicalize_with_visibility_and_position_bounded_with_xml_base_budget( + doc: &Document, + visibility: Option<&dyn NodeVisibility>, + algo: &C14nAlgorithm, + tracked_element: Option, + max_output_bytes: usize, + xml_base_resolution: &xml_base::XmlBaseResolutionBudget, + output: &mut Vec, +) -> Result, C14nError> { + canonicalize_with_visibility_and_position_impl( + doc, + visibility, + algo, + tracked_element, + Some(max_output_bytes), + Some(xml_base_resolution), output, ) } @@ -316,8 +355,11 @@ fn canonicalize_with_visibility_and_position_impl( algo: &C14nAlgorithm, tracked_element: Option, max_output_bytes: Option, + xml_base_resolution: Option<&xml_base::XmlBaseResolutionBudget>, output: &mut Vec, ) -> Result, C14nError> { + let default_xml_base_resolution = xml_base::XmlBaseResolutionBudget::default(); + let xml_base_resolution = xml_base_resolution.unwrap_or(&default_xml_base_resolution); // inherit_xml_attrs: Inclusive C14N inherits xml:* attrs from ancestors // per §2.4. Exclusive C14N explicitly omits this per Exc-C14N §3. // fixup_xml_base: C14N 1.1 resolves relative xml:base URIs via RFC 3986. @@ -336,6 +378,7 @@ fn canonicalize_with_visibility_and_position_impl( config, tracked_element, max_output_bytes, + xml_base_resolution, output, ) } @@ -353,6 +396,7 @@ fn canonicalize_with_visibility_and_position_impl( config, tracked_element, max_output_bytes, + xml_base_resolution, output, ) } @@ -370,6 +414,7 @@ fn canonicalize_with_visibility_and_position_impl( config, tracked_element, max_output_bytes, + xml_base_resolution, output, ) } @@ -385,6 +430,7 @@ fn serialize_canonical_visible_with_position_dispatch( config: C14nConfig, tracked_element: Option, max_output_bytes: Option, + xml_base_resolution: &xml_base::XmlBaseResolutionBudget, output: &mut Vec, ) -> Result, C14nError> { match max_output_bytes { @@ -394,7 +440,7 @@ fn serialize_canonical_visible_with_position_dispatch( with_comments, renderer, config, - CanonicalOutputOptions::bounded(tracked_element, max_output_bytes), + CanonicalOutputOptions::bounded(tracked_element, max_output_bytes, xml_base_resolution), output, ), None => serialize_canonical_visible_with_position( @@ -603,4 +649,42 @@ mod tests { output.len() ); } + + #[test] + fn c14n_1_1_bounds_inherited_xml_base_components() { + // C14N 1.1 subset fixup walks ancestors outside the selected node set. + // Bounding that walk prevents deeply nested xml:base chains from + // multiplying URI-resolution work during canonicalization. + let mut xml = String::new(); + for _ in 0..=crate::hard_limits::XML_BASE_COMPONENT_CEILING { + xml.push_str(r#""#); + } + xml.push_str(""); + for _ in 0..=crate::hard_limits::XML_BASE_COMPONENT_CEILING { + xml.push_str(""); + } + let document = Document::parse(&xml).expect("fixed XML must parse"); + let leaf = document + .descendants() + .find(|node| node.has_tag_name("leaf")) + .expect("leaf"); + let visible = |node: Node<'_, '_>| node == leaf; + let algorithm = C14nAlgorithm::new(C14nMode::Inclusive1_1, false); + let mut output = Vec::new(); + + let error = canonicalize_with_visibility( + &document, + Some(&ClosureVisibility { + predicate: &visible, + }), + &algorithm, + &mut output, + ) + .expect_err("C14N must reject an overlong inherited xml:base chain"); + + assert!( + error.to_string().contains("XML Base"), + "unexpected C14N error: {error}" + ); + } } diff --git a/src/c14n/serialize.rs b/src/c14n/serialize.rs index 497fbe42..63519cec 100644 --- a/src/c14n/serialize.rs +++ b/src/c14n/serialize.rs @@ -13,7 +13,10 @@ use roxmltree::{Document, Node, NodeId, NodeType}; use super::ClosureVisibility; use super::escape::{escape_attr, escape_cr, escape_text}; use super::prefix::{attribute_prefix, element_prefix}; -use super::xml_base::{compute_effective_xml_base, preserves_xml_base_context, resolve_uri}; +use super::xml_base::{ + XmlBaseResolutionBudget, XmlBaseResolutionError, compute_effective_xml_base_with_budget, + preserves_xml_base_context, resolve_uri_with_budget, +}; use super::{C14nError, NodeVisibility}; #[derive(Debug)] @@ -50,6 +53,7 @@ struct CanonicalOutput<'a> { limit_exceeded: bool, tracked_element: Option, tracked_position: Option, + xml_base_resolution: &'a XmlBaseResolutionBudget, } impl Write for CanonicalOutput<'_> { @@ -98,23 +102,33 @@ pub(crate) struct C14nConfig { } #[derive(Clone, Copy)] -pub(crate) struct CanonicalOutputOptions { +pub(crate) struct CanonicalOutputOptions<'a> { tracked_element: Option, max_output_bytes: Option, + xml_base_resolution: &'a XmlBaseResolutionBudget, } -impl CanonicalOutputOptions { - pub(crate) fn unbounded(tracked_element: Option) -> Self { +impl<'a> CanonicalOutputOptions<'a> { + pub(crate) fn unbounded( + tracked_element: Option, + xml_base_resolution: &'a XmlBaseResolutionBudget, + ) -> Self { Self { tracked_element, max_output_bytes: None, + xml_base_resolution, } } - pub(crate) fn bounded(tracked_element: Option, max_output_bytes: usize) -> Self { + pub(crate) fn bounded( + tracked_element: Option, + max_output_bytes: usize, + xml_base_resolution: &'a XmlBaseResolutionBudget, + ) -> Self { Self { tracked_element, max_output_bytes: Some(max_output_bytes), + xml_base_resolution, } } } @@ -184,13 +198,14 @@ pub(crate) fn serialize_canonical_visible_with_position( tracked_element: Option, output: &mut Vec, ) -> Result, C14nError> { + let xml_base_resolution = XmlBaseResolutionBudget::default(); serialize_canonical_visible_with_position_bounded( doc, visibility, with_comments, ns_renderer, config, - CanonicalOutputOptions::unbounded(tracked_element), + CanonicalOutputOptions::unbounded(tracked_element, &xml_base_resolution), output, ) } @@ -201,7 +216,7 @@ pub(crate) fn serialize_canonical_visible_with_position_bounded( with_comments: bool, ns_renderer: &dyn NsRenderer, config: C14nConfig, - options: CanonicalOutputOptions, + options: CanonicalOutputOptions<'_>, output: &mut Vec, ) -> Result, C14nError> { let root = doc.root(); @@ -220,6 +235,7 @@ pub(crate) fn serialize_canonical_visible_with_position_bounded( limit_exceeded: false, tracked_element: options.tracked_element, tracked_position: None, + xml_base_resolution: options.xml_base_resolution, }; let result = serialize_children( root, @@ -459,8 +475,15 @@ fn serialize_element( false }; let effective_parent_base = if config.fixup_xml_base && parent_not_in_set { - node.parent() - .and_then(|p| compute_effective_xml_base(p, visibility)) + match node.parent() { + Some(parent) => compute_effective_xml_base_with_budget( + parent, + visibility, + output.xml_base_resolution, + ) + .map_err(map_xml_base_error)?, + None => None, + } } else { None }; @@ -483,7 +506,10 @@ fn serialize_element( if raw.is_empty() { Cow::Borrowed(raw) } else { - Cow::Owned(resolve_uri(base, raw)) + Cow::Owned( + resolve_uri_with_budget(base, raw, output.xml_base_resolution) + .map_err(map_xml_base_error)?, + ) } } else { Cow::Borrowed(attr.value()) @@ -545,6 +571,21 @@ fn serialize_element( Ok(()) } +fn map_xml_base_error(error: XmlBaseResolutionError) -> C14nError { + match error { + XmlBaseResolutionError::Components { maximum, actual } => { + C14nError::XmlBaseComponentsTooLarge { + max: maximum, + actual, + } + } + XmlBaseResolutionError::Bytes { maximum, actual } => C14nError::XmlBaseResolutionTooLarge { + max_bytes: maximum, + actual, + }, + } +} + /// Emit the separator preceding a document-level comment or PI. fn write_doc_level_prefix( is_doc_root: bool, diff --git a/src/c14n/xml_base.rs b/src/c14n/xml_base.rs index c0c2206f..aacc926f 100644 --- a/src/c14n/xml_base.rs +++ b/src/c14n/xml_base.rs @@ -9,6 +9,8 @@ //! This module provides a minimal RFC 3986 relative URI resolver — just //! enough for `xml:base` fixup. It is NOT a general-purpose URI library. +use std::cell::Cell; + use roxmltree::Node; use super::NodeVisibility; @@ -16,6 +18,65 @@ use super::NodeVisibility; /// The XML namespace URI. const XML_NS: &str = "http://www.w3.org/XML/1998/namespace"; +/// Deterministic limits shared by XML Base consumers in one operation. +pub(crate) struct XmlBaseResolutionBudget { + remaining_bytes: Cell, + max_bytes: usize, + max_components: usize, +} + +impl Default for XmlBaseResolutionBudget { + fn default() -> Self { + Self::with_limits( + crate::hard_limits::XML_BASE_COMPONENT_CEILING, + crate::hard_limits::XML_BASE_RESOLUTION_BYTE_CEILING, + ) + } +} + +impl XmlBaseResolutionBudget { + pub(crate) fn with_limits(max_components: usize, max_bytes: usize) -> Self { + Self { + remaining_bytes: Cell::new(max_bytes), + max_bytes, + max_components, + } + } + + fn check_components(&self, actual: usize) -> Result<(), XmlBaseResolutionError> { + if actual > self.max_components { + return Err(XmlBaseResolutionError::Components { + maximum: self.max_components, + actual, + }); + } + Ok(()) + } + + fn charge_bytes(&self, bytes: usize) -> Result<(), XmlBaseResolutionError> { + let remaining = self.remaining_bytes.get(); + let Some(next) = remaining.checked_sub(bytes) else { + self.remaining_bytes.set(0); + return Err(XmlBaseResolutionError::Bytes { + maximum: self.max_bytes, + actual: self + .max_bytes + .saturating_add(bytes.saturating_sub(remaining)), + }); + }; + self.remaining_bytes.set(next); + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub(crate) enum XmlBaseResolutionError { + #[error("XML Base resolution exceeds maximum of {maximum} inherited components: got {actual}")] + Components { maximum: usize, actual: usize }, + #[error("XML Base resolution exceeds cumulative maximum of {maximum} bytes: got {actual}")] + Bytes { maximum: usize, actual: usize }, +} + /// Compute the effective `xml:base` for an element by resolving the ancestor /// chain per [RFC 3986 §5](https://www.rfc-editor.org/rfc/rfc3986#section-5). /// @@ -34,6 +95,7 @@ const XML_NS: &str = "http://www.w3.org/XML/1998/namespace"; /// /// Returns `None` if the considered ancestor chain has no non-empty /// `xml:base` attribute. +#[cfg(test)] pub(crate) fn compute_effective_xml_base( start: Node<'_, '_>, visibility: Option<&dyn NodeVisibility>, @@ -71,6 +133,62 @@ pub(crate) fn compute_effective_xml_base( Some(effective) } +/// Budgeted form used for attacker-controlled XMLDSig URI resolution. +pub(crate) fn compute_effective_xml_base_with_budget( + start: Node<'_, '_>, + visibility: Option<&dyn NodeVisibility>, + budget: &XmlBaseResolutionBudget, +) -> Result, XmlBaseResolutionError> { + let mut bases: Vec<&str> = Vec::new(); + let mut current = Some(start); + while let Some(node) = current { + if node.is_element() { + let base = xml_base_value(node); + if let Some(set) = visibility + && preserves_xml_base_context(node, set) + { + break; + } + if let Some(base) = base { + let component_count = bases.len().saturating_add(1); + budget.check_components(component_count)?; + budget.charge_bytes(base.len())?; + bases.push(base); + } + } + current = node.parent(); + } + + let Some(first) = bases.pop() else { + return Ok(None); + }; + budget.charge_bytes(first.len())?; + let mut effective = first.to_owned(); + for relative in bases.into_iter().rev() { + effective = resolve_uri_with_budget(&effective, relative, budget)?; + } + Ok(Some(effective)) +} + +pub(crate) fn resolve_uri_with_budget( + base: &str, + reference: &str, + budget: &XmlBaseResolutionBudget, +) -> Result { + let input_bytes = base + .len() + .checked_add(reference.len()) + .and_then(|bytes| bytes.checked_add(1)) + .ok_or(XmlBaseResolutionError::Bytes { + maximum: budget.max_bytes, + actual: usize::MAX, + })?; + budget.charge_bytes(input_bytes)?; + let resolved = resolve_uri(base, reference); + budget.charge_bytes(resolved.len())?; + Ok(resolved) +} + /// Whether a selected element establishes its source `xml:base` context in the /// canonical output and therefore forms a boundary for descendant fixup. pub(super) fn preserves_xml_base_context( diff --git a/src/hard_limits.rs b/src/hard_limits.rs index 2d1879ce..3e4fd5d1 100644 --- a/src/hard_limits.rs +++ b/src/hard_limits.rs @@ -4,17 +4,32 @@ //! permits larger inputs. Deployment policy may only select stricter values. /// Maximum XML nodes allocated while parsing one verification or transform document. +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] pub(crate) const XML_DOCUMENT_NODE_CEILING: u32 = 100_000; +/// Maximum inherited `xml:base` attributes considered for one URI resolution. +pub(crate) const XML_BASE_COMPONENT_CEILING: usize = 64; + +/// Maximum cumulative bytes inspected or allocated while resolving XML Base. +pub(crate) const XML_BASE_RESOLUTION_BYTE_CEILING: usize = 1024 * 1024; + /// Maximum canonicalized SignedInfo plus retained diagnostics for one signature. +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] pub(crate) const CANONICALIZED_SIGNATURE_DATA_BYTE_CEILING: usize = 32 * 1024 * 1024; +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] pub(crate) const EXTERNAL_RESOURCE_BYTE_CEILING: usize = 8 * 1024 * 1024; +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] pub(crate) const EXTERNAL_RESOURCE_TOTAL_BYTE_CEILING: usize = 32 * 1024 * 1024; +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] pub(crate) const ENCRYPTION_CIPHER_VALUE_BASE64_BYTE_CEILING: usize = 16 * 1024 * 1024; +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] pub(crate) const ENCRYPTION_PLAINTEXT_BYTE_CEILING: usize = (ENCRYPTION_CIPHER_VALUE_BASE64_BYTE_CEILING / 4 * 3) - 32; +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] pub(crate) const ENCRYPTION_DOCUMENT_BYTE_CEILING: usize = ENCRYPTION_CIPHER_VALUE_BASE64_BYTE_CEILING; +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] pub(crate) const ENCRYPTION_RECIPIENT_CEILING: usize = 64; +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] pub(crate) const ENCRYPTION_METADATA_BYTE_CEILING: usize = 4 * 1024; diff --git a/src/lib.rs b/src/lib.rs index 324c35b6..92521237 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,7 +32,6 @@ pub mod c14n; pub mod error; -#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] mod hard_limits; #[cfg(any(feature = "xmldsig", feature = "xmlenc"))] pub mod policy; diff --git a/src/policy.rs b/src/policy.rs index f2724a9b..f5d3a202 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -61,6 +61,10 @@ pub struct ResourcePolicy { pub max_references: usize, /// Maximum transforms in one reference. pub max_transforms_per_reference: usize, + /// Maximum inherited `xml:base` components in one URI resolution. + pub max_xml_base_components: usize, + /// Maximum cumulative bytes processed while resolving `xml:base` URIs. + pub max_xml_base_resolution_bytes: usize, /// Maximum canonical bytes retained across one signature operation. pub max_canonicalized_bytes: usize, /// Maximum decoded external resource bytes. @@ -83,6 +87,8 @@ impl Default for ResourcePolicy { max_xml_nodes: crate::hard_limits::XML_DOCUMENT_NODE_CEILING as usize, max_references: 64, max_transforms_per_reference: 64, + max_xml_base_components: crate::hard_limits::XML_BASE_COMPONENT_CEILING, + max_xml_base_resolution_bytes: crate::hard_limits::XML_BASE_RESOLUTION_BYTE_CEILING, 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: @@ -114,6 +120,16 @@ impl ResourcePolicy { self.max_transforms_per_reference, 64, )?; + Self::within( + "XML Base components", + self.max_xml_base_components, + crate::hard_limits::XML_BASE_COMPONENT_CEILING, + )?; + Self::within( + "XML Base resolution bytes", + self.max_xml_base_resolution_bytes, + crate::hard_limits::XML_BASE_RESOLUTION_BYTE_CEILING, + )?; Self::within( "encryption document", self.max_encryption_document_bytes, @@ -151,7 +167,7 @@ impl ResourcePolicy { selected: usize, ceiling: usize, ) -> Result<(), PolicyViolation> { - if selected == 0 || selected > ceiling { + if selected > ceiling { return Err(PolicyViolation::ResourceLimit { resource, maximum: ceiling, @@ -160,6 +176,21 @@ impl ResourcePolicy { } Ok(()) } + + fn nonzero_within( + resource: &'static str, + selected: usize, + ceiling: usize, + ) -> Result<(), PolicyViolation> { + if selected == 0 { + return Err(PolicyViolation::ResourceLimit { + resource, + maximum: ceiling, + actual: selected, + }); + } + Self::within(resource, selected, ceiling) + } } /// XML parsing decisions shared by all operation policies. @@ -182,6 +213,7 @@ pub struct KeyTrustPolicy { /// Permit legacy RSA-SHA1 verification after key resolution. pub allow_legacy_rsa_sha1: bool, /// Authenticate and enforce embedded CRLs during path validation. + /// Requires [`Self::verify_x509_chains`]. pub check_crls: bool, /// Verification time override; `None` selects the system clock. pub verification_time: Option, @@ -204,8 +236,13 @@ impl Default for KeyTrustPolicy { #[cfg(feature = "xmldsig")] impl KeyTrustPolicy { pub(crate) fn validate(&self) -> Result<(), PolicyViolation> { - ResourcePolicy::within("X.509 chain depth", self.max_x509_chain_depth, 9)?; - ResourcePolicy::within("X.509 candidate paths", self.max_x509_candidate_paths, 64) + if self.check_crls && !self.verify_x509_chains { + return Err(PolicyViolation::KeyTrust { + reason: "CRL checking requires X.509 chain validation", + }); + } + ResourcePolicy::nonzero_within("X.509 chain depth", self.max_x509_chain_depth, 9)?; + ResourcePolicy::nonzero_within("X.509 candidate paths", self.max_x509_candidate_paths, 64) } } @@ -339,6 +376,12 @@ mod tests { let mut aggregate = ResourcePolicy::default(); aggregate.max_external_resource_total_bytes += 1; policies.push(aggregate); + let mut xml_base_components = ResourcePolicy::default(); + xml_base_components.max_xml_base_components += 1; + policies.push(xml_base_components); + let mut xml_base_bytes = ResourcePolicy::default(); + xml_base_bytes.max_xml_base_resolution_bytes += 1; + policies.push(xml_base_bytes); let mut plaintext = ResourcePolicy::default(); plaintext.max_encryption_plaintext_bytes += 1; policies.push(plaintext); @@ -357,6 +400,46 @@ mod tests { } } + #[test] + fn resource_policy_accepts_zero_as_a_deny_all_ceiling() { + // Zero is a valid policy decision for resources that an operation can + // avoid consuming; runtime checks must reject only actual non-zero use. + let policy = ResourcePolicy { + max_xml_nodes: 0, + max_references: 0, + max_transforms_per_reference: 0, + max_xml_base_components: 0, + max_xml_base_resolution_bytes: 0, + max_canonicalized_bytes: 0, + max_external_resource_bytes: 0, + max_external_resource_total_bytes: 0, + max_encryption_plaintext_bytes: 0, + max_encryption_document_bytes: 0, + max_encryption_recipients: 0, + max_encryption_metadata_bytes: 0, + }; + + assert_eq!(policy.validate(), Ok(())); + } + + #[cfg(feature = "xmldsig")] + #[test] + fn crl_checking_requires_x509_chain_validation() { + // CRLs authenticate through the validated issuer path. Accepting this + // combination would advertise a security control the resolver skips. + let policy = KeyTrustPolicy { + check_crls: true, + ..KeyTrustPolicy::default() + }; + + assert!(matches!( + policy.validate(), + Err(PolicyViolation::KeyTrust { + reason: "CRL checking requires X.509 chain validation" + }) + )); + } + #[cfg(feature = "xmldsig")] #[test] fn rsa_sha1_requires_legacy_verification_policy() { diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index ecac90f8..f16f380b 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -1073,6 +1073,31 @@ mod tests { } } + #[test] + fn resolver_rejects_crl_checking_without_chain_validation() { + // CRL authentication is part of path validation. A resolver must not + // accept a configuration that would silently skip the requested check. + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + lookup_certs: vec![certificate_der(RSA_4096_CERTIFICATE)], + trust: crate::policy::KeyTrustPolicy { + check_crls: true, + ..crate::policy::KeyTrustPolicy::default() + }, + ..KeyResolverConfig::default() + }); + let error = super::super::VerifyContext::new() + .key_resolver(&resolver) + .verify(&x509_signature_with_leaf_subject()) + .expect_err("CRL-only trust policy must fail before certificate use"); + + assert!(matches!( + error, + DsigError::Policy(crate::policy::PolicyViolation::KeyTrust { + reason: "CRL checking requires X.509 chain validation" + }) + )); + } + #[test] fn hmac_key_rejects_empty_secret_and_wrong_algorithm() { // HMAC secrets are caller-owned and cannot be reused as asymmetric keys. diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index 81e5cbd8..5f01e7dd 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -35,7 +35,9 @@ use super::whitespace::{ }; use super::x509::certificate_signature_matches; use crate::c14n::C14nAlgorithm; -use crate::c14n::xml_base::{compute_effective_xml_base, resolve_uri}; +use crate::c14n::xml_base::{ + XmlBaseResolutionBudget, compute_effective_xml_base_with_budget, resolve_uri_with_budget, +}; /// XMLDSig namespace URI. pub(crate) const XMLDSIG_NS: &str = "http://www.w3.org/2000/09/xmldsig#"; @@ -619,6 +621,15 @@ pub fn parse_key_info(key_info_node: Node) -> Result { pub(crate) fn parse_key_info_with_provider( key_info_node: Node, provider: &dyn crate::provider::CryptoProvider, +) -> Result { + let xml_base_budget = XmlBaseResolutionBudget::default(); + parse_key_info_with_provider_and_xml_base_budget(key_info_node, provider, &xml_base_budget) +} + +pub(crate) fn parse_key_info_with_provider_and_xml_base_budget( + key_info_node: Node, + provider: &dyn crate::provider::CryptoProvider, + xml_base_budget: &XmlBaseResolutionBudget, ) -> Result { verify_ds_element(key_info_node, "KeyInfo")?; ensure_no_non_whitespace_text(key_info_node, "KeyInfo")?; @@ -665,9 +676,13 @@ pub(crate) fn parse_key_info_with_provider( } 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()) + match compute_effective_xml_base_with_budget(child, None, xml_base_budget) + .map_err(|error| ParseError::InvalidStructure(error.to_string()))? + { + Some(base) => resolve_uri_with_budget(&base, lexical_uri, xml_base_budget) + .map_err(|error| ParseError::InvalidStructure(error.to_string()))?, + None => lexical_uri.to_owned(), + } }; let resource_type = child.attribute("Type"); if resource_type.is_some_and(|value| value.len() > MAX_KEY_NAME_TEXT_LEN) { @@ -3588,6 +3603,28 @@ BA== )); } + #[test] + fn parse_key_info_bounds_retrieval_method_xml_base_chain() { + // RetrievalMethod resolves its resource identity during parsing, so it + // must use the same bounded XML Base algorithm as Reference lookup. + let mut xml = + format!(r#""#); + for _ in 0..65 { + xml = format!(r#"{xml}"#); + } + let document = Document::parse(&xml).unwrap(); + let key_info = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) + .unwrap(); + + assert!(matches!( + parse_key_info(key_info), + Err(ParseError::InvalidStructure(reason)) + if reason.contains("XML Base resolution") + )); + } + #[test] fn parse_key_info_accepts_namespace_equivalent_retrieval_xpath_prefix() { let xml = r##" diff --git a/src/xmldsig/transforms.rs b/src/xmldsig/transforms.rs index 6d14b1f3..86234df0 100644 --- a/src/xmldsig/transforms.rs +++ b/src/xmldsig/transforms.rs @@ -34,6 +34,7 @@ use super::xpath::{ XPathDocumentRelation, XPathWorkBudget, apply_xpath_filter_with_semantics_and_budget, apply_xpath_filter2_with_semantics_and_budget, compile_xpath, is_xpath_whitespace, }; +use crate::c14n::xml_base::XmlBaseResolutionBudget; use crate::c14n::{self, C14nAlgorithm}; use crate::hard_limits::XML_DOCUMENT_NODE_CEILING; @@ -102,6 +103,7 @@ pub(crate) struct TransformExecutionBudget { c14n: C14nOutputBudget, node_filter: NodeFilterWorkBudget, node_set_materialization: NodeSetMaterializationBudget, + xml_base_resolution: XmlBaseResolutionBudget, xml_node_limit: u32, } @@ -113,6 +115,7 @@ impl Default for TransformExecutionBudget { c14n: C14nOutputBudget::default(), node_filter: NodeFilterWorkBudget::default(), node_set_materialization: NodeSetMaterializationBudget::default(), + xml_base_resolution: XmlBaseResolutionBudget::default(), xml_node_limit: XML_DOCUMENT_NODE_CEILING, } } @@ -218,6 +221,7 @@ impl TransformExecutionBudget { c14n: C14nOutputBudget::default(), node_filter: NodeFilterWorkBudget::default(), node_set_materialization: NodeSetMaterializationBudget::default(), + xml_base_resolution: XmlBaseResolutionBudget::default(), xml_node_limit: XML_DOCUMENT_NODE_CEILING, } } @@ -231,6 +235,7 @@ impl TransformExecutionBudget { remaining: Cell::new(limit), }, node_set_materialization: NodeSetMaterializationBudget::default(), + xml_base_resolution: XmlBaseResolutionBudget::default(), xml_node_limit: XML_DOCUMENT_NODE_CEILING, } } @@ -242,6 +247,7 @@ impl TransformExecutionBudget { c14n: C14nOutputBudget::default(), node_filter: NodeFilterWorkBudget::default(), node_set_materialization: NodeSetMaterializationBudget::with_limit(limit), + xml_base_resolution: XmlBaseResolutionBudget::default(), xml_node_limit: XML_DOCUMENT_NODE_CEILING, } } @@ -258,6 +264,10 @@ impl TransformExecutionBudget { pub(crate) fn from_resources(resources: &crate::policy::ResourcePolicy) -> Self { Self { c14n: C14nOutputBudget::with_limit(resources.max_canonicalized_bytes), + xml_base_resolution: XmlBaseResolutionBudget::with_limits( + resources.max_xml_base_components, + resources.max_xml_base_resolution_bytes, + ), xml_node_limit: u32::try_from(resources.max_xml_nodes) .unwrap_or(XML_DOCUMENT_NODE_CEILING), ..Self::default() @@ -279,6 +289,10 @@ impl TransformExecutionBudget { pub(crate) fn node_set_materialization(&self) -> &NodeSetMaterializationBudget { &self.node_set_materialization } + + pub(crate) fn xml_base_resolution(&self) -> &XmlBaseResolutionBudget { + &self.xml_base_resolution + } } impl TransformOptions { @@ -637,12 +651,13 @@ fn apply_transform_with_options_and_state<'s, 'd>( Transform::C14n(algo) => { let nodes = input.into_node_set()?; let mut output = Vec::new(); - c14n::canonicalize_with_visibility_and_position_bounded( + c14n::canonicalize_with_visibility_and_position_bounded_with_xml_base_budget( nodes.document(), Some(&nodes), algo, None, budget.c14n.remaining(), + budget.xml_base_resolution(), &mut output, ) .map_err(|error| map_c14n_limit_error(error, budget.c14n.max_bytes))?; @@ -830,7 +845,7 @@ fn execute_transform_chain<'s, 'e, 'd>( context: &TransformExecutionContext<'_>, ) -> Result, TransformError> { let Some((transform, remaining)) = transforms.split_first() else { - return finalize_transform_data(data, &context.budget.c14n); + return finalize_transform_data(data, context.budget); }; if transform_requires_node_set(transform) @@ -908,15 +923,17 @@ fn execute_transform_chain<'s, 'e, 'd>( .filter(|signature| nodes.contains(*signature)) .map(|signature| signature.id()); let mut output = Vec::new(); - let position = c14n::canonicalize_with_visibility_and_position_bounded( - nodes.document(), - Some(nodes), - algo, - tracked_element, - context.budget.c14n.remaining(), - &mut output, - ) - .map_err(|error| map_c14n_limit_error(error, context.budget.c14n.max_bytes))?; + let position = + c14n::canonicalize_with_visibility_and_position_bounded_with_xml_base_budget( + nodes.document(), + Some(nodes), + algo, + tracked_element, + context.budget.c14n.remaining(), + context.budget.xml_base_resolution(), + &mut output, + ) + .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, @@ -1012,7 +1029,7 @@ fn transform_requires_node_set(transform: &Transform) -> bool { fn finalize_transform_data( data: TransformData<'_>, - c14n_budget: &C14nOutputBudget, + budget: &TransformExecutionBudget, ) -> Result, TransformError> { // Final coercion: if the result is still a NodeSet, canonicalize with // default inclusive C14N 1.0 per XMLDSig spec §4.3.3.2. @@ -1023,26 +1040,34 @@ fn finalize_transform_data( let algo = C14nAlgorithm::from_uri(DEFAULT_IMPLICIT_C14N_URI) .expect("default C14N algorithm URI must be supported by C14nAlgorithm::from_uri"); let mut output = Vec::new(); - c14n::canonicalize_with_visibility_and_position_bounded( + c14n::canonicalize_with_visibility_and_position_bounded_with_xml_base_budget( nodes.document(), Some(&nodes), &algo, None, - c14n_budget.remaining(), + budget.c14n.remaining(), + budget.xml_base_resolution(), &mut output, ) - .map_err(|error| map_c14n_limit_error(error, c14n_budget.max_bytes))?; - c14n_budget.charge(output.len())?; + .map_err(|error| map_c14n_limit_error(error, budget.c14n.max_bytes))?; + budget.c14n.charge(output.len())?; Ok(output) } } } fn map_c14n_limit_error(error: c14n::C14nError, max_bytes: usize) -> TransformError { - if c14n::is_output_limit_error(&error) { - TransformError::C14nOutputTooLarge { max_bytes } - } else { - TransformError::C14n(error) + match error { + error if c14n::is_output_limit_error(&error) => { + TransformError::C14nOutputTooLarge { max_bytes } + } + c14n::C14nError::XmlBaseComponentsTooLarge { max, actual } => { + TransformError::XmlBaseComponentsTooLarge { max, actual } + } + c14n::C14nError::XmlBaseResolutionTooLarge { max_bytes, actual } => { + TransformError::XmlBaseResolutionTooLarge { max_bytes, actual } + } + error => TransformError::C14n(error), } } @@ -1607,6 +1632,40 @@ mod tests { )); } + #[test] + fn c14n_1_1_uses_the_compiled_xml_base_policy() { + // The operation's compiled resource policy must govern C14N 1.1 + // fixup as well as external Reference and RetrievalMethod resolution. + let document = Document::parse( + r#""#, + ) + .unwrap(); + let leaf = document + .descendants() + .find(|node| node.has_tag_name("leaf")) + .unwrap(); + let resources = crate::policy::ResourcePolicy { + max_xml_base_components: 1, + ..crate::policy::ResourcePolicy::default() + }; + let budget = TransformExecutionBudget::from_resources(&resources); + let algorithm = C14nAlgorithm::new(crate::c14n::C14nMode::Inclusive1_1, false); + + let error = execute_transforms_with_options_and_budget( + document.root_element(), + TransformData::NodeSet(NodeSet::subtree(leaf).unwrap()), + &[Transform::C14n(algorithm)], + TransformOptions::default(), + &budget, + ) + .expect_err("C14N must use the operation's XML Base component limit"); + + assert!(matches!( + error, + TransformError::XmlBaseComponentsTooLarge { max: 1, actual: 2 } + )); + } + // ── Base64 transform ──────────────────────────────────────────── #[test] diff --git a/src/xmldsig/types.rs b/src/xmldsig/types.rs index 12728420..f20a323f 100644 --- a/src/xmldsig/types.rs +++ b/src/xmldsig/types.rs @@ -714,6 +714,24 @@ pub enum TransformError { #[error("XML transform input exceeds the configured node limit")] XmlNodeLimit, + /// Effective XML Base resolution crossed too many inherited attributes. + #[error("XML Base resolution exceeds maximum of {max} inherited components: got {actual}")] + XmlBaseComponentsTooLarge { + /// Maximum inherited components permitted by the operation policy. + max: usize, + /// Number of inherited components encountered. + actual: usize, + }, + + /// Effective XML Base resolution exhausted its cumulative byte budget. + #[error("XML Base resolution exceeds cumulative maximum of {max_bytes} bytes: got {actual}")] + XmlBaseResolutionTooLarge { + /// Maximum cumulative bytes permitted by the operation policy. + max_bytes: usize, + /// Conservatively charged cumulative byte count. + actual: usize, + }, + /// The Signature node passed to the enveloped transform belongs to a /// different `Document` than the input `NodeSet`. #[error("enveloped-signature transform: invalid Signature node for this document")] diff --git a/src/xmldsig/uri.rs b/src/xmldsig/uri.rs index d5cb431a..3c6fbfc8 100644 --- a/src/xmldsig/uri.rs +++ b/src/xmldsig/uri.rs @@ -17,7 +17,10 @@ use std::collections::{HashMap, HashSet}; use roxmltree::{Document, Node, NodeId}; -use crate::c14n::xml_base::{compute_effective_xml_base, resolve_uri}; +use crate::c14n::xml_base::{ + XmlBaseResolutionBudget, XmlBaseResolutionError, compute_effective_xml_base_with_budget, + resolve_uri_with_budget, +}; use super::types::{NodeSet, NodeSetMaterializationBudget, TransformData, TransformError}; @@ -165,15 +168,20 @@ impl<'a> UriReferenceResolver<'a> { uri: &str, origin: Node<'_, '_>, budget: &NodeSetMaterializationBudget, + xml_base_budget: &XmlBaseResolutionBudget, ) -> 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()); + let resolved = match compute_effective_xml_base_with_budget(origin, None, xml_base_budget) + .map_err(map_xml_base_resolution_error)? + { + Some(base) => resolve_uri_with_budget(&base, uri, xml_base_budget) + .map_err(map_xml_base_resolution_error)?, + None => uri.to_owned(), + }; self.dereference_with_budget(&resolved, budget) } @@ -300,6 +308,23 @@ impl<'a> UriReferenceResolver<'a> { } } +fn map_xml_base_resolution_error(error: XmlBaseResolutionError) -> TransformError { + match error { + XmlBaseResolutionError::Components { maximum, actual } => { + TransformError::XmlBaseComponentsTooLarge { + max: maximum, + actual, + } + } + XmlBaseResolutionError::Bytes { maximum, actual } => { + TransformError::XmlBaseResolutionTooLarge { + max_bytes: maximum, + actual, + } + } + } +} + /// Parse `xpointer(id('value'))` or `xpointer(id("value"))` and return the ID value. /// Returns `None` if the fragment doesn't match this pattern. pub(crate) fn parse_xpointer_id_fragment(fragment: &str) -> Option<&str> { @@ -562,10 +587,16 @@ mod tests { b"payload".to_vec(), )]); let budget = NodeSetMaterializationBudget::default(); + let xml_base_budget = XmlBaseResolutionBudget::default(); let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources); let data = resolver - .dereference_from_with_budget(reference.attribute("URI").unwrap(), reference, &budget) + .dereference_from_with_budget( + reference.attribute("URI").unwrap(), + reference, + &budget, + &xml_base_budget, + ) .unwrap(); assert_eq!(data.into_binary().unwrap(), b"payload"); @@ -585,10 +616,16 @@ mod tests { .unwrap(); let resources = HashMap::from([("data.bin".to_owned(), b"payload".to_vec())]); let budget = NodeSetMaterializationBudget::default(); + let xml_base_budget = XmlBaseResolutionBudget::default(); let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources); let data = resolver - .dereference_from_with_budget(reference.attribute("URI").unwrap(), reference, &budget) + .dereference_from_with_budget( + reference.attribute("URI").unwrap(), + reference, + &budget, + &xml_base_budget, + ) .unwrap(); assert_eq!(data.into_binary().unwrap(), b"payload"); @@ -608,10 +645,16 @@ mod tests { .unwrap(); let resources = HashMap::from([("/data.bin".to_owned(), b"payload".to_vec())]); let budget = NodeSetMaterializationBudget::default(); + let xml_base_budget = XmlBaseResolutionBudget::default(); let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources); let data = resolver - .dereference_from_with_budget(reference.attribute("URI").unwrap(), reference, &budget) + .dereference_from_with_budget( + reference.attribute("URI").unwrap(), + reference, + &budget, + &xml_base_budget, + ) .unwrap(); assert_eq!(data.into_binary().unwrap(), b"payload"); @@ -631,10 +674,16 @@ mod tests { .unwrap(); let resources = HashMap::from([("//cdn.example/data.bin".to_owned(), b"payload".to_vec())]); let budget = NodeSetMaterializationBudget::default(); + let xml_base_budget = XmlBaseResolutionBudget::default(); let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources); let data = resolver - .dereference_from_with_budget(reference.attribute("URI").unwrap(), reference, &budget) + .dereference_from_with_budget( + reference.attribute("URI").unwrap(), + reference, + &budget, + &xml_base_budget, + ) .unwrap(); assert_eq!(data.into_binary().unwrap(), b"payload"); @@ -657,10 +706,16 @@ mod tests { b"payload".to_vec(), )]); let budget = NodeSetMaterializationBudget::default(); + let xml_base_budget = XmlBaseResolutionBudget::default(); let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources); let data = resolver - .dereference_from_with_budget(reference.attribute("URI").unwrap(), reference, &budget) + .dereference_from_with_budget( + reference.attribute("URI").unwrap(), + reference, + &budget, + &xml_base_budget, + ) .unwrap(); assert_eq!(data.into_binary().unwrap(), b"payload"); @@ -678,10 +733,16 @@ mod tests { .unwrap(); let resources = HashMap::from([("urn:payload".to_owned(), b"payload".to_vec())]); let budget = NodeSetMaterializationBudget::default(); + let xml_base_budget = XmlBaseResolutionBudget::default(); let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources); let data = resolver - .dereference_from_with_budget(reference.attribute("URI").unwrap(), reference, &budget) + .dereference_from_with_budget( + reference.attribute("URI").unwrap(), + reference, + &budget, + &xml_base_budget, + ) .unwrap(); assert_eq!(data.into_binary().unwrap(), b"payload"); diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index f322304e..b1c3d5b0 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -30,7 +30,7 @@ use super::parse::{ RetrievalMethodTransforms, SignatureAlgorithm, XMLDSIG_NS, }; use super::parse::{ - parse_key_info_with_provider, parse_reference_with_xpath_budget, + parse_key_info_with_provider_and_xml_base_budget, parse_reference_with_xpath_budget, parse_signed_info_with_xpath_budget, parse_x509_certificate, parse_x509_data_dispatch_with_budget_and_provider, reference_digest_method, }; @@ -639,6 +639,7 @@ fn process_reference_with_options( uri, node, execution.transform_budget.node_set_materialization(), + execution.transform_budget.xml_base_resolution(), ) }, ) @@ -959,6 +960,7 @@ fn verify_signature_with_context( entity_resolver: None, }, )?; + let execution_budget = TransformExecutionBudget::from_resources(&ctx.policy.resources); let mut signatures = doc.descendants().filter(|node| { node.is_element() && node.tag_name().name() == "Signature" @@ -988,7 +990,13 @@ fn verify_signature_with_context( let mut key_info = if should_parse_key_info { signature_children .key_info_node - .map(|node| parse_key_info_with_provider(node, ctx.provider)) + .map(|node| { + parse_key_info_with_provider_and_xml_base_budget( + node, + ctx.provider, + execution_budget.xml_base_resolution(), + ) + }) .transpose() .map_err(SignatureVerificationPipelineError::ParseKeyInfo)? } else { @@ -1081,7 +1089,6 @@ fn verify_signature_with_context( } else { RetrievalMaterialization::default() }; - let execution_budget = TransformExecutionBudget::from_resources(&ctx.policy.resources); let canonicalized_data_budget = CanonicalizedDataBudget::with_limit(ctx.policy.resources.max_canonicalized_bytes); let execution = ReferenceExecutionContext { @@ -2575,6 +2582,57 @@ mod tests { )); } + #[test] + fn verify_context_bounds_effective_xml_base_components() { + // External URI resolution must stop before repeatedly copying an + // attacker-controlled chain of effective XML Base values. + let mut xml = minimal_signature_xml("payload", ""); + for _ in 0..65 { + xml = format!(r#"{xml}"#); + } + let resources = HashMap::new(); + let error = VerifyContext::new() + .key(&AcceptingKey) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&xml) + .expect_err("XML Base component work must be bounded before lookup"); + + assert!( + error.to_string().contains("XML Base resolution"), + "unexpected error: {error:?}" + ); + } + + #[test] + fn verify_context_bounds_cumulative_xml_base_resolution_bytes() { + // The operation-wide byte budget charges intermediate URI copies, not + // merely the small final external resource returned by the caller map. + let mut xml = minimal_signature_xml("payload", ""); + for _ in 0..2 { + xml = format!(r#"{xml}"#); + } + let resources = HashMap::new(); + let mut policy = crate::policy::VerificationPolicy::default(); + policy.resources.max_xml_base_resolution_bytes = 32; + let error = VerifyContext::new() + .policy(policy) + .key(&AcceptingKey) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&xml) + .expect_err("cumulative XML Base copies must obey the operation budget"); + + assert!(matches!( + error, + SignatureVerificationPipelineError::Reference( + ReferenceProcessingError::UriDereference( + TransformError::XmlBaseResolutionTooLarge { max_bytes: 32, .. } + ) + ) + )); + } + #[test] fn verify_context_rejects_empty_uri_when_policy_disallows_empty() { let xml = minimal_signature_xml("", ""); diff --git a/src/xmlenc/encrypt.rs b/src/xmlenc/encrypt.rs index 03faacca..872bb864 100644 --- a/src/xmlenc/encrypt.rs +++ b/src/xmlenc/encrypt.rs @@ -1136,6 +1136,26 @@ mod tests { )); } + #[test] + fn zero_resource_ceilings_allow_operations_that_consume_none() { + // Zero is deny-all, not an invalid policy. Direct-key encryption has no + // recipients, and an empty binary payload consumes no plaintext bytes. + let policy = crate::policy::EncryptionPolicy { + resources: crate::policy::ResourcePolicy { + max_encryption_plaintext_bytes: 0, + max_encryption_recipients: 0, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::EncryptionPolicy::default() + }; + + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key([0_u8; 16]) + .policy(policy) + .encrypt_binary(&[]) + .expect("zero ceilings must allow resources the operation does not consume"); + } + #[test] fn element_plaintext_enforces_replacement_node_contract() { // Element ciphertext must be safe for the reciprocal document replacement: From 92bf93e66fcdf89730792d7bb0b1dd20232e13d4 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 9 Aug 2026 10:54:42 +0300 Subject: [PATCH 33/63] fix(xmldsig): enforce resolution budgets - Meter repeated external dereferences through one resolver budget - Preserve URI normalization and XML Base limits across all paths - Report invalid lower-bound policy limits accurately --- docs/xmldsig.md | 4 +- src/c14n/mod.rs | 43 ++++++++++++++-- src/c14n/serialize.rs | 26 +++++++++- src/c14n/xml_base.rs | 33 +------------ src/policy.rs | 33 +++++++++++-- src/xmldsig/keys.rs | 6 ++- src/xmldsig/parse.rs | 20 +++++++- src/xmldsig/types.rs | 18 +++++++ src/xmldsig/uri.rs | 112 ++++++++++++++++++++++++++++++++++++++++-- src/xmldsig/verify.rs | 85 +++++++++++++++++++++++--------- src/xmlenc/encrypt.rs | 13 ++++- 11 files changed, 324 insertions(+), 69 deletions(-) diff --git a/docs/xmldsig.md b/docs/xmldsig.md index f3651157..55be43a7 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -82,7 +82,9 @@ 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. External key retrieval has an independent policy boundary: callers must +complete map to 32 MiB. The same aggregate ceiling is charged again per successful dereference, +so repeatedly referencing one map entry cannot multiply transform work or retained diagnostics +without bound. 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, untransformed direct same-document `X509Data`, diff --git a/src/c14n/mod.rs b/src/c14n/mod.rs index f058aa0e..d5e04496 100644 --- a/src/c14n/mod.rs +++ b/src/c14n/mod.rs @@ -39,8 +39,8 @@ use ns_inclusive::InclusiveNsRenderer; #[cfg(any(feature = "xmldsig", test))] use serialize::CanonicalOutputLimitExceeded; use serialize::{ - C14nConfig, CanonicalOutputOptions, serialize_canonical_visible_with_position, - serialize_canonical_visible_with_position_bounded, + C14nConfig, CanonicalOutputOptions, serialize_canonical_visible_with_position_bounded, + serialize_canonical_visible_with_position_with_xml_base_budget, }; /// C14N algorithm mode (without the comments flag). @@ -443,13 +443,14 @@ fn serialize_canonical_visible_with_position_dispatch( CanonicalOutputOptions::bounded(tracked_element, max_output_bytes, xml_base_resolution), output, ), - None => serialize_canonical_visible_with_position( + None => serialize_canonical_visible_with_position_with_xml_base_budget( doc, visibility, with_comments, renderer, config, tracked_element, + xml_base_resolution, output, ), } @@ -687,4 +688,40 @@ mod tests { "unexpected C14N error: {error}" ); } + + #[test] + fn unbounded_output_preserves_the_callers_xml_base_budget() { + // Output capacity and XML Base work are independent limits. Omitting + // an output ceiling must not replace the caller's XML Base policy. + let document = Document::parse( + r#""#, + ) + .unwrap(); + let leaf = document + .descendants() + .find(|node| node.has_tag_name("leaf")) + .unwrap(); + let visible = |node: Node<'_, '_>| node == leaf; + let budget = xml_base::XmlBaseResolutionBudget::with_limits(1, usize::MAX); + let algorithm = C14nAlgorithm::new(C14nMode::Inclusive1_1, false); + let mut output = Vec::new(); + + let error = canonicalize_with_visibility_and_position_impl( + &document, + Some(&ClosureVisibility { + predicate: &visible, + }), + &algorithm, + None, + None, + Some(&budget), + &mut output, + ) + .expect_err("the caller's component ceiling must survive unbounded dispatch"); + + assert!(matches!( + error, + C14nError::XmlBaseComponentsTooLarge { max: 1, actual: 2 } + )); + } } diff --git a/src/c14n/serialize.rs b/src/c14n/serialize.rs index 63519cec..7623b47f 100644 --- a/src/c14n/serialize.rs +++ b/src/c14n/serialize.rs @@ -189,6 +189,7 @@ pub(crate) fn serialize_canonical( Ok(()) } +#[cfg(test)] pub(crate) fn serialize_canonical_visible_with_position( doc: &Document, visibility: Option<&dyn NodeVisibility>, @@ -199,13 +200,36 @@ pub(crate) fn serialize_canonical_visible_with_position( output: &mut Vec, ) -> Result, C14nError> { let xml_base_resolution = XmlBaseResolutionBudget::default(); + serialize_canonical_visible_with_position_with_xml_base_budget( + doc, + visibility, + with_comments, + ns_renderer, + config, + tracked_element, + &xml_base_resolution, + output, + ) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn serialize_canonical_visible_with_position_with_xml_base_budget( + doc: &Document, + visibility: Option<&dyn NodeVisibility>, + with_comments: bool, + ns_renderer: &dyn NsRenderer, + config: C14nConfig, + tracked_element: Option, + xml_base_resolution: &XmlBaseResolutionBudget, + output: &mut Vec, +) -> Result, C14nError> { serialize_canonical_visible_with_position_bounded( doc, visibility, with_comments, ns_renderer, config, - CanonicalOutputOptions::unbounded(tracked_element, &xml_base_resolution), + CanonicalOutputOptions::unbounded(tracked_element, xml_base_resolution), output, ) } diff --git a/src/c14n/xml_base.rs b/src/c14n/xml_base.rs index aacc926f..96da03fd 100644 --- a/src/c14n/xml_base.rs +++ b/src/c14n/xml_base.rs @@ -100,37 +100,8 @@ pub(crate) fn compute_effective_xml_base( start: Node<'_, '_>, visibility: Option<&dyn NodeVisibility>, ) -> Option { - let mut bases: Vec<&str> = Vec::new(); - let mut current = Some(start); - while let Some(n) = current { - if n.is_element() { - let base = xml_base_value(n); - if let Some(set) = visibility - && preserves_xml_base_context(n, set) - { - // A visible non-empty base establishes the context that - // descendants inherit in the canonical output. Reapplying - // ancestors above that boundary would duplicate the context. - break; - } - if let Some(base) = base { - bases.push(base); - } - } - current = n.parent(); - } - - if bases.is_empty() { - return None; - } - - // bases is closest-first, root-last. Reverse to resolve root→closest. - bases.reverse(); - let mut effective = bases[0].to_string(); - for &relative in &bases[1..] { - effective = resolve_uri(&effective, relative); - } - Some(effective) + compute_effective_xml_base_with_budget(start, visibility, &XmlBaseResolutionBudget::default()) + .expect("test XML Base fixtures stay within implementation ceilings") } /// Budgeted form used for attacker-controlled XMLDSig URI resolution. diff --git a/src/policy.rs b/src/policy.rs index f5d3a202..dc0eb8aa 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -38,6 +38,16 @@ pub enum PolicyViolation { /// Observed consumption. actual: usize, }, + /// A configured resource limit violates a structural policy requirement. + #[error("{resource} has invalid policy limit {actual}: {requirement}")] + InvalidResourceLimit { + /// Resource whose configured limit was rejected. + resource: &'static str, + /// Required property of the configured limit. + requirement: &'static str, + /// Rejected configured value. + actual: usize, + }, /// The selected key source or trust mode is disallowed. #[error("key/trust policy rejected the operation: {reason}")] KeyTrust { @@ -69,7 +79,7 @@ pub struct ResourcePolicy { pub max_canonicalized_bytes: usize, /// Maximum decoded external resource bytes. pub max_external_resource_bytes: usize, - /// Maximum aggregate external resource bytes. + /// Maximum bytes in the complete external map and cumulatively dereferenced. pub max_external_resource_total_bytes: usize, /// Maximum XMLEnc plaintext bytes. pub max_encryption_plaintext_bytes: usize, @@ -183,9 +193,9 @@ impl ResourcePolicy { ceiling: usize, ) -> Result<(), PolicyViolation> { if selected == 0 { - return Err(PolicyViolation::ResourceLimit { + return Err(PolicyViolation::InvalidResourceLimit { resource, - maximum: ceiling, + requirement: "limit must be nonzero", actual: selected, }); } @@ -422,6 +432,23 @@ mod tests { assert_eq!(policy.validate(), Ok(())); } + #[cfg(feature = "xmldsig")] + #[test] + fn mandatory_x509_limits_report_the_nonzero_requirement() { + // A lower-bound violation must not be reported as exceeding the upper + // ceiling: that diagnostic points callers toward the wrong correction. + let policy = KeyTrustPolicy { + max_x509_chain_depth: 0, + ..KeyTrustPolicy::default() + }; + + let error = policy.validate().expect_err("zero depth must be rejected"); + assert!( + error.to_string().contains("must be nonzero"), + "unexpected lower-bound diagnostic: {error}" + ); + } + #[cfg(feature = "xmldsig")] #[test] fn crl_checking_requires_x509_chain_validation() { diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index f16f380b..714da1a9 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -1068,7 +1068,11 @@ mod tests { assert!(matches!( error, - DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit { actual: 0, .. }) + DsigError::Policy(crate::policy::PolicyViolation::InvalidResourceLimit { + requirement: "limit must be nonzero", + actual: 0, + .. + }) )); } } diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index 5f01e7dd..4c53d0cc 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -681,7 +681,8 @@ pub(crate) fn parse_key_info_with_provider_and_xml_base_budget( { Some(base) => resolve_uri_with_budget(&base, lexical_uri, xml_base_budget) .map_err(|error| ParseError::InvalidStructure(error.to_string()))?, - None => lexical_uri.to_owned(), + None => resolve_uri_with_budget("", lexical_uri, xml_base_budget) + .map_err(|error| ParseError::InvalidStructure(error.to_string()))?, } }; let resource_type = child.attribute("Type"); @@ -3625,6 +3626,23 @@ BA== )); } + #[test] + fn parse_key_info_normalizes_external_retrieval_without_xml_base() { + // RetrievalMethod stores the resolved identity used for caller-map + // lookup, including RFC 3986 normalization when no base is declared. + let xml = format!( + r#""# + ); + let document = Document::parse(&xml).unwrap(); + let key_info = parse_key_info(document.root_element()).unwrap(); + + assert!(matches!( + key_info.sources.as_slice(), + [KeyInfoSource::RetrievalMethod { uri, .. }] + if uri == "https://example.test/key.der" + )); + } + #[test] fn parse_key_info_accepts_namespace_equivalent_retrieval_xpath_prefix() { let xml = r##" diff --git a/src/xmldsig/types.rs b/src/xmldsig/types.rs index f20a323f..b978b238 100644 --- a/src/xmldsig/types.rs +++ b/src/xmldsig/types.rs @@ -732,6 +732,24 @@ pub enum TransformError { actual: usize, }, + /// One external resource exceeds the operation's per-resource byte limit. + #[error("external resource bytes exceed maximum of {max_bytes}: got {actual}")] + ExternalResourceTooLarge { + /// Maximum bytes permitted for one external resource. + max_bytes: usize, + /// Bytes in the selected external resource. + actual: usize, + }, + + /// Repeated external dereferences exhausted the operation-wide byte budget. + #[error("aggregate external resource bytes exceed maximum of {max_bytes}: got {actual}")] + ExternalResourceTotalTooLarge { + /// Maximum cumulative bytes permitted across dereferences. + max_bytes: usize, + /// Cumulative dereferenced bytes that exceeded the maximum. + actual: usize, + }, + /// The Signature node passed to the enveloped transform belongs to a /// different `Document` than the input `NodeSet`. #[error("enveloped-signature transform: invalid Signature node for this document")] diff --git a/src/xmldsig/uri.rs b/src/xmldsig/uri.rs index 3c6fbfc8..c79aedad 100644 --- a/src/xmldsig/uri.rs +++ b/src/xmldsig/uri.rs @@ -12,6 +12,7 @@ //! External URI bytes are resolved only from an explicit caller-owned map; this //! module never performs network or filesystem I/O. +use std::cell::Cell; use std::collections::hash_map::Entry; use std::collections::{HashMap, HashSet}; @@ -32,6 +33,52 @@ use super::types::{NodeSet, NodeSetMaterializationBudget, TransformData, Transfo /// - `id` — general XML const DEFAULT_ID_ATTRS: &[&str] = &["ID", "Id", "id"]; +struct ExternalResourceBudget { + remaining_total_bytes: Cell, + max_resource_bytes: usize, + max_total_bytes: usize, +} + +impl Default for ExternalResourceBudget { + fn default() -> Self { + Self::with_limits( + crate::hard_limits::EXTERNAL_RESOURCE_BYTE_CEILING, + crate::hard_limits::EXTERNAL_RESOURCE_TOTAL_BYTE_CEILING, + ) + } +} + +impl ExternalResourceBudget { + fn with_limits(max_resource_bytes: usize, max_total_bytes: usize) -> Self { + Self { + remaining_total_bytes: Cell::new(max_total_bytes), + max_resource_bytes, + max_total_bytes, + } + } + + fn charge(&self, bytes: usize) -> Result<(), TransformError> { + if bytes > self.max_resource_bytes { + return Err(TransformError::ExternalResourceTooLarge { + max_bytes: self.max_resource_bytes, + actual: bytes, + }); + } + let remaining = self.remaining_total_bytes.get(); + let Some(next) = remaining.checked_sub(bytes) else { + self.remaining_total_bytes.set(0); + return Err(TransformError::ExternalResourceTotalTooLarge { + max_bytes: self.max_total_bytes, + actual: self + .max_total_bytes + .saturating_add(bytes.saturating_sub(remaining)), + }); + }; + self.remaining_total_bytes.set(next); + Ok(()) + } +} + /// Resolves same-document URI references against a parsed XML document. /// /// Builds a `HashMap<&str, Node>` index on construction for O(1) fragment @@ -58,6 +105,7 @@ pub struct UriReferenceResolver<'a> { /// ID → element node mapping for O(1) fragment lookups. id_map: HashMap<&'a str, Node<'a, 'a>>, external_resources: Option<&'a HashMap>>, + external_resource_budget: ExternalResourceBudget, } impl<'a> UriReferenceResolver<'a> { @@ -128,17 +176,41 @@ impl<'a> UriReferenceResolver<'a> { doc, id_map, external_resources: None, + external_resource_budget: ExternalResourceBudget::default(), } } /// Attach an explicit caller-owned external-resource map. /// - /// No network or filesystem access is performed by this resolver. + /// No network or filesystem access is performed by this resolver. Keys are + /// RFC 3986 resolved URI identities: paths have dot segments removed while + /// query and fragment suffixes are retained. pub fn with_external_resources(mut self, resources: &'a HashMap>) -> Self { self.external_resources = Some(resources); self } + pub(crate) fn with_external_resource_limits( + mut self, + max_resource_bytes: usize, + max_total_bytes: usize, + ) -> Self { + self.external_resource_budget = + ExternalResourceBudget::with_limits(max_resource_bytes, max_total_bytes); + self + } + + pub(crate) fn external_resource(&self, uri: &str) -> Result, TransformError> { + let Some(bytes) = self + .external_resources + .and_then(|resources| resources.get(uri)) + else { + return Ok(None); + }; + self.external_resource_budget.charge(bytes.len())?; + Ok(Some(bytes)) + } + /// Dereference a URI string to a [`TransformData`]. /// /// # URI forms @@ -180,7 +252,8 @@ impl<'a> UriReferenceResolver<'a> { { Some(base) => resolve_uri_with_budget(&base, uri, xml_base_budget) .map_err(map_xml_base_resolution_error)?, - None => uri.to_owned(), + None => resolve_uri_with_budget("", uri, xml_base_budget) + .map_err(map_xml_base_resolution_error)?, }; self.dereference_with_budget(&resolved, budget) } @@ -208,9 +281,8 @@ impl<'a> UriReferenceResolver<'a> { // xmlsec1 also passes fragments through without decoding. self.dereference_fragment(fragment, budget) } else { - self.external_resources - .and_then(|resources| resources.get(uri)) - .map(|bytes| TransformData::Binary(bytes.clone())) + self.external_resource(uri)? + .map(|bytes| TransformData::Binary(bytes.to_vec())) .ok_or_else(|| TransformError::UnsupportedUri(uri.to_string())) } } @@ -602,6 +674,36 @@ mod tests { assert_eq!(data.into_binary().unwrap(), b"payload"); } + #[test] + fn external_uri_without_xml_base_uses_normalized_resource_identity() { + // RFC 3986 normalization defines the caller map key even when the + // document does not provide an explicit XML Base ancestor. + 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 xml_base_budget = XmlBaseResolutionBudget::default(); + let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources); + + let data = resolver + .dereference_from_with_budget( + reference.attribute("URI").unwrap(), + reference, + &budget, + &xml_base_budget, + ) + .expect("the normalized resource key must resolve without xml:base"); + + 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 diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index b1c3d5b0..834a58da 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -303,7 +303,9 @@ impl<'a> VerifyContext<'a> { /// /// The map is the complete external I/O boundary: verification never /// performs network or filesystem access. External URIs must also be - /// enabled through [`UriTypeSet`]. + /// enabled through [`UriTypeSet`]. Map keys are RFC 3986 resolved URI + /// identities: use normalized paths with dot segments removed and retain + /// query or fragment suffixes. pub fn external_resources(mut self, resources: &'a HashMap>) -> Self { self.external_resources = Some(resources); self @@ -1073,15 +1075,18 @@ fn verify_signature_with_context( .into()); } } + let resolver = UriReferenceResolver::new(&doc).with_external_resource_limits( + ctx.policy.resources.max_external_resource_bytes, + ctx.policy.resources.max_external_resource_total_bytes, + ); let resolver = match ctx.external_resources { - Some(resources) => UriReferenceResolver::new(&doc).with_external_resources(resources), - None => UriReferenceResolver::new(&doc), + Some(resources) => resolver.with_external_resources(resources), + None => resolver, }; let retrieval_materialization = if let Some(info) = key_info.as_mut() { materialize_retrieval_methods( info, &resolver, - ctx.external_resources, ctx.policy.retrieval_uri_types, ctx.allowed_transform_uris(), ctx.provider, @@ -1231,7 +1236,6 @@ struct RetrievalMaterialization { fn materialize_retrieval_methods( key_info: &mut KeyInfo, resolver: &UriReferenceResolver<'_>, - external_resources: Option<&HashMap>>, allowed_uri_types: UriTypeSet, allowed_transforms: Option<&HashSet>, provider: &dyn crate::provider::CryptoProvider, @@ -1279,8 +1283,12 @@ fn materialize_retrieval_methods( if !allowed_uri_types.allows(&uri) { return Err(SignatureVerificationPipelineError::DisallowedUri { uri }); } - let Some(certificate) = external_resources.and_then(|resources| resources.get(&uri)) - else { + let certificate = resolver.external_resource(&uri).map_err(|error| { + SignatureVerificationPipelineError::Reference(ReferenceProcessingError::Transform( + error, + )) + })?; + let Some(certificate) = certificate else { outcome.deferred_error.get_or_insert_with(|| { SignatureVerificationPipelineError::Reference( ReferenceProcessingError::Transform(super::TransformError::UnsupportedUri( @@ -1317,7 +1325,7 @@ fn materialize_retrieval_methods( }; materialized.push(super::parse::KeyInfoSource::X509Data( super::parse::X509DataInfo { - certificates: vec![certificate.clone()], + certificates: vec![certificate.to_vec()], parsed_certificates: vec![parsed], certificate_chain: vec![0], ..super::parse::X509DataInfo::default() @@ -2633,6 +2641,45 @@ mod tests { )); } + #[test] + fn verify_context_meters_repeated_external_dereferences() { + // One caller-owned entry can be referenced repeatedly. The aggregate + // ceiling bounds bytes cloned and processed, not just unique map data. + let payload = b"payload"; + let digest = base64::engine::general_purpose::STANDARD.encode( + crate::xmldsig::compute_digest(DigestAlgorithm::Sha1, payload), + ); + let reference = format!( + r#"{digest}"# + ); + let xml = format!( + r#"{reference}{reference}AQ=="# + ); + let resources = HashMap::from([("urn:payload".to_owned(), payload.to_vec())]); + let policy = crate::policy::VerificationPolicy { + reference_uri_types: UriTypeSet::ALL, + resources: crate::policy::ResourcePolicy { + max_external_resource_bytes: payload.len(), + max_external_resource_total_bytes: payload.len(), + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::VerificationPolicy::default() + }; + + let error = VerifyContext::new() + .key(&AcceptingKey) + .policy(policy) + .external_resources(&resources) + .verify(&xml) + .expect_err("the second dereference must exhaust the aggregate byte ceiling"); + + assert!( + error + .to_string() + .contains("aggregate external resource bytes") + ); + } + #[test] fn verify_context_rejects_empty_uri_when_policy_disallows_empty() { let xml = minimal_signature_xml("", ""); @@ -3502,7 +3549,6 @@ mod tests { materialize_retrieval_methods( &mut key_info, &resolver, - None, UriTypeSet::SAME_DOCUMENT, None, crate::provider::default_provider(), @@ -3535,7 +3581,6 @@ mod tests { materialize_retrieval_methods( &mut key_info, &UriReferenceResolver::new(&document), - None, UriTypeSet::SAME_DOCUMENT, None, crate::provider::default_provider(), @@ -3572,11 +3617,11 @@ mod tests { "https://example.test/keys/signer.der".to_string(), certificate, )]); + let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources); materialize_retrieval_methods( &mut key_info, - &UriReferenceResolver::new(&document), - Some(&resources), + &resolver, UriTypeSet::ALL, None, crate::provider::default_provider(), @@ -3608,7 +3653,6 @@ mod tests { let error = materialize_retrieval_methods( &mut key_info, &UriReferenceResolver::new(&document), - None, UriTypeSet::SAME_DOCUMENT, None, crate::provider::default_provider(), @@ -3640,7 +3684,6 @@ mod tests { let error = materialize_retrieval_methods( &mut key_info, &UriReferenceResolver::new(&document), - None, UriTypeSet::SAME_DOCUMENT, None, crate::provider::default_provider(), @@ -3671,7 +3714,6 @@ mod tests { let error = materialize_retrieval_methods( &mut key_info, &UriReferenceResolver::new(&document), - None, UriTypeSet::SAME_DOCUMENT, None, crate::provider::default_provider(), @@ -3706,7 +3748,6 @@ mod tests { materialize_retrieval_methods( &mut key_info, &UriReferenceResolver::new(&document), - None, UriTypeSet::SAME_DOCUMENT, None, crate::provider::default_provider(), @@ -3741,11 +3782,11 @@ mod tests { .collect(), }; let document = Document::parse("").unwrap(); + let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources); let error = materialize_retrieval_methods( &mut key_info, - &UriReferenceResolver::new(&document), - Some(&resources), + &resolver, UriTypeSet::ALL, None, crate::provider::default_provider(), @@ -3779,11 +3820,11 @@ mod tests { .collect(), }; let document = Document::parse("").unwrap(); + let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources); materialize_retrieval_methods( &mut key_info, - &UriReferenceResolver::new(&document), - Some(&resources), + &resolver, UriTypeSet::ALL, None, crate::provider::default_provider(), @@ -3814,11 +3855,11 @@ mod tests { }], }; let document = Document::parse("").unwrap(); + let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources); let error = materialize_retrieval_methods( &mut key_info, - &UriReferenceResolver::new(&document), - Some(&resources), + &resolver, UriTypeSet::ALL, None, crate::provider::default_provider(), diff --git a/src/xmlenc/encrypt.rs b/src/xmlenc/encrypt.rs index 872bb864..32122aae 100644 --- a/src/xmlenc/encrypt.rs +++ b/src/xmlenc/encrypt.rs @@ -1151,9 +1151,20 @@ mod tests { EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) .direct_key([0_u8; 16]) - .policy(policy) + .policy(policy.clone()) .encrypt_binary(&[]) .expect("zero ceilings must allow resources the operation does not consume"); + + assert!(matches!( + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key([0_u8; 16]) + .policy(policy) + .encrypt_binary(b"x"), + Err(XmlEncError::PlaintextTooLarge { + maximum: 0, + actual: 1 + }) + )); } #[test] From a4706028230cf9b852b235b4eb18ce3af1109154 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 9 Aug 2026 12:45:24 +0300 Subject: [PATCH 34/63] fix(xmldsig): close policy bypasses - share the operation XML Base budget with SignedInfo C14N - route X.509 path and CRL authentication through the selected provider - cover verification, signing, path-building, and CRL regressions --- docs/xmldsig.md | 10 +- src/c14n/mod.rs | 12 ++- src/xmldsig/keys.rs | 223 +++++++++++++++++++++++++++++++++++++--- src/xmldsig/parse.rs | 75 +++++++++----- src/xmldsig/sign.rs | 5 +- src/xmldsig/verify.rs | 43 +++++++- src/xmldsig/x509.rs | 161 +++++++++++++++++++++-------- tests/signing_digest.rs | 47 +++++++++ 8 files changed, 478 insertions(+), 98 deletions(-) diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 55be43a7..3328c918 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -39,8 +39,9 @@ randomness must implement `SigningKey::sign_with_provider`; deterministic and ex keys can use the default implementation. `VerifyContext::provider` covers every verification-time cryptographic operation, including -reference digests, signature verification, and `X509Digest` selector evaluation performed by -`DefaultKeyResolver`. Custom resolvers that evaluate cryptographic key metadata should override +reference digests, document signatures, `X509Digest` selector evaluation, X.509 candidate-path +edges, complete certificate paths, and CRL authentication performed by `DefaultKeyResolver`. +Custom resolvers that evaluate cryptographic key metadata should override `KeyResolver::resolve_with_policy_and_provider`; source-only resolvers can retain the default hook. ## Verification Policy @@ -92,8 +93,9 @@ and the Merlin same-document `X509Data` XPath selection. Relative external `Refe `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. The compiled resource policy bounds both inherited `xml:base` components and cumulative URI-resolution bytes across external -References, `RetrievalMethod`, and C14N 1.1 fixup; implementation ceilings are 64 components and -1 MiB per operation. Other retrieval transform chains fail closed instead of being ignored. +References, `RetrievalMethod`, Reference transforms, and SignedInfo C14N 1.1 fixup; implementation +ceilings are 64 components and 1 MiB per operation. Other retrieval transform chains fail closed +instead of being ignored. `VerifyContext::allowed_transforms` applies to Reference transforms and implicit C14N, the declared SignedInfo canonicalization method, and supported RetrievalMethod transforms. Allowing XPath for signed payload processing therefore also explicitly permits the bounded diff --git a/src/c14n/mod.rs b/src/c14n/mod.rs index d5e04496..1c73f467 100644 --- a/src/c14n/mod.rs +++ b/src/c14n/mod.rs @@ -257,17 +257,18 @@ 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( +/// Canonicalize through the closure visibility API while enforcing both the +/// output ceiling and the caller's operation-wide XML Base work budget. +pub(crate) fn canonicalize_bounded_with_xml_base_budget( doc: &Document, node_set: Option<&dyn Fn(Node) -> bool>, algo: &C14nAlgorithm, max_output_bytes: usize, + xml_base_resolution: &xml_base::XmlBaseResolutionBudget, output: &mut Vec, ) -> Result<(), C14nError> { let visibility = node_set.map(|predicate| ClosureVisibility { predicate }); - canonicalize_with_visibility_and_position_bounded( + canonicalize_with_visibility_and_position_bounded_with_xml_base_budget( doc, visibility .as_ref() @@ -275,6 +276,7 @@ pub(crate) fn canonicalize_bounded( algo, None, max_output_bytes, + xml_base_resolution, output, )?; Ok(()) @@ -308,7 +310,7 @@ pub(crate) fn canonicalize_with_visibility_and_position( ) } -#[cfg(any(feature = "xmldsig", test))] +#[cfg(test)] pub(crate) fn canonicalize_with_visibility_and_position_bounded( doc: &Document, visibility: Option<&dyn NodeVisibility>, diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 714da1a9..96da9d3b 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -22,7 +22,7 @@ use super::{ 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, + x509::verify_x509_certificate_chain_with_provider, }; /// Caller-owned HMAC-SHA1 verification key. @@ -235,7 +235,7 @@ impl DefaultKeyResolver { .ok_or(KeyResolutionError::InvalidCertificate)? .clone(); if trust.verify_x509_chains { - self.prepare_embedded_x509(info, signing_index, trust)?; + self.prepare_embedded_x509(info, signing_index, trust, provider)?; } certificate_der } else { @@ -269,6 +269,7 @@ impl DefaultKeyResolver { &self, info: &X509DataInfo, trust: &crate::policy::KeyTrustPolicy, + provider: &dyn crate::provider::CryptoProvider, ) -> Result<(), KeyResolutionError> { let options = X509ChainOptions { trusted_certs: &self.config.trusted_certs, @@ -276,7 +277,7 @@ impl DefaultKeyResolver { max_chain_depth: trust.max_x509_chain_depth, check_crls: trust.check_crls, }; - verify_x509_certificate_chain(info, &options)?; + verify_x509_certificate_chain_with_provider(info, &options, provider)?; Ok(()) } @@ -285,6 +286,7 @@ impl DefaultKeyResolver { info: &X509DataInfo, signing_index: usize, trust: &crate::policy::KeyTrustPolicy, + provider: &dyn crate::provider::CryptoProvider, ) -> Result { let signing_der = info .certificates @@ -329,7 +331,13 @@ impl DefaultKeyResolver { .iter() .position(|certificate| certificate == signing_der) .ok_or(KeyResolutionError::InvalidCertificate)?; - self.select_valid_x509_path(&mut available, signing_index, trusted_prefix_len, trust)?; + self.select_valid_x509_path( + &mut available, + signing_index, + trusted_prefix_len, + trust, + provider, + )?; Ok(available) } @@ -339,6 +347,7 @@ impl DefaultKeyResolver { signing_index: usize, trusted_prefix_len: usize, trust: &crate::policy::KeyTrustPolicy, + provider: &dyn crate::provider::CryptoProvider, ) -> Result<(), KeyResolutionError> { let candidates = build_x509_certificate_paths_to_trusted_prefix( available, @@ -346,15 +355,19 @@ impl DefaultKeyResolver { trusted_prefix_len, trust.max_x509_chain_depth, trust.max_x509_candidate_paths, + provider, ) .map_err(|error| match error { X509ChainBuildError::AmbiguousIssuer => KeyResolutionError::AmbiguousCertificate, + X509ChainBuildError::Provider(error) => { + KeyResolutionError::Chain(super::X509ChainError::Provider(error)) + } _ => KeyResolutionError::InvalidCertificate, })?; let mut first_error = None; for candidate in candidates { available.certificate_chain = candidate; - match self.verify_x509_policy(available, trust) { + match self.verify_x509_policy(available, trust, provider) { Ok(()) => return Ok(()), Err(error) => { first_error.get_or_insert(error); @@ -465,16 +478,21 @@ 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 < trusted_prefix_len - || !trust.verify_x509_chains - { - vec![signing_index] - } else { - self.select_valid_x509_path(&mut available, signing_index, trusted_prefix_len, trust)?; - available.certificate_chain.clone() - }; + available.certificate_chain = + if signing_index < trusted_prefix_len || !trust.verify_x509_chains { + vec![signing_index] + } else { + self.select_valid_x509_path( + &mut available, + signing_index, + trusted_prefix_len, + trust, + provider, + )?; + available.certificate_chain.clone() + }; if trust.verify_x509_chains && signing_index < trusted_prefix_len { - self.verify_x509_policy(&available, trust)?; + self.verify_x509_policy(&available, trust, provider)?; } Ok(Some(available)) } @@ -787,6 +805,9 @@ mod tests { struct RejectSecondSha512Provider { sha512_calls: AtomicUsize, + verification_calls: AtomicUsize, + reject_verification_call: Option, + rejected_verification_data: Option>, } impl crate::provider::CryptoProvider for RejectSecondSha512Provider { @@ -834,6 +855,19 @@ mod tests { data: &[u8], signature: &[u8], ) -> Result { + let call = self.verification_calls.fetch_add(1, Ordering::Relaxed); + if self.reject_verification_call == Some(call) + || self + .rejected_verification_data + .as_deref() + .is_some_and(|rejected| rejected == data) + { + return Err(crate::provider::ProviderError::Unsupported { + operation: crate::provider::ProviderOperation::Verify, + algorithm: Some(algorithm.uri().to_owned()), + } + .into()); + } crate::provider::default_provider().verify(key, algorithm, data, signature) } @@ -1495,6 +1529,125 @@ mod tests { assert!(resolved.is_some()); } + #[test] + fn x509_path_signatures_use_the_operation_provider() { + // Embedded and selector-resolved certificates converge on the same + // path validator. Neither source may fall back to a crate-global + // verifier when the operation provider rejects certificate signatures. + let root = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("provider root", true), + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("root should be self-signable"); + let leaf = generated_certificate_params("provider leaf", false) + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &root, + ) + .expect("root should sign the leaf"); + let leaf_der = leaf.der().to_vec(); + let leaf_metadata = + parse_x509_certificate(&leaf_der).expect("generated leaf metadata should parse"); + let policy = crate::policy::VerificationPolicy { + key_trust: chain_policy(), + ..crate::policy::VerificationPolicy::default() + }; + + let cases = [ + ( + KeyInfo { + sources: vec![KeyInfoSource::X509Data(x509_info( + vec![leaf_der.clone()], + 0, + ))], + }, + Vec::new(), + ), + ( + KeyInfo { + sources: vec![KeyInfoSource::X509Data(X509DataInfo { + subject_names: vec![leaf_metadata.subject_dn], + ..X509DataInfo::default() + })], + }, + vec![leaf_der], + ), + ]; + + for (key_info, lookup_certs) in cases { + let provider = RejectSecondSha512Provider { + sha512_calls: AtomicUsize::new(0), + verification_calls: AtomicUsize::new(0), + reject_verification_call: Some(0), + rejected_verification_data: None, + }; + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + trusted_certs: vec![root.der().to_vec()], + lookup_certs, + trust: chain_policy(), + ..KeyResolverConfig::default() + }); + let error = match resolver.resolve_with_policy_and_provider( + Some(&key_info), + SignatureAlgorithm::EcdsaP256Sha256, + &policy, + &provider, + ) { + Ok(_) => panic!("the operation provider must gate every X.509 path signature"), + Err(error) => error, + }; + + assert!(matches!( + error, + DsigError::KeyResolution(KeyResolutionError::Chain( + super::super::X509ChainError::Provider( + crate::provider::ProviderError::Unsupported { + operation: crate::provider::ProviderOperation::Verify, + .. + } + ) + )) + )); + assert_eq!(provider.verification_calls.load(Ordering::Relaxed), 1); + } + + // A provider rejection after path construction proves complete-path + // validation does not switch back to the crate-global provider. + let provider = RejectSecondSha512Provider { + sha512_calls: AtomicUsize::new(0), + verification_calls: AtomicUsize::new(0), + reject_verification_call: Some(1), + rejected_verification_data: None, + }; + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + trusted_certs: vec![root.der().to_vec()], + trust: chain_policy(), + ..KeyResolverConfig::default() + }); + let key_info = KeyInfo { + sources: vec![KeyInfoSource::X509Data(x509_info( + vec![leaf.der().to_vec()], + 0, + ))], + }; + let error = match resolver.resolve_with_policy_and_provider( + Some(&key_info), + SignatureAlgorithm::EcdsaP256Sha256, + &policy, + &provider, + ) { + Ok(_) => panic!("complete-path validation must retain the operation provider"), + Err(error) => error, + }; + assert!(matches!( + error, + DsigError::KeyResolution(KeyResolutionError::Chain( + super::super::X509ChainError::Provider(_) + )) + )); + assert_eq!(provider.verification_calls.load(Ordering::Relaxed), 2); + } + #[test] fn embedded_leaf_uses_lookup_intermediate_with_duplicate_anchor() { // Deduplicating repeated trust anchors must not shift an untrusted @@ -1680,7 +1833,14 @@ mod tests { ); assert!(matches!( - build_x509_certificate_paths_to_trusted_prefix(&info, 2, 1, 9, 2), + build_x509_certificate_paths_to_trusted_prefix( + &info, + 2, + 1, + 9, + 2, + crate::provider::default_provider(), + ), Err(X509ChainBuildError::AmbiguousIssuer) )); } @@ -1765,9 +1925,13 @@ mod tests { let crl = crl_der(include_str!( "../../tests/fixtures/keys/rsa/rsa-2048-cert-revoked-crl.pem" )); + let (_, parsed_crl) = + x509_parser::revocation_list::CertificateRevocationList::from_der(&crl) + .expect("tracked CRL must parse"); + let crl_signed_data = parsed_crl.tbs_cert_list.as_ref().to_vec(); let xml = replace_unprefixed_key_info( RSA_KEY_VALUE_SIGNATURE, - &selector.replace("CRL_PLACEHOLDER", &STANDARD.encode(crl)), + &selector.replace("CRL_PLACEHOLDER", &STANDARD.encode(&crl)), ); let resolver = DefaultKeyResolver::new(KeyResolverConfig { lookup_certs: vec![certificate_der(include_str!( @@ -1797,6 +1961,27 @@ mod tests { super::super::X509ChainError::Revoked(0) )) )); + + // Match the exact TBSCertList bytes so earlier certificate-edge + // verification succeeds and the provider rejection occurs at CRL + // authentication itself. + let provider = RejectSecondSha512Provider { + sha512_calls: AtomicUsize::new(0), + verification_calls: AtomicUsize::new(0), + reject_verification_call: None, + rejected_verification_data: Some(crl_signed_data), + }; + let error = super::super::VerifyContext::new() + .key_resolver(&resolver) + .provider(&provider) + .verify(&xml) + .expect_err("CRL authentication must retain the operation provider"); + assert!(matches!( + error, + DsigError::KeyResolution(KeyResolutionError::Chain( + super::super::X509ChainError::Provider(_) + )) + )); } #[test] @@ -1963,6 +2148,9 @@ mod tests { }); let provider = RejectSecondSha512Provider { sha512_calls: AtomicUsize::new(0), + verification_calls: AtomicUsize::new(0), + reject_verification_call: None, + rejected_verification_data: None, }; let error = super::super::VerifyContext::new() .key_resolver(&resolver) @@ -2088,6 +2276,9 @@ mod tests { let document = roxmltree::Document::parse(&xml).expect("generated KeyInfo must be XML"); let provider = RejectSecondSha512Provider { sha512_calls: AtomicUsize::new(1), + verification_calls: AtomicUsize::new(0), + reject_verification_call: None, + rejected_verification_data: None, }; let error = diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index 4c53d0cc..f0113646 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -33,7 +33,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 super::x509::certificate_signature_matches_with_provider; use crate::c14n::C14nAlgorithm; use crate::c14n::xml_base::{ XmlBaseResolutionBudget, compute_effective_xml_base_with_budget, resolve_uri_with_budget, @@ -1200,16 +1200,17 @@ fn build_x509_certificate_chain( } let signing_idx = select_x509_signing_certificate(info, provider)?; - build_x509_certificate_chain_from(info, signing_idx).map_err(ParseError::from) + build_x509_certificate_chain_from(info, signing_idx, provider).map_err(ParseError::from) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum X509ChainBuildError { InconsistentMetadata, DepthExceeded, Cycle, IssuerSignatureMismatch, AmbiguousIssuer, + Provider(crate::provider::ProviderError), } impl From for ParseError { @@ -1228,6 +1229,7 @@ impl From for ParseError { X509ChainBuildError::AmbiguousIssuer => { "X509Data certificate chain contains ambiguous issuer certificates" } + X509ChainBuildError::Provider(error) => return Self::Provider(error), }; Self::InvalidStructure(reason.into()) } @@ -1237,6 +1239,7 @@ impl From for ParseError { pub(crate) fn build_x509_certificate_chain_from( info: &X509DataInfo, signing_idx: usize, + provider: &dyn crate::provider::CryptoProvider, ) -> Result, X509ChainBuildError> { if signing_idx >= info.parsed_certificates.len() || info.parsed_certificates.len() != info.certificates.len() @@ -1269,15 +1272,21 @@ pub(crate) fn build_x509_certificate_chain_from( [] => break, [issuer_idx] => *issuer_idx, _ => { - let verified = candidates - .into_iter() - .filter(|issuer_idx| { - certificate_signature_matches( - &info.certificates[current_idx], - &info.certificates[*issuer_idx], - ) - }) - .collect::>(); + let mut verified = Vec::new(); + for issuer_idx in candidates { + match certificate_signature_matches_with_provider( + &info.certificates[current_idx], + &info.certificates[issuer_idx], + provider, + ) { + Ok(true) => verified.push(issuer_idx), + Ok(false) => {} + Err(super::X509ChainError::Provider(error)) => { + return Err(X509ChainBuildError::Provider(error)); + } + Err(_) => return Err(X509ChainBuildError::IssuerSignatureMismatch), + } + } match verified.as_slice() { [issuer_idx] => *issuer_idx, [] => return Err(X509ChainBuildError::IssuerSignatureMismatch), @@ -1306,6 +1315,7 @@ pub(crate) fn build_x509_certificate_paths_to_trusted_prefix( trusted_prefix_len: usize, max_depth: usize, max_candidate_paths: usize, + provider: &dyn crate::provider::CryptoProvider, ) -> Result>, X509ChainBuildError> { if signing_idx >= info.parsed_certificates.len() || info.parsed_certificates.len() != info.certificates.len() @@ -1336,20 +1346,30 @@ pub(crate) fn build_x509_certificate_paths_to_trusted_prefix( } let current = &info.parsed_certificates[current_idx]; - 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::>() - }); + if issuer_cache[current_idx].is_none() { + let mut verified = Vec::new(); + for (issuer_idx, issuer) in info.parsed_certificates.iter().enumerate() { + if !distinguished_names_equal(&issuer.subject_dn, ¤t.issuer_dn) { + continue; + } + match certificate_signature_matches_with_provider( + &info.certificates[current_idx], + &info.certificates[issuer_idx], + provider, + ) { + Ok(true) => verified.push(issuer_idx), + Ok(false) => {} + Err(super::X509ChainError::Provider(error)) => { + return Err(X509ChainBuildError::Provider(error)); + } + Err(_) => return Err(X509ChainBuildError::IssuerSignatureMismatch), + } + } + issuer_cache[current_idx] = Some(verified); + } + let issuers = issuer_cache[current_idx] + .as_ref() + .expect("issuer cache entry was initialized"); let issuers = issuers .iter() .copied() @@ -2872,7 +2892,8 @@ BA== 0 ); assert_eq!( - build_x509_certificate_chain_from(&info, 0).unwrap(), + build_x509_certificate_chain_from(&info, 0, crate::provider::default_provider()) + .unwrap(), vec![0, 1, 2] ); } diff --git a/src/xmldsig/sign.rs b/src/xmldsig/sign.rs index aa169165..c6db1c5c 100644 --- a/src/xmldsig/sign.rs +++ b/src/xmldsig/sign.rs @@ -19,7 +19,7 @@ use sha2::{Sha256, Sha384, Sha512}; use std::collections::HashSet; use x509_parser::prelude::FromDer; -use crate::c14n::{canonicalize_bounded, is_output_limit_error}; +use crate::c14n::{canonicalize_bounded_with_xml_base_budget, is_output_limit_error}; use super::builder::{SignatureBuilder, SignatureBuilderError}; use super::digest::DigestAlgorithm; @@ -824,11 +824,12 @@ fn canonicalize_signed_info( .map(|node: Node<'_, '_>| node.id()) .collect(); let mut canonical_signed_info = Vec::new(); - canonicalize_bounded( + canonicalize_bounded_with_xml_base_budget( &doc, Some(&|node| signed_info_subtree.contains(&node.id())), &signed_info.c14n_method, execution_budget.remaining_c14n_output(), + execution_budget.xml_base_resolution(), &mut canonical_signed_info, ) .map_err(|error| { diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index 834a58da..5523ee07 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_bounded, is_output_limit_error}; +use crate::c14n::{canonicalize_bounded_with_xml_base_budget, is_output_limit_error}; use crate::hard_limits::{CANONICALIZED_SIGNATURE_DATA_BYTE_CEILING, XML_DOCUMENT_NODE_CEILING}; #[cfg(test)] @@ -1125,11 +1125,12 @@ fn verify_signature_with_context( .map(|node: Node<'_, '_>| node.id()) .collect(); let mut canonical_signed_info = Vec::new(); - canonicalize_bounded( + canonicalize_bounded_with_xml_base_budget( &doc, Some(&|node| signed_info_subtree.contains(&node.id())), &signed_info.c14n_method, canonicalized_data_budget.remaining(), + execution_budget.xml_base_resolution(), &mut canonical_signed_info, ) .map_err(|error| { @@ -2641,6 +2642,44 @@ mod tests { )); } + #[test] + fn verify_context_applies_xml_base_policy_to_signed_info_c14n() { + // The SignedInfo node-set excludes its ancestors, so C14N 1.1 must + // resolve their inherited xml:base values through the same operation + // budget already used by Reference processing. + let xml = signature_with_target_reference("AQ==") + .replacen( + "http://www.w3.org/2001/10/xml-exc-c14n#", + "http://www.w3.org/2006/12/xml-c14n11", + 1, + ) + .replace( + " ", + " ", + ) + .replace(" ", " "); + let policy = crate::policy::VerificationPolicy { + resources: crate::policy::ResourcePolicy { + max_xml_base_components: 1, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::VerificationPolicy::default() + }; + + let error = VerifyContext::new() + .key(&AcceptingKey) + .policy(policy) + .verify(&xml) + .expect_err("SignedInfo C14N must use the operation XML Base budget"); + + assert!(matches!( + error, + SignatureVerificationPipelineError::Canonicalization( + crate::c14n::C14nError::XmlBaseComponentsTooLarge { max: 1, actual: 2 } + ) + )); + } + #[test] fn verify_context_meters_repeated_external_dereferences() { // One caller-owned entry can be referenced repeatedly. The aggregate diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index 8e7c85d9..d50c7efd 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -3,17 +3,13 @@ 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, }; use super::{ - X509DataInfo, + SignatureAlgorithm, VerificationKey, X509DataInfo, parse::{distinguished_names_equal, x509_name_to_rfc4514}, }; @@ -34,6 +30,9 @@ pub struct X509ChainOptions<'a> { #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[non_exhaustive] pub enum X509ChainError { + /// The selected cryptographic provider rejected path authentication. + #[error("cryptographic provider rejected X.509 authentication: {0}")] + Provider(#[from] crate::provider::ProviderError), /// The configured path limit cannot contain a certificate. #[error("maximum certificate chain depth must be greater than zero")] InvalidDepth, @@ -88,6 +87,14 @@ pub enum X509ChainError { pub fn verify_x509_certificate_chain( info: &X509DataInfo, options: &X509ChainOptions<'_>, +) -> Result<(), X509ChainError> { + verify_x509_certificate_chain_with_provider(info, options, crate::provider::default_provider()) +} + +pub(crate) fn verify_x509_certificate_chain_with_provider( + info: &X509DataInfo, + options: &X509ChainOptions<'_>, + provider: &dyn crate::provider::CryptoProvider, ) -> Result<(), X509ChainError> { if options.max_chain_depth == 0 { return Err(X509ChainError::InvalidDepth); @@ -121,18 +128,18 @@ pub fn verify_x509_certificate_chain( let verification_time = system_time_to_asn1(options.verification_time)?; let embedded_anchor = trusted_anchors.iter().any(|(der, _)| *der == last.as_raw()); if embedded_anchor { - return validate_path(&path_der, info, options, verification_time); + return validate_path(&path_der, info, options, verification_time, provider); } // 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 && certificate_names_equal(last.subject(), last.issuer()) - && verify_certificate_signature(&last, &last) + && verify_certificate_signature_with_provider(&last, &last, provider)? { let child = parse_certificate(path_der[path_der.len() - 2])?; certificate_names_equal(child.issuer(), last.subject()) - && verify_certificate_signature(&child, &last) + && verify_certificate_signature_with_provider(&child, &last, provider)? } else { false }; @@ -149,13 +156,15 @@ pub fn verify_x509_certificate_chain( )?; let mut first_validation_error = None; - for (anchor_der, _) in trusted_anchors.iter().filter(|(_, cert)| { - certificate_names_equal(cert.subject(), candidate_child.issuer()) - && verify_certificate_signature(&candidate_child, cert) - }) { + for (anchor_der, cert) in &trusted_anchors { + if !certificate_names_equal(cert.subject(), candidate_child.issuer()) + || !verify_certificate_signature_with_provider(&candidate_child, cert, provider)? + { + continue; + } let mut candidate_path = candidate_base.to_vec(); candidate_path.push(anchor_der); - match validate_path(&candidate_path, info, options, verification_time) { + match validate_path(&candidate_path, info, options, verification_time, provider) { Ok(()) => return Ok(()), Err(error) => first_validation_error.get_or_insert(error), }; @@ -169,6 +178,7 @@ fn validate_path( info: &X509DataInfo, options: &X509ChainOptions<'_>, verification_time: ASN1Time, + provider: &dyn crate::provider::CryptoProvider, ) -> Result<(), X509ChainError> { if path_der.len() > options.max_chain_depth { return Err(X509ChainError::DepthExceeded(options.max_chain_depth)); @@ -195,39 +205,48 @@ fn validate_path( unreachable!() }; if !certificate_names_equal(child.issuer(), issuer.subject()) - || !verify_certificate_signature(child, issuer) + || !verify_certificate_signature_with_provider(child, issuer, provider)? { return Err(X509ChainError::InvalidSignature(position)); } } if options.check_crls { - verify_crls(&path, &info.crls, verification_time)?; + verify_crls(&path, &info.crls, verification_time, provider)?; } Ok(()) } +#[cfg(test)] fn verify_certificate_signature( certificate: &X509Certificate<'_>, issuer: &X509Certificate<'_>, ) -> bool { + verify_certificate_signature_with_provider( + certificate, + issuer, + crate::provider::default_provider(), + ) + .unwrap_or(false) +} + +fn verify_certificate_signature_with_provider( + certificate: &X509Certificate<'_>, + issuer: &X509Certificate<'_>, + provider: &dyn crate::provider::CryptoProvider, +) -> Result { // 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() - { - return true; + return Ok(false); } - verify_dsa_sha1_signature( + verify_x509_signature_with_provider( &certificate.signature_algorithm.algorithm.to_id_string(), &certificate.signature_value.data, certificate.tbs_certificate.as_ref(), issuer.public_key().raw, + provider, ) } @@ -235,14 +254,28 @@ fn verify_certificate_signature( /// 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. +#[cfg(test)] pub(crate) fn certificate_signature_matches(certificate_der: &[u8], issuer_der: &[u8]) -> bool { + certificate_signature_matches_with_provider( + certificate_der, + issuer_der, + crate::provider::default_provider(), + ) + .unwrap_or(false) +} + +pub(crate) fn certificate_signature_matches_with_provider( + certificate_der: &[u8], + issuer_der: &[u8], + provider: &dyn crate::provider::CryptoProvider, +) -> Result { let (Ok(certificate), Ok(issuer)) = ( parse_certificate(certificate_der), parse_certificate(issuer_der), ) else { - return false; + return Ok(false); }; - verify_certificate_signature(&certificate, &issuer) + verify_certificate_signature_with_provider(&certificate, &issuer, provider) } fn certificate_names_equal( @@ -255,40 +288,83 @@ fn certificate_names_equal( distinguished_names_equal(&left, &right) } +#[cfg(test)] fn verify_crl_signature(crl: &CertificateRevocationList<'_>, issuer: &X509Certificate<'_>) -> bool { + verify_crl_signature_with_provider(crl, issuer, crate::provider::default_provider()) + .unwrap_or(false) +} + +fn verify_crl_signature_with_provider( + crl: &CertificateRevocationList<'_>, + issuer: &X509Certificate<'_>, + provider: &dyn crate::provider::CryptoProvider, +) -> Result { // 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; + return Ok(false); } - verify_dsa_sha1_signature( + verify_x509_signature_with_provider( &crl.signature_algorithm.algorithm.to_id_string(), &crl.signature_value.data, crl.tbs_cert_list.as_ref(), issuer.public_key().raw, + provider, ) } -fn verify_dsa_sha1_signature( +fn verify_x509_signature_with_provider( 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_spki_der) else { - return false; + provider: &dyn crate::provider::CryptoProvider, +) -> Result { + let Some(algorithm) = x509_signature_algorithm(algorithm_oid) else { + return Ok(false); }; - let Ok(signature) = dsa::Signature::from_der(signature_der) else { - return false; + let signature = if algorithm == SignatureAlgorithm::DsaSha1 { + let Ok(signature) = dsa::Signature::from_der(signature_der) else { + return Ok(false); + }; + let r = signature.r().to_be_bytes(); + let s = signature.s().to_be_bytes(); + let r = &r[r.iter().position(|byte| *byte != 0).unwrap_or(r.len())..]; + let s = &s[s.iter().position(|byte| *byte != 0).unwrap_or(s.len())..]; + if r.len() > 20 || s.len() > 20 { + return Ok(false); + } + let mut fixed = vec![0_u8; 40]; + fixed[20 - r.len()..20].copy_from_slice(r); + fixed[40 - s.len()..].copy_from_slice(s); + fixed + } else { + signature_der.to_vec() }; - let digest = Sha1::digest(signed_data); - key.verify_prehash(&digest, &signature).is_ok() + let key = VerificationKey { + algorithm, + public_key_bytes: issuer_spki_der.to_vec(), + certificate_der: None, + name: None, + }; + match provider.verify(&key, algorithm, signed_data, &signature) { + Ok(verified) => Ok(verified), + Err(super::DsigError::Provider(error)) => Err(error.into()), + Err(_) => Ok(false), + } +} + +fn x509_signature_algorithm(oid: &str) -> Option { + match oid { + "1.2.840.10040.4.3" => Some(SignatureAlgorithm::DsaSha1), + "1.2.840.113549.1.1.5" | "1.3.14.3.2.29" => Some(SignatureAlgorithm::RsaSha1), + "1.2.840.113549.1.1.11" => Some(SignatureAlgorithm::RsaSha256), + "1.2.840.113549.1.1.12" => Some(SignatureAlgorithm::RsaSha384), + "1.2.840.113549.1.1.13" => Some(SignatureAlgorithm::RsaSha512), + "1.2.840.10045.4.3.2" => Some(SignatureAlgorithm::EcdsaP256Sha256), + "1.2.840.10045.4.3.3" => Some(SignatureAlgorithm::EcdsaP384Sha384), + _ => None, + } } fn validate_leaf_key_usage(cert: &X509Certificate<'_>) -> Result<(), X509ChainError> { @@ -377,6 +453,7 @@ fn verify_crls( path: &[X509Certificate<'_>], crl_der: &[Vec], verification_time: ASN1Time, + provider: &dyn crate::provider::CryptoProvider, ) -> Result<(), X509ChainError> { let crls = crl_der .iter() @@ -421,7 +498,7 @@ fn verify_crls( && crl .next_update() .is_none_or(|next| verification_time <= next); - if !time_valid || !verify_crl_signature(crl, issuer) { + if !time_valid || !verify_crl_signature_with_provider(crl, issuer, provider)? { return Err(X509ChainError::InvalidCrl(*crl_index)); } if crl.iter_revoked_certificates().any(|revoked| { diff --git a/tests/signing_digest.rs b/tests/signing_digest.rs index f17f2064..00cac1ee 100644 --- a/tests/signing_digest.rs +++ b/tests/signing_digest.rs @@ -346,6 +346,53 @@ fn signing_policy_shares_canonicalization_budget_with_signed_info() { .expect("the same reference must sign when the combined budget fits"); } +#[test] +fn signing_policy_applies_xml_base_budget_to_signed_info_c14n() { + // SignedInfo canonicalization is part of the same operation as Reference + // transforms and must not replace the compiled XML Base policy with hard + // defaults when inherited context is reconstructed. + 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") + .replacen( + "http://www.w3.org/2001/10/xml-exc-c14n#", + "http://www.w3.org/2006/12/xml-c14n11", + 1, + ) + .replace( + "", ""); + let policy = SigningPolicy { + resources: xml_sec::policy::ResourcePolicy { + max_xml_base_components: 1, + ..xml_sec::policy::ResourcePolicy::default() + }, + ..SigningPolicy::default() + }; + + let result = SignContext::new(&private_key) + .policy(policy) + .sign_template(&xml); + assert!( + matches!( + result, + Err(SigningError::Canonicalization( + xml_sec::c14n::C14nError::XmlBaseComponentsTooLarge { max: 1, actual: 2 } + )) + ), + "unexpected signing result: {result:?}" + ); +} + #[test] fn signing_policy_covers_implicit_and_signed_info_canonicalization() { // The transform allowlist covers algorithms executed implicitly by the From 42a25e8b5da8e8b565bff1644aa9507dfe12278f Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 9 Aug 2026 13:25:53 +0300 Subject: [PATCH 35/63] fix(xmldsig): decouple ECDSA curve and hash - select ECDSA digest from SignatureMethod or X.509 OID - select P-256, P-384, or P-521 exclusively from SPKI - cover cross-pair signing, verification, and donor vectors --- README.md | 4 +- docs/xmldsig.md | 7 +- src/xmldsig/keys.rs | 81 ++++++++++---------- src/xmldsig/parse.rs | 31 ++++---- src/xmldsig/sign.rs | 37 +++++---- src/xmldsig/signature.rs | 101 ++++++++++--------------- src/xmldsig/verify.rs | 2 +- src/xmldsig/x509.rs | 56 +++++++++++++- tests/donor_full_verification_suite.rs | 4 +- tests/donor_interop_suite.rs | 6 +- tests/ecdsa_signature_integration.rs | 40 +++++----- tests/signature_builder.rs | 2 +- tests/signing_digest.rs | 63 +++++++++++---- tests/xmlsec1_interop.rs | 4 +- 14 files changed, 258 insertions(+), 180 deletions(-) diff --git a/README.md b/README.md index d72f00dc..93b63e1a 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,9 @@ Currently implemented (core paths): - 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 -- ECDSA verification helpers for P-256/SHA-256 and P-384/SHA-384 +- ECDSA SHA-256/SHA-384 verification for P-256, P-384, and P-521 keys - 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 +- RSA PKCS#1 v1.5 and ECDSA SHA-256/SHA-384 signing with P-256/P-384 PKCS#8 keys - Opt-in X.509 certificate-chain validation with explicit trust anchors, validity checks, CA constraints, and authenticated CRLs - Caller-supplied external references and X.509 `RetrievalMethod` resolution with bounded RFC 3986 `xml:base` processing and no implicit I/O - XMLEnc AES-128/256-CBC and AES-128/256-GCM encryption/decryption with direct diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 3328c918..2d6f38ac 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 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, +Base64, XPath 1.0, and XPath Filter 2.0 transforms, RSA PKCS#1 v1.5, ECDSA SHA-256/SHA-384 +with P-256/P-384/P-521 verification keys, DSA-SHA1 and HMAC-SHA1 verification, embedded X.509 certificates, and configured key resolution. @@ -117,7 +118,9 @@ reported as an invalid per-reference result without changing core `SignedInfo` v ## 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. +verification, SHA-256/SHA-384/SHA-512 for signing, and ECDSA with SHA-256 or SHA-384. ECDSA +verification selects P-256, P-384, or P-521 from the SPKI independently of the hash identifier; +the built-in P-256 and P-384 signing keys support either ECDSA hash identifier. 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 diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 96da9d3b..d9496763 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -136,7 +136,7 @@ impl VerifyingKey for VerificationKey { signed_data, signature_value, ), - SignatureAlgorithm::EcdsaP256Sha256 | SignatureAlgorithm::EcdsaP384Sha384 => { + SignatureAlgorithm::EcdsaSha256 | SignatureAlgorithm::EcdsaSha384 => { verify_ecdsa_signature_spki( algorithm, &self.public_key_bytes, @@ -529,7 +529,7 @@ impl DefaultKeyResolver { } => { if !matches!( algorithm, - SignatureAlgorithm::EcdsaP256Sha256 | SignatureAlgorithm::EcdsaP384Sha384 + SignatureAlgorithm::EcdsaSha256 | SignatureAlgorithm::EcdsaSha384 ) { return Ok(None); } @@ -777,16 +777,11 @@ fn validate_spki_algorithm( | SignatureAlgorithm::RsaSha512, PublicKey::RSA(_), ) => Ok(()), - (SignatureAlgorithm::EcdsaP256Sha256, PublicKey::EC(_)) - if curve_oid.as_deref() == Some("1.2.840.10045.3.1.7") => - { - Ok(()) - } - // xmlsec's OpenSSL backend maps ecdsa-sha384 to EVP_sha384() plus the - // generic EC key class, without restricting the curve to P-384. Keep - // P-521/SHA-384 compatible with that donor contract. - (SignatureAlgorithm::EcdsaP384Sha384, PublicKey::EC(_)) - if matches!(curve_oid.as_deref(), Some("1.3.132.0.34" | "1.3.132.0.35")) => + (SignatureAlgorithm::EcdsaSha256 | SignatureAlgorithm::EcdsaSha384, PublicKey::EC(_)) + if matches!( + curve_oid.as_deref(), + Some("1.2.840.10045.3.1.7" | "1.3.132.0.34" | "1.3.132.0.35") + ) => { Ok(()) } @@ -1320,7 +1315,7 @@ mod tests { }); let resolved = resolver - .resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) + .resolve(Some(&key_info), SignatureAlgorithm::EcdsaSha256) .expect("configured self-signed certificate should validate as its own anchor"); assert!(resolved.is_some()); @@ -1372,7 +1367,7 @@ mod tests { }); let resolved = resolver - .resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) + .resolve(Some(&key_info), SignatureAlgorithm::EcdsaSha256) .expect("an explicitly trusted selected certificate must terminate its path"); assert!(resolved.is_some()); @@ -1415,7 +1410,7 @@ mod tests { }); let resolved = resolver - .resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) + .resolve(Some(&key_info), SignatureAlgorithm::EcdsaSha256) .expect("path construction must stop at the configured anchor"); assert!(resolved.is_some()); @@ -1523,7 +1518,7 @@ mod tests { }); let resolved = resolver - .resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) + .resolve(Some(&key_info), SignatureAlgorithm::EcdsaSha256) .expect("selector-resolved leaf should chain through the lookup intermediate"); assert!(resolved.is_some()); @@ -1589,7 +1584,7 @@ mod tests { }); let error = match resolver.resolve_with_policy_and_provider( Some(&key_info), - SignatureAlgorithm::EcdsaP256Sha256, + SignatureAlgorithm::EcdsaSha256, &policy, &provider, ) { @@ -1632,7 +1627,7 @@ mod tests { }; let error = match resolver.resolve_with_policy_and_provider( Some(&key_info), - SignatureAlgorithm::EcdsaP256Sha256, + SignatureAlgorithm::EcdsaSha256, &policy, &provider, ) { @@ -1687,7 +1682,7 @@ mod tests { ..KeyResolverConfig::default() }); - let error = match resolver.resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) { + let error = match resolver.resolve(Some(&key_info), SignatureAlgorithm::EcdsaSha256) { Ok(_) => panic!("an untrusted lookup intermediate must not become a trust anchor"), Err(error) => error, }; @@ -1752,7 +1747,7 @@ mod tests { }); let resolved = resolver - .resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) + .resolve(Some(&key_info), SignatureAlgorithm::EcdsaSha256) .expect("the sole path to a configured anchor should be selected"); assert!(resolved.is_some()); @@ -1796,7 +1791,7 @@ mod tests { }); let resolved = resolver - .resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) + .resolve(Some(&key_info), SignatureAlgorithm::EcdsaSha256) .expect("same-name rollover path must reach its configured signer"); assert!(resolved.is_some()); @@ -1913,7 +1908,7 @@ mod tests { }); let resolved = resolver - .resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) + .resolve(Some(&key_info), SignatureAlgorithm::EcdsaSha256) .expect("the leaf signature should select its unique same-subject issuer"); assert!(resolved.is_some()); @@ -2181,7 +2176,7 @@ mod tests { config.named_keys.insert( "idp-signing".into(), VerificationKey { - algorithm: SignatureAlgorithm::EcdsaP256Sha256, + algorithm: SignatureAlgorithm::EcdsaSha256, public_key_bytes: public_key_der(SAML_PUBLIC_KEY), certificate_der: None, name: Some("idp-signing".into()), @@ -2447,7 +2442,7 @@ mod tests { config.named_keys.insert( "idp-signing".into(), VerificationKey { - algorithm: SignatureAlgorithm::EcdsaP256Sha256, + algorithm: SignatureAlgorithm::EcdsaSha256, public_key_bytes: public_key_der(SAML_PUBLIC_KEY), certificate_der: None, name: Some("idp-signing".into()), @@ -2471,7 +2466,7 @@ mod tests { config.named_keys.insert( "idp-signing".into(), VerificationKey { - algorithm: SignatureAlgorithm::EcdsaP256Sha256, + algorithm: SignatureAlgorithm::EcdsaSha256, public_key_bytes: public_key_der(SAML_PUBLIC_KEY), certificate_der: None, name: Some("idp-signing".into()), @@ -2495,7 +2490,7 @@ mod tests { config.named_keys.insert( "idp-signing".into(), VerificationKey { - algorithm: SignatureAlgorithm::EcdsaP256Sha256, + algorithm: SignatureAlgorithm::EcdsaSha256, public_key_bytes: public_key_der(SAML_PUBLIC_KEY), certificate_der: None, name: Some("idp-signing".into()), @@ -2520,7 +2515,7 @@ mod tests { config.named_keys.insert( "idp-signing".into(), VerificationKey { - algorithm: SignatureAlgorithm::EcdsaP256Sha256, + algorithm: SignatureAlgorithm::EcdsaSha256, public_key_bytes: public_key_der(SAML_PUBLIC_KEY), certificate_der: None, name: Some("idp-signing".into()), @@ -2544,7 +2539,7 @@ mod tests { config.named_keys.insert( "idp-signing".into(), VerificationKey { - algorithm: SignatureAlgorithm::EcdsaP256Sha256, + algorithm: SignatureAlgorithm::EcdsaSha256, public_key_bytes: public_key_der(SAML_PUBLIC_KEY), certificate_der: None, name: Some("idp-signing".into()), @@ -2578,7 +2573,7 @@ mod tests { config.named_keys.insert( "idp-signing".into(), VerificationKey { - algorithm: SignatureAlgorithm::EcdsaP256Sha256, + algorithm: SignatureAlgorithm::EcdsaSha256, public_key_bytes: public_key_der(SAML_PUBLIC_KEY), certificate_der: None, name: Some("idp-signing".into()), @@ -2595,28 +2590,32 @@ mod tests { } #[test] - fn mismatched_ec_curve_falls_back_to_later_key_name() { - // A valid P-384 key is unusable for an ECDSA-SHA256 signature but must not - // prevent a later P-256 KeyName from resolving the same document. + fn supported_ec_curve_does_not_fall_back_to_later_key_name() { + // ECDSA-SHA256 accepts P-384, so this first source is a usable key and + // must not be skipped merely because a later P-256 KeyName happens to + // verify the signature. Verification fails against the selected key. let key_info = r#"BO/yd/OZzDfjX4qivDY/vsUIuh6KWAxoxW5P4ukvwd+T6pVljWsX2UBJNNy5MdhTwB8e2YwB8kUbJwdsAS/XGi/fz8unFrs+lVlAgIs6s/xBYFbfUoRiAacD2SpVDe6XBA==idp-signing"#; let xml = replace_key_info(SIGNED_SAML, key_info); let mut config = KeyResolverConfig::default(); config.named_keys.insert( "idp-signing".into(), VerificationKey { - algorithm: SignatureAlgorithm::EcdsaP256Sha256, + algorithm: SignatureAlgorithm::EcdsaSha256, public_key_bytes: public_key_der(SAML_PUBLIC_KEY), certificate_der: None, name: Some("idp-signing".into()), }, ); let resolver = DefaultKeyResolver::new(config); - let result = super::super::VerifyContext::new() + let error = super::super::VerifyContext::new() .key_resolver(&resolver) .verify(&xml) - .expect("later KeyName should resolve after mismatched ECKeyValue"); + .expect_err("a usable first key source must not fall through after verification"); - assert_eq!(result.status, super::super::DsigStatus::Valid); + assert!(matches!( + error, + DsigError::Crypto(super::super::SignatureVerificationError::InvalidSignatureFormat) + )); } #[test] @@ -2635,17 +2634,17 @@ mod tests { } #[test] - fn lone_mismatched_ec_curve_reports_algorithm_mismatch() { + fn lone_supported_ec_curve_reaches_signature_verification() { let key_info = r#"BO/yd/OZzDfjX4qivDY/vsUIuh6KWAxoxW5P4ukvwd+T6pVljWsX2UBJNNy5MdhTwB8e2YwB8kUbJwdsAS/XGi/fz8unFrs+lVlAgIs6s/xBYFbfUoRiAacD2SpVDe6XBA=="#; let xml = replace_key_info(SIGNED_SAML, key_info); let error = super::super::VerifyContext::new() .key_resolver(&DefaultKeyResolver::default()) .verify(&xml) - .expect_err("lone mismatched ECKeyValue should surface typed key error"); + .expect_err("a supported EC curve must reach signature verification"); assert!(matches!( error, - DsigError::KeyResolution(KeyResolutionError::AlgorithmMismatch) + DsigError::Crypto(super::super::SignatureVerificationError::InvalidSignatureFormat) )); } @@ -2709,7 +2708,7 @@ mod tests { config.named_keys.insert( "mislabeled".into(), VerificationKey { - algorithm: SignatureAlgorithm::EcdsaP256Sha256, + algorithm: SignatureAlgorithm::EcdsaSha256, public_key_bytes: public_key_der(RSA_PUBLIC_KEY), certificate_der: None, name: Some("mislabeled".into()), @@ -2738,7 +2737,7 @@ mod tests { config.named_keys.insert( "malformed".into(), VerificationKey { - algorithm: SignatureAlgorithm::EcdsaP256Sha256, + algorithm: SignatureAlgorithm::EcdsaSha256, public_key_bytes: vec![1, 2, 3], certificate_der: None, name: Some("malformed".into()), diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index f0113646..c1bd19d1 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -87,15 +87,10 @@ pub enum SignatureAlgorithm { RsaSha384, /// RSA with SHA-512. RsaSha512, - /// ECDSA P-256 with SHA-256. - EcdsaP256Sha256, - /// XMLDSig `ecdsa-sha384` URI. - /// - /// The variant name is historical. - /// - /// Verification currently accepts this XMLDSig URI for P-384 and for the - /// donor P-521 interop case. - EcdsaP384Sha384, + /// ECDSA with SHA-256; the key selects the elliptic curve. + EcdsaSha256, + /// ECDSA with SHA-384; the key selects the elliptic curve. + EcdsaSha384, } impl SignatureAlgorithm { @@ -109,8 +104,8 @@ impl SignatureAlgorithm { "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), "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512" => Some(Self::RsaSha512), - "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256" => Some(Self::EcdsaP256Sha256), - "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384" => Some(Self::EcdsaP384Sha384), + "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256" => Some(Self::EcdsaSha256), + "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384" => Some(Self::EcdsaSha384), _ => None, } } @@ -125,8 +120,8 @@ impl SignatureAlgorithm { Self::RsaSha256 => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", Self::RsaSha384 => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384", Self::RsaSha512 => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512", - Self::EcdsaP256Sha256 => "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256", - Self::EcdsaP384Sha384 => "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384", + Self::EcdsaSha256 => "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256", + Self::EcdsaSha384 => "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384", } } @@ -2236,7 +2231,7 @@ mod tests { fn signature_algorithm_from_uri_ecdsa_sha256() { assert_eq!( SignatureAlgorithm::from_uri("http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256"), - Some(SignatureAlgorithm::EcdsaP256Sha256) + Some(SignatureAlgorithm::EcdsaSha256) ); } @@ -2257,8 +2252,8 @@ mod tests { SignatureAlgorithm::RsaSha256, SignatureAlgorithm::RsaSha384, SignatureAlgorithm::RsaSha512, - SignatureAlgorithm::EcdsaP256Sha256, - SignatureAlgorithm::EcdsaP384Sha384, + SignatureAlgorithm::EcdsaSha256, + SignatureAlgorithm::EcdsaSha384, ] { assert_eq!( SignatureAlgorithm::from_uri(algo.uri()), @@ -2274,7 +2269,7 @@ mod tests { assert!(!SignatureAlgorithm::HmacSha1.signing_allowed()); assert!(!SignatureAlgorithm::RsaSha1.signing_allowed()); assert!(SignatureAlgorithm::RsaSha256.signing_allowed()); - assert!(SignatureAlgorithm::EcdsaP256Sha256.signing_allowed()); + assert!(SignatureAlgorithm::EcdsaSha256.signing_allowed()); } // ── find_signature_node ────────────────────────────────────────── @@ -4012,7 +4007,7 @@ BA== let doc = Document::parse(xml).unwrap(); let si = parse_signed_info(doc.root_element()).unwrap(); - assert_eq!(si.signature_method, SignatureAlgorithm::EcdsaP256Sha256); + assert_eq!(si.signature_method, SignatureAlgorithm::EcdsaSha256); assert_eq!(si.references.len(), 2); assert_eq!(si.references[0].uri.as_deref(), Some("#a")); assert_eq!(si.references[0].digest_method, DigestAlgorithm::Sha256); diff --git a/src/xmldsig/sign.rs b/src/xmldsig/sign.rs index c6db1c5c..0c4b193c 100644 --- a/src/xmldsig/sign.rs +++ b/src/xmldsig/sign.rs @@ -13,9 +13,10 @@ use roxmltree::{Document, Node}; use rsa::RsaPrivateKey; use rsa::pkcs1v15::Signature as RsaPkcs1v15Signature; use rsa::pkcs1v15::SigningKey as RsaPkcs1v15SigningKey; -use rsa::signature::{RandomizedSigner, SignatureEncoding, Signer}; +use rsa::signature::{RandomizedSigner, SignatureEncoding}; use rsa::traits::PublicKeyParts; -use sha2::{Sha256, Sha384, Sha512}; +use sha2::{Digest, Sha256, Sha384, Sha512}; +use signature::hazmat::PrehashSigner; use std::collections::HashSet; use x509_parser::prelude::FromDer; @@ -438,14 +439,18 @@ impl SigningKey for EcdsaP256SigningKey { algorithm: SignatureAlgorithm, canonical_signed_info: &[u8], ) -> Result, SigningKeyError> { - if algorithm != SignatureAlgorithm::EcdsaP256Sha256 { - return Err(SigningKeyError::UnsupportedAlgorithm { - uri: algorithm.uri().to_string(), - }); - } + let prehash = match algorithm { + SignatureAlgorithm::EcdsaSha256 => Sha256::digest(canonical_signed_info).to_vec(), + SignatureAlgorithm::EcdsaSha384 => Sha384::digest(canonical_signed_info).to_vec(), + _ => { + return Err(SigningKeyError::UnsupportedAlgorithm { + uri: algorithm.uri().to_string(), + }); + } + }; let signature: P256Signature = self .key - .try_sign(canonical_signed_info) + .sign_prehash(&prehash) .map_err(|_| SigningKeyError::SigningFailed)?; Ok(signature.to_bytes().to_vec()) } @@ -490,14 +495,18 @@ impl SigningKey for EcdsaP384SigningKey { algorithm: SignatureAlgorithm, canonical_signed_info: &[u8], ) -> Result, SigningKeyError> { - if algorithm != SignatureAlgorithm::EcdsaP384Sha384 { - return Err(SigningKeyError::UnsupportedAlgorithm { - uri: algorithm.uri().to_string(), - }); - } + let prehash = match algorithm { + SignatureAlgorithm::EcdsaSha256 => Sha256::digest(canonical_signed_info).to_vec(), + SignatureAlgorithm::EcdsaSha384 => Sha384::digest(canonical_signed_info).to_vec(), + _ => { + return Err(SigningKeyError::UnsupportedAlgorithm { + uri: algorithm.uri().to_string(), + }); + } + }; let signature: P384Signature = self .key - .try_sign(canonical_signed_info) + .sign_prehash(&prehash) .map_err(|_| SigningKeyError::SigningFailed)?; Ok(signature.to_bytes().to_vec()) } diff --git a/src/xmldsig/signature.rs b/src/xmldsig/signature.rs index 315dabdc..0981676c 100644 --- a/src/xmldsig/signature.rs +++ b/src/xmldsig/signature.rs @@ -10,7 +10,6 @@ //! - ECDSA keys are validated as uncompressed SEC1 points from the SPKI bit //! string and verified with RustCrypto curve crates (`p256`/`p384`/`p521`). -use p256::ecdsa::signature::Verifier as P256Verifier; use p256::ecdsa::{Signature as P256Signature, VerifyingKey as P256VerifyingKey}; use p384::ecdsa::{Signature as P384Signature, VerifyingKey as P384VerifyingKey}; use p521::ecdsa::{Signature as P521Signature, VerifyingKey as P521VerifyingKey}; @@ -19,6 +18,7 @@ use rsa::pkcs8::DecodePublicKey; use rsa::signature::hazmat::PrehashVerifier; use sha1::Sha1; use sha2::{Digest, Sha256, Sha384, Sha512}; +use signature::Verifier; use x509_parser::prelude::FromDer; use x509_parser::public_key::{ECPoint, PublicKey}; use x509_parser::x509::SubjectPublicKeyInfo; @@ -247,7 +247,7 @@ pub fn verify_ecdsa_signature_spki( ) -> Result { if !matches!( algorithm, - SignatureAlgorithm::EcdsaP256Sha256 | SignatureAlgorithm::EcdsaP384Sha384 + SignatureAlgorithm::EcdsaSha256 | SignatureAlgorithm::EcdsaSha384 ) { return Err(SignatureVerificationError::UnsupportedAlgorithm { uri: algorithm.uri().to_string(), @@ -266,25 +266,30 @@ pub fn verify_ecdsa_signature_spki( match public_key { PublicKey::EC(ec) => { validate_ec_public_key_encoding(&ec, &spki.subject_public_key.data)?; - let (curve, component_len) = ecdsa_curve_and_component_len(&spki, &ec, algorithm)?; + let (curve, component_len) = ecdsa_curve_and_component_len(&spki, &ec)?; let signature_encoding = classify_ecdsa_signature_encoding(signature_value, component_len)?; + let prehash = match algorithm { + SignatureAlgorithm::EcdsaSha256 => Sha256::digest(signed_data).to_vec(), + SignatureAlgorithm::EcdsaSha384 => Sha384::digest(signed_data).to_vec(), + _ => unreachable!("ECDSA algorithm was validated above"), + }; match curve { - EcCurve::P256 => verify_ecdsa_p256_sha256( + EcCurve::P256 => verify_ecdsa_p256( &spki.subject_public_key.data, - signed_data, + &prehash, signature_value, signature_encoding, ), - EcCurve::P384 => verify_ecdsa_p384_sha384( + EcCurve::P384 => verify_ecdsa_p384( &spki.subject_public_key.data, - signed_data, + &prehash, signature_value, signature_encoding, ), - EcCurve::P521 => verify_ecdsa_p521_sha384( + EcCurve::P521 => verify_ecdsa_p521( &spki.subject_public_key.data, - signed_data, + &prehash, signature_value, signature_encoding, ), @@ -356,7 +361,6 @@ enum EcCurve { fn ecdsa_curve_and_component_len( spki: &SubjectPublicKeyInfo<'_>, ec: &ECPoint<'_>, - algorithm: SignatureAlgorithm, ) -> Result<(EcCurve, usize), SignatureVerificationError> { let curve_oid = spki .algorithm @@ -367,98 +371,75 @@ fn ecdsa_curve_and_component_len( let point_len = ec.key_size(); let curve_oid = curve_oid.to_id_string(); - match algorithm { - SignatureAlgorithm::EcdsaP256Sha256 => { - if curve_oid == "1.2.840.10045.3.1.7" && point_len == 256 { - Ok((EcCurve::P256, 32)) - } else { - Err(SignatureVerificationError::KeyAlgorithmMismatch { - uri: algorithm.uri().to_string(), - }) - } - } - SignatureAlgorithm::EcdsaP384Sha384 => { - if curve_oid == "1.3.132.0.34" && point_len == 384 { - Ok((EcCurve::P384, 48)) - // XMLDSig `ecdsa-sha384` identifies the digest/signature method URI, - // not a single curve. For interop we accept secp521r1 donor vectors; - // x509-parser reports ECPoint::key_size() as byte-aligned bits (528) - // for P-521 uncompressed points, so allow both exact and aligned size. - } else if curve_oid == "1.3.132.0.35" && matches!(point_len, 521 | 528) { - Ok((EcCurve::P521, 66)) - } else { - Err(SignatureVerificationError::KeyAlgorithmMismatch { - uri: algorithm.uri().to_string(), - }) - } - } - _ => Err(SignatureVerificationError::UnsupportedAlgorithm { - uri: algorithm.uri().to_string(), - }), + match (curve_oid.as_str(), point_len) { + ("1.2.840.10045.3.1.7", 256) => Ok((EcCurve::P256, 32)), + ("1.3.132.0.34", 384) => Ok((EcCurve::P384, 48)), + // x509-parser reports the byte-aligned SEC1 point size for P-521. + ("1.3.132.0.35", 521 | 528) => Ok((EcCurve::P521, 66)), + _ => Err(SignatureVerificationError::InvalidKeyDer), } } -fn verify_ecdsa_p256_sha256( +fn verify_ecdsa_p256( public_key: &[u8], - signed_data: &[u8], + prehash: &[u8], signature_value: &[u8], signature_encoding: EcdsaSignatureEncoding, ) -> Result { let key = P256VerifyingKey::from_sec1_bytes(public_key) .map_err(|_| SignatureVerificationError::InvalidKeyDer)?; - verify_p256_signature(&key, signature_value, signature_encoding, signed_data) + verify_p256_signature(&key, signature_value, signature_encoding, prehash) } -fn verify_ecdsa_p384_sha384( +fn verify_ecdsa_p384( public_key: &[u8], - signed_data: &[u8], + prehash: &[u8], signature_value: &[u8], signature_encoding: EcdsaSignatureEncoding, ) -> Result { let key = P384VerifyingKey::from_sec1_bytes(public_key) .map_err(|_| SignatureVerificationError::InvalidKeyDer)?; - verify_p384_signature(&key, signature_value, signature_encoding, signed_data) + verify_p384_signature(&key, signature_value, signature_encoding, prehash) } -fn verify_ecdsa_p521_sha384( +fn verify_ecdsa_p521( public_key: &[u8], - signed_data: &[u8], + prehash: &[u8], signature_value: &[u8], signature_encoding: EcdsaSignatureEncoding, ) -> Result { let key = P521VerifyingKey::from_sec1_bytes(public_key) .map_err(|_| SignatureVerificationError::InvalidKeyDer)?; - let prehash = Sha384::digest(signed_data); - verify_p521_signature(&key, signature_value, signature_encoding, &prehash) + verify_p521_signature(&key, signature_value, signature_encoding, prehash) } fn verify_p256_signature( key: &P256VerifyingKey, signature_value: &[u8], signature_encoding: EcdsaSignatureEncoding, - signed_data: &[u8], + prehash: &[u8], ) -> Result { match signature_encoding { EcdsaSignatureEncoding::XmlDsigFixed => { let signature = P256Signature::from_slice(signature_value) .map_err(|_| SignatureVerificationError::InvalidSignatureFormat)?; - Ok(key.verify(signed_data, &signature).is_ok()) + Ok(key.verify_prehash(prehash, &signature).is_ok()) } EcdsaSignatureEncoding::Asn1Der => { let signature = P256Signature::from_der(signature_value) .map_err(|_| SignatureVerificationError::InvalidSignatureFormat)?; - Ok(key.verify(signed_data, &signature).is_ok()) + Ok(key.verify_prehash(prehash, &signature).is_ok()) } EcdsaSignatureEncoding::Ambiguous => { if let Ok(signature) = P256Signature::from_der(signature_value) - && key.verify(signed_data, &signature).is_ok() + && key.verify_prehash(prehash, &signature).is_ok() { return Ok(true); } let signature = P256Signature::from_slice(signature_value) .map_err(|_| SignatureVerificationError::InvalidSignatureFormat)?; - Ok(key.verify(signed_data, &signature).is_ok()) + Ok(key.verify_prehash(prehash, &signature).is_ok()) } } } @@ -467,29 +448,29 @@ fn verify_p384_signature( key: &P384VerifyingKey, signature_value: &[u8], signature_encoding: EcdsaSignatureEncoding, - signed_data: &[u8], + prehash: &[u8], ) -> Result { match signature_encoding { EcdsaSignatureEncoding::XmlDsigFixed => { let signature = P384Signature::from_slice(signature_value) .map_err(|_| SignatureVerificationError::InvalidSignatureFormat)?; - Ok(key.verify(signed_data, &signature).is_ok()) + Ok(key.verify_prehash(prehash, &signature).is_ok()) } EcdsaSignatureEncoding::Asn1Der => { let signature = P384Signature::from_der(signature_value) .map_err(|_| SignatureVerificationError::InvalidSignatureFormat)?; - Ok(key.verify(signed_data, &signature).is_ok()) + Ok(key.verify_prehash(prehash, &signature).is_ok()) } EcdsaSignatureEncoding::Ambiguous => { if let Ok(signature) = P384Signature::from_der(signature_value) - && key.verify(signed_data, &signature).is_ok() + && key.verify_prehash(prehash, &signature).is_ok() { return Ok(true); } let signature = P384Signature::from_slice(signature_value) .map_err(|_| SignatureVerificationError::InvalidSignatureFormat)?; - Ok(key.verify(signed_data, &signature).is_ok()) + Ok(key.verify_prehash(prehash, &signature).is_ok()) } } } @@ -690,8 +671,8 @@ mod tests { #[test] fn ecdsa_algorithms_are_rejected_for_rsa_verification() { for algorithm in [ - SignatureAlgorithm::EcdsaP256Sha256, - SignatureAlgorithm::EcdsaP384Sha384, + SignatureAlgorithm::EcdsaSha256, + SignatureAlgorithm::EcdsaSha384, ] { let err = ensure_rsa_signature_algorithm(algorithm).unwrap_err(); assert!(matches!( diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index 5523ee07..11f59672 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -2001,7 +2001,7 @@ fn verify_with_algorithm( signed_data, signature_value, )?), - SignatureAlgorithm::EcdsaP256Sha256 | SignatureAlgorithm::EcdsaP384Sha384 => { + SignatureAlgorithm::EcdsaSha256 | SignatureAlgorithm::EcdsaSha384 => { // Malformed ECDSA signature bytes are treated as a verification miss // (Ok(false)) instead of a pipeline error; only key/algorithm and // crypto-operation failures propagate as Err. diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index d50c7efd..df31ec9d 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -361,8 +361,8 @@ fn x509_signature_algorithm(oid: &str) -> Option { "1.2.840.113549.1.1.11" => Some(SignatureAlgorithm::RsaSha256), "1.2.840.113549.1.1.12" => Some(SignatureAlgorithm::RsaSha384), "1.2.840.113549.1.1.13" => Some(SignatureAlgorithm::RsaSha512), - "1.2.840.10045.4.3.2" => Some(SignatureAlgorithm::EcdsaP256Sha256), - "1.2.840.10045.4.3.3" => Some(SignatureAlgorithm::EcdsaP384Sha384), + "1.2.840.10045.4.3.2" => Some(SignatureAlgorithm::EcdsaSha256), + "1.2.840.10045.4.3.3" => Some(SignatureAlgorithm::EcdsaSha384), _ => None, } } @@ -516,9 +516,61 @@ fn verify_crls( mod tests { use super::*; use crate::xmldsig::{KeyInfoSource, parse::XMLDSIG_NS, parse_key_info}; + use p256::pkcs8::EncodePublicKey; use roxmltree::Document; + use sha2::{Digest, Sha256, Sha384}; + use signature::hazmat::PrehashSigner; use std::time::Duration; + #[test] + fn x509_ecdsa_hash_oid_does_not_select_the_issuer_curve() { + // RFC 5758 signature OIDs select the digest while SubjectPublicKeyInfo + // selects the curve. Both non-default pairings must therefore reach + // the provider with the issuer's actual curve rather than a curve + // inferred from the hash OID. + let data = b"certificate tbs bytes"; + + let p384_key = p384::ecdsa::SigningKey::from_slice(&[0x42; 48]) + .expect("fixed P-384 test key must be valid"); + let p384_signature: p384::ecdsa::Signature = p384_key + .sign_prehash(&Sha256::digest(data)) + .expect("P-384 must sign a SHA-256 prehash"); + let p384_spki = p384_key + .verifying_key() + .to_public_key_der() + .expect("P-384 SPKI must encode"); + assert!( + verify_x509_signature_with_provider( + "1.2.840.10045.4.3.2", + p384_signature.to_der().as_bytes(), + data, + p384_spki.as_bytes(), + crate::provider::default_provider(), + ) + .expect("P-384 with SHA-256 must be a supported X.509 pairing") + ); + + let p256_key = p256::ecdsa::SigningKey::from_slice(&[0x24; 32]) + .expect("fixed P-256 test key must be valid"); + let p256_signature: p256::ecdsa::Signature = p256_key + .sign_prehash(&Sha384::digest(data)) + .expect("P-256 must sign a SHA-384 prehash"); + let p256_spki = p256_key + .verifying_key() + .to_public_key_der() + .expect("P-256 SPKI must encode"); + assert!( + verify_x509_signature_with_provider( + "1.2.840.10045.4.3.3", + p256_signature.to_der().as_bytes(), + data, + p256_spki.as_bytes(), + crate::provider::default_provider(), + ) + .expect("P-256 with SHA-384 must be a supported X.509 pairing") + ); + } + #[test] fn path_edge_signature_check_does_not_repeat_name_matching() { // Path construction performs RFC 5280 name matching before asking this diff --git a/tests/donor_full_verification_suite.rs b/tests/donor_full_verification_suite.rs index 3d0fba61..abc32f31 100644 --- a/tests/donor_full_verification_suite.rs +++ b/tests/donor_full_verification_suite.rs @@ -84,7 +84,7 @@ fn cases() -> Vec { expectation: Expectation::Named { key_name: "TestKeyName-ec-prime256v1", key_path: "tests/fixtures/keys/ec/ec-prime256v1-pubkey.pem", - algorithm: SignatureAlgorithm::EcdsaP256Sha256, + algorithm: SignatureAlgorithm::EcdsaSha256, }, }, VectorCase { @@ -93,7 +93,7 @@ fn cases() -> Vec { expectation: Expectation::Named { key_name: "TestKeyName-ec-prime521v1", key_path: "tests/fixtures/keys/ec/ec-prime521v1-pubkey.pem", - algorithm: SignatureAlgorithm::EcdsaP384Sha384, + algorithm: SignatureAlgorithm::EcdsaSha384, }, }, VectorCase { diff --git a/tests/donor_interop_suite.rs b/tests/donor_interop_suite.rs index 61fa532b..c729918c 100644 --- a/tests/donor_interop_suite.rs +++ b/tests/donor_interop_suite.rs @@ -40,6 +40,8 @@ fn xmlsec11_expected(path: &Path) -> ExpectedOutcome { .unwrap_or_default(); match name { "signature-enveloping-p256_sha256.xml" + | "signature-enveloping-p256_sha384.xml" + | "signature-enveloping-p384_sha256.xml" | "signature-enveloping-p384_sha384.xml" | "signature-enveloping-derencoded-ec.xml" => ExpectedOutcome::Valid, name if name.contains("hmac") => ExpectedOutcome::Unsupported("HMAC signature method"), @@ -119,10 +121,10 @@ fn donor_interop_vectors_have_explicit_pass_or_fail_closed_accounting() { } } - assert_eq!(valid, 3, "all supported ECKeyValue vectors must verify"); + assert_eq!(valid, 5, "all supported ECKeyValue vectors must verify"); assert_eq!( unsupported.len(), - 51, + 49, "all remaining vectors must be accounted for" ); } diff --git a/tests/ecdsa_signature_integration.rs b/tests/ecdsa_signature_integration.rs index b39fb5a3..55eabc15 100644 --- a/tests/ecdsa_signature_integration.rs +++ b/tests/ecdsa_signature_integration.rs @@ -86,7 +86,7 @@ fn donor_ecdsa_p256_signature_matches() { assert_donor_signature_valid( Path::new("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloped-sha256-ecdsa-sha256.xml"), Path::new("tests/fixtures/keys/ec/ec-prime256v1-pubkey.pem"), - SignatureAlgorithm::EcdsaP256Sha256, + SignatureAlgorithm::EcdsaSha256, ); } @@ -100,9 +100,9 @@ fn local_p384_signature_matches() { let (signature_algorithm, canonical_signed_info, _) = canonicalized_signed_info_and_signature(&xml); assert_eq!( - SignatureAlgorithm::EcdsaP384Sha384, + SignatureAlgorithm::EcdsaSha384, signature_algorithm, - "fixture SignatureMethod should be EcdsaP384Sha384", + "fixture SignatureMethod should be EcdsaSha384", ); let pkcs8_der = x509_parser::pem::parse_x509_pem(private_key_pem.as_bytes()) @@ -115,7 +115,7 @@ fn local_p384_signature_matches() { let signature_bytes = signature.to_bytes(); let valid = verify_ecdsa_signature_pem( - SignatureAlgorithm::EcdsaP384Sha384, + SignatureAlgorithm::EcdsaSha384, &public_key_pem, &canonical_signed_info, signature_bytes.as_ref(), @@ -135,9 +135,9 @@ fn local_p384_der_signature_matches() { let (signature_algorithm, canonical_signed_info, _) = canonicalized_signed_info_and_signature(&xml); assert_eq!( - SignatureAlgorithm::EcdsaP384Sha384, + SignatureAlgorithm::EcdsaSha384, signature_algorithm, - "fixture SignatureMethod should be EcdsaP384Sha384", + "fixture SignatureMethod should be EcdsaSha384", ); let pkcs8_der = x509_parser::pem::parse_x509_pem(private_key_pem.as_bytes()) @@ -150,7 +150,7 @@ fn local_p384_der_signature_matches() { let signature_der = signature.to_der(); let valid = verify_ecdsa_signature_pem( - SignatureAlgorithm::EcdsaP384Sha384, + SignatureAlgorithm::EcdsaSha384, &public_key_pem, &canonical_signed_info, signature_der.as_bytes(), @@ -189,7 +189,7 @@ fn tampered_signed_info_fails_verification() { } #[test] -fn curve_mismatched_public_key_returns_typed_key_error() { +fn different_curve_key_with_wrong_signature_width_returns_format_error() { let xml = read_fixture(Path::new( "tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloped-sha256-ecdsa-sha256.xml", )); @@ -197,24 +197,24 @@ fn curve_mismatched_public_key_returns_typed_key_error() { let (algorithm, canonical_signed_info, signature_value) = canonicalized_signed_info_and_signature(&xml); - let err = verify_ecdsa_signature_pem( + let error = verify_ecdsa_signature_pem( algorithm, &public_key_pem, &canonical_signed_info, &signature_value, ) - .expect_err("curve-mismatched EC key should be rejected before verification"); + .expect_err("the P-256 SignatureValue width is invalid for a P-384 key"); assert!(matches!( - err, - SignatureVerificationError::KeyAlgorithmMismatch { .. } + error, + SignatureVerificationError::InvalidSignatureFormat )); } #[test] fn non_public_key_pem_returns_invalid_key_format() { let err = verify_ecdsa_signature_pem( - SignatureAlgorithm::EcdsaP256Sha256, + SignatureAlgorithm::EcdsaSha256, "-----BEGIN CERTIFICATE-----\nZm9v\n-----END CERTIFICATE-----\n", b"payload", &[0_u8; 64], @@ -230,7 +230,7 @@ fn non_public_key_pem_returns_invalid_key_format() { #[test] fn malformed_pem_returns_typed_error() { let err = verify_ecdsa_signature_pem( - SignatureAlgorithm::EcdsaP256Sha256, + SignatureAlgorithm::EcdsaSha256, "-----BEGIN PUBLIC KEY-----\n%%%%\n-----END PUBLIC KEY-----\n", b"payload", &[0_u8; 64], @@ -248,7 +248,7 @@ fn pem_with_trailing_garbage_returns_typed_error() { ); let err = verify_ecdsa_signature_pem( - SignatureAlgorithm::EcdsaP256Sha256, + SignatureAlgorithm::EcdsaSha256, &public_key_pem, b"payload", &[0_u8; 64], @@ -261,7 +261,7 @@ fn pem_with_trailing_garbage_returns_typed_error() { #[test] fn malformed_spki_der_returns_typed_error() { let err = verify_ecdsa_signature_spki( - SignatureAlgorithm::EcdsaP256Sha256, + SignatureAlgorithm::EcdsaSha256, &[0x01, 0x02, 0x03], b"payload", &[0_u8; 64], @@ -284,7 +284,7 @@ fn non_ec_spki_key_returns_algorithm_mismatch_error() { .contents; let err = verify_ecdsa_signature_spki( - SignatureAlgorithm::EcdsaP256Sha256, + SignatureAlgorithm::EcdsaSha256, &public_key_der, b"payload", &[0_u8; 64], @@ -390,7 +390,7 @@ fn signature_with_wrong_length_returns_typed_error() { let public_key_pem = read_fixture(Path::new("tests/fixtures/keys/ec/ec-prime256v1-pubkey.pem")); let err = verify_ecdsa_signature_pem( - SignatureAlgorithm::EcdsaP256Sha256, + SignatureAlgorithm::EcdsaSha256, &public_key_pem, b"payload", &[0_u8; 63], @@ -414,7 +414,7 @@ fn malformed_der_signature_with_non_raw_length_returns_typed_error() { }; let err = verify_ecdsa_signature_pem( - SignatureAlgorithm::EcdsaP384Sha384, + SignatureAlgorithm::EcdsaSha384, &public_key_pem, b"payload", &malformed_der_signature, @@ -438,7 +438,7 @@ fn spki_der_with_trailing_garbage_returns_typed_error() { public_key_der.extend_from_slice(b"TRAILING"); let err = verify_ecdsa_signature_spki( - SignatureAlgorithm::EcdsaP256Sha256, + SignatureAlgorithm::EcdsaSha256, &public_key_der, b"payload", &[0_u8; 64], diff --git a/tests/signature_builder.rs b/tests/signature_builder.rs index c85f4f8b..f7451859 100644 --- a/tests/signature_builder.rs +++ b/tests/signature_builder.rs @@ -79,7 +79,7 @@ fn builds_parseable_prefixed_template_in_required_order() { #[test] fn preserves_reference_order_and_default_namespace() { // Reference order is signed data and must never be normalized or sorted. - let xml = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::EcdsaP256Sha256) + let xml = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::EcdsaSha256) .add_reference(ReferenceBuilder::new(DigestAlgorithm::Sha384).uri("#first")) .add_reference(ReferenceBuilder::new(DigestAlgorithm::Sha512).uri("#second")) .build_template() diff --git a/tests/signing_digest.rs b/tests/signing_digest.rs index 00cac1ee..7193f520 100644 --- a/tests/signing_digest.rs +++ b/tests/signing_digest.rs @@ -141,24 +141,15 @@ fn signing_keys_reject_unsupported_signature_algorithms() { "tests/fixtures/keys/ec/ec-prime256v1-key.pem", )) .expect("P-256 private key fixture must parse"); - let p384_key = EcdsaP384SigningKey::from_pkcs8_pem(&read_fixture( - "tests/fixtures/keys/ec/ec-prime384v1-key.pem", - )) - .expect("P-384 private key fixture must parse"); - for (result, expected_uri) in [ ( - rsa_key.sign(SignatureAlgorithm::EcdsaP256Sha256, b"signed-info"), - SignatureAlgorithm::EcdsaP256Sha256.uri(), + rsa_key.sign(SignatureAlgorithm::EcdsaSha256, b"signed-info"), + SignatureAlgorithm::EcdsaSha256.uri(), ), ( p256_key.sign(SignatureAlgorithm::RsaSha256, b"signed-info"), SignatureAlgorithm::RsaSha256.uri(), ), - ( - p384_key.sign(SignatureAlgorithm::EcdsaP256Sha256, b"signed-info"), - SignatureAlgorithm::EcdsaP256Sha256.uri(), - ), ] { assert!(matches!( result, @@ -872,7 +863,7 @@ fn signs_ecdsa_p256_template_and_verifies_round_trip() { )) .expect("P-256 private key fixture must parse"); let public_key_pem = read_fixture("tests/fixtures/keys/ec/ec-prime256v1-pubkey.pem"); - let builder = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::EcdsaP256Sha256) + let builder = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::EcdsaSha256) .add_reference( ReferenceBuilder::new(DigestAlgorithm::Sha256) .uri("#payload") @@ -902,7 +893,7 @@ fn signs_ecdsa_p384_template_and_verifies_round_trip() { )) .expect("P-384 private key fixture must parse"); let public_key_pem = read_fixture("tests/fixtures/keys/ec/ec-prime384v1-pubkey.pem"); - let builder = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::EcdsaP384Sha384) + let builder = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::EcdsaSha384) .add_reference( ReferenceBuilder::new(DigestAlgorithm::Sha384) .uri("#payload") @@ -923,6 +914,52 @@ fn signs_ecdsa_p384_template_and_verifies_round_trip() { assert!(!signed.contains("")); } +#[test] +fn signs_ecdsa_with_digest_independent_of_curve() { + // XMLDSig SignatureMethod selects the hash; the signing key selects the + // curve and SignatureValue width. Exercise both non-default pairings + // through the complete template, signing, and verification pipeline. + let p256_key = EcdsaP256SigningKey::from_pkcs8_pem(&read_fixture( + "tests/fixtures/keys/ec/ec-prime256v1-key.pem", + )) + .expect("P-256 private key fixture must parse"); + let p384_key = EcdsaP384SigningKey::from_pkcs8_pem(&read_fixture( + "tests/fixtures/keys/ec/ec-prime384v1-key.pem", + )) + .expect("P-384 private key fixture must parse"); + let cases: [(&dyn SigningKey, SignatureAlgorithm, &str); 2] = [ + ( + &p384_key, + SignatureAlgorithm::EcdsaSha256, + "tests/fixtures/keys/ec/ec-prime384v1-pubkey.pem", + ), + ( + &p256_key, + SignatureAlgorithm::EcdsaSha384, + "tests/fixtures/keys/ec/ec-prime256v1-pubkey.pem", + ), + ]; + + for (key, algorithm, public_key_path) in cases { + let builder = SignatureBuilder::new(exclusive_c14n(), algorithm).add_reference( + ReferenceBuilder::new(DigestAlgorithm::Sha256) + .uri("#payload") + .transform(Transform::C14n(exclusive_c14n())), + ); + let signed = SignContext::new(key) + .sign_with_builder( + "hello", + &builder, + ) + .expect("curve-independent ECDSA signing must succeed"); + let public_key_pem = read_fixture(public_key_path); + let result = verify_signature_with_pem_key(&signed, &public_key_pem, true) + .expect("curve-independent ECDSA verification must run"); + + assert_eq!(result.status, DsigStatus::Valid, "{}", algorithm.uri()); + } +} + #[test] fn signs_rsa_donor_templates_and_verifies_round_trip() { // These are xmlsec1's supported enveloping signing templates. They exercise diff --git a/tests/xmlsec1_interop.rs b/tests/xmlsec1_interop.rs index a4524710..1f984c0f 100644 --- a/tests/xmlsec1_interop.rs +++ b/tests/xmlsec1_interop.rs @@ -458,7 +458,7 @@ fn xmlsec1_verifies_ecdsa_signatures_from_xml_sec() { let p256_signed = signed_payload_xml( &p256_key, - &signing_builder(SignatureAlgorithm::EcdsaP256Sha256, DigestAlgorithm::Sha256), + &signing_builder(SignatureAlgorithm::EcdsaSha256, DigestAlgorithm::Sha256), ); assert_xmlsec1_accepts( &p256_signed, @@ -467,7 +467,7 @@ fn xmlsec1_verifies_ecdsa_signatures_from_xml_sec() { let p384_signed = signed_payload_xml( &p384_key, - &signing_builder(SignatureAlgorithm::EcdsaP384Sha384, DigestAlgorithm::Sha384), + &signing_builder(SignatureAlgorithm::EcdsaSha384, DigestAlgorithm::Sha384), ); assert_xmlsec1_accepts( &p384_signed, From 37ad953fbb53e28b73b5d856b70cbe2c11484a01 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 9 Aug 2026 15:04:01 +0300 Subject: [PATCH 36/63] fix(xmldsig): preserve provider invariants - route ECDSA prehashing through the selected provider\n- preserve typed X.509 RSA-PSS and Ed25519 verification\n- share canonicalization work across the verification pipeline --- Cargo.toml | 2 + README.md | 4 +- docs/xmldsig.md | 19 +- src/provider.rs | 415 +++++++++++++++++++++++++++++++++++++++ src/xmldsig/keys.rs | 34 +++- src/xmldsig/parse.rs | 12 ++ src/xmldsig/sign.rs | 42 +++- src/xmldsig/signature.rs | 2 +- src/xmldsig/verify.rs | 44 ++++- src/xmldsig/x509.rs | 211 +++++++++++++++----- 10 files changed, 720 insertions(+), 65 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7cfe8722..5ea805ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ 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 } +ed25519-dalek = { version = "3", features = ["pkcs8"], optional = true } hmac = { version = "0.13", optional = true } signature = { version = "3", optional = true } subtle = { version = "2", optional = true } @@ -64,6 +65,7 @@ xmldsig = [ # XML Digital Signatures (sign + verify) "dep:der", "dep:crypto-bigint", "dep:dsa", + "dep:ed25519-dalek", "dep:getrandom", "dep:hmac", "dep:p256", diff --git a/README.md b/README.md index 93b63e1a..994f468d 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Currently implemented (core paths): - ECDSA SHA-256/SHA-384 verification for P-256, P-384, and P-521 keys - Legacy DSA-SHA1 and HMAC-SHA1 verification, including truncated HMAC output - RSA PKCS#1 v1.5 and ECDSA SHA-256/SHA-384 signing with P-256/P-384 PKCS#8 keys -- Opt-in X.509 certificate-chain validation with explicit trust anchors, validity checks, CA constraints, and authenticated CRLs +- Opt-in X.509 certificate-chain validation with explicit trust anchors, validity checks, CA constraints, authenticated CRLs, and typed RSA-PSS/Ed25519 certificate-signature support - Caller-supplied external references and X.509 `RetrievalMethod` resolution with bounded RFC 3986 `xml:base` processing and no 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 @@ -56,7 +56,7 @@ Currently implemented (core paths): padding details, but CBC remains unauthenticated and can be excluded by policy Still in progress: -- XMLDSig DSA-SHA256, broader HMAC verification/signing, and RSA-PSS algorithms +- XMLDSig DSA-SHA256, broader HMAC verification/signing, and RSA-PSS `SignatureMethod` algorithms - Complete XMLDSig and XMLEnc conformance-suite classification - Expanded fuzz coverage, benchmarks, production hardening, and API stabilization diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 2d6f38ac..5354b409 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -33,15 +33,19 @@ validation in `sign_with_builder`, digest filling, `SignedInfo` parsing, signatu optional `KeyInfo` filling. An internal-DTD opt-in and XML node ceiling therefore cannot be lost between stages. -`SignContext::provider` selects both digest primitives and operation randomness. Built-in RSA -signing routes its blinding randomness through that provider rather than acquiring operating-system -randomness behind the provider boundary. Custom `SigningKey` implementations whose primitive uses -randomness must implement `SigningKey::sign_with_provider`; deterministic and externally managed -keys can use the default implementation. +`SignContext::provider` selects both digest primitives and operation randomness. Built-in ECDSA +signing obtains its prehash from that provider, while built-in RSA signing routes its blinding +randomness through the provider rather than acquiring operating-system randomness behind the +boundary. Custom `SigningKey` implementations that hash or use randomness inside their primitive +must implement `SigningKey::sign_with_provider`; externally managed keys can use the default +implementation only when the provider has no primitive work to observe. `VerifyContext::provider` covers every verification-time cryptographic operation, including reference digests, document signatures, `X509Digest` selector evaluation, X.509 candidate-path edges, complete certificate paths, and CRL authentication performed by `DefaultKeyResolver`. +Certificate authentication uses a separate typed algorithm contract so RSA-PSS parameters and +Ed25519 are not collapsed into the narrower XMLDSig `SignatureMethod` enum. Unsupported certificate +OIDs remain typed path errors rather than ordinary signature mismatches. Custom resolvers that evaluate cryptographic key metadata should override `KeyResolver::resolve_with_policy_and_provider`; source-only resolvers can retain the default hook. @@ -123,5 +127,6 @@ verification selects P-256, P-384, or P-521 from the SPKI independently of the h the built-in P-256 and P-384 signing keys support either ECDSA hash identifier. 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. +X.509 path and CRL authentication additionally supports standard RSA-PSS with SHA-256/SHA-384/ +SHA-512 parameters and Ed25519. DSA-SHA256, broader HMAC verification/signing, XMLDSig +`SignatureMethod` RSA-PSS, and implicit external resource loading are not currently supported. diff --git a/src/provider.rs b/src/provider.rs index efe1e5d5..2fc814b2 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -26,6 +26,8 @@ pub enum ProviderOperation { Sign, /// Public-key signature verification. Verify, + /// X.509 certificate or CRL signature verification. + VerifyCertificate, /// Authenticated or padded symmetric encryption. Encrypt, /// Authenticated or padded symmetric decryption. @@ -53,6 +55,54 @@ pub struct CapabilityQuery<'a> { pub algorithm: Option<&'a str>, } +/// Provider-neutral X.509 certificate and CRL signature parameters. +#[cfg(feature = "xmldsig")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum X509SignatureAlgorithm { + /// DSA with the selected message digest. + Dsa(DigestAlgorithm), + /// RSASSA-PKCS1-v1_5 with the selected message digest. + RsaPkcs1v15(DigestAlgorithm), + /// RSASSA-PSS with explicit RFC 4055 parameters. + RsaPss { + /// Message digest applied to the signed certificate data. + digest: DigestAlgorithm, + /// Digest used by MGF1. + mgf_digest: DigestAlgorithm, + /// Salt length in octets. + salt_len: usize, + }, + /// ECDSA with the selected message digest; SPKI selects the curve. + Ecdsa(DigestAlgorithm), + /// Pure Ed25519 as specified by RFC 8410. + Ed25519, +} + +#[cfg(feature = "xmldsig")] +impl X509SignatureAlgorithm { + /// Return the standard AlgorithmIdentifier OID used for capability queries. + #[must_use] + pub const fn oid(self) -> &'static str { + match self { + Self::Dsa(DigestAlgorithm::Sha1) => "1.2.840.10040.4.3", + Self::Dsa(DigestAlgorithm::Sha256) => "2.16.840.1.101.3.4.3.2", + Self::Dsa(DigestAlgorithm::Sha384) => "2.16.840.1.101.3.4.3.3", + Self::Dsa(DigestAlgorithm::Sha512) => "2.16.840.1.101.3.4.3.4", + Self::RsaPkcs1v15(DigestAlgorithm::Sha1) => "1.2.840.113549.1.1.5", + Self::RsaPkcs1v15(DigestAlgorithm::Sha256) => "1.2.840.113549.1.1.11", + Self::RsaPkcs1v15(DigestAlgorithm::Sha384) => "1.2.840.113549.1.1.12", + Self::RsaPkcs1v15(DigestAlgorithm::Sha512) => "1.2.840.113549.1.1.13", + Self::RsaPss { .. } => "1.2.840.113549.1.1.10", + Self::Ecdsa(DigestAlgorithm::Sha1) => "1.2.840.10045.4.1", + Self::Ecdsa(DigestAlgorithm::Sha256) => "1.2.840.10045.4.3.2", + Self::Ecdsa(DigestAlgorithm::Sha384) => "1.2.840.10045.4.3.3", + Self::Ecdsa(DigestAlgorithm::Sha512) => "1.2.840.10045.4.3.4", + Self::Ed25519 => "1.3.101.112", + } + } +} + /// Structured invalid-input reasons returned by cryptographic providers. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[non_exhaustive] @@ -156,6 +206,22 @@ pub trait CryptoProvider: Send + Sync { signature: &[u8], ) -> Result; + /// Verify an X.509 certificate or CRL signature under its issuer SPKI. + #[cfg(feature = "xmldsig")] + fn verify_x509_signature( + &self, + algorithm: X509SignatureAlgorithm, + signed_data: &[u8], + signature: &[u8], + issuer_spki_der: &[u8], + ) -> Result { + let _ = (signed_data, signature, issuer_spki_der); + Err(ProviderError::Unsupported { + operation: ProviderOperation::VerifyCertificate, + algorithm: Some(algorithm.oid().to_owned()), + }) + } + /// Encrypt XMLEnc content bytes, including standard framing. #[cfg(feature = "xmlenc")] fn encrypt_data( @@ -297,6 +363,16 @@ impl CryptoProvider for RustCryptoProvider { false } } + ProviderOperation::VerifyCertificate => { + #[cfg(feature = "xmldsig")] + { + query.algorithm.is_none_or(is_supported_x509_signature_oid) + } + #[cfg(not(feature = "xmldsig"))] + { + false + } + } ProviderOperation::Encrypt | ProviderOperation::Decrypt => { #[cfg(feature = "xmlenc")] { @@ -379,6 +455,18 @@ impl CryptoProvider for RustCryptoProvider { key.verify(algorithm, data, signature) } + #[cfg(feature = "xmldsig")] + fn verify_x509_signature( + &self, + algorithm: X509SignatureAlgorithm, + signed_data: &[u8], + signature: &[u8], + issuer_spki_der: &[u8], + ) -> Result { + self.require(ProviderOperation::VerifyCertificate, Some(algorithm.oid()))?; + rustcrypto_x509::verify_signature(algorithm, signed_data, signature, issuer_spki_der) + } + #[cfg(feature = "xmlenc")] fn encrypt_data( &self, @@ -495,6 +583,204 @@ fn is_supported_signing_uri(algorithm: &str) -> bool { ) } +#[cfg(feature = "xmldsig")] +fn is_supported_x509_signature_oid(algorithm: &str) -> bool { + matches!( + algorithm, + "1.2.840.10040.4.3" + | "1.2.840.113549.1.1.5" + | "1.3.14.3.2.29" + | "1.2.840.113549.1.1.10" + | "1.2.840.113549.1.1.11" + | "1.2.840.113549.1.1.12" + | "1.2.840.113549.1.1.13" + | "1.2.840.10045.4.3.2" + | "1.2.840.10045.4.3.3" + | "1.3.101.112" + ) +} + +#[cfg(feature = "xmldsig")] +mod rustcrypto_x509 { + use der::Decode as _; + use ed25519_dalek::pkcs8::DecodePublicKey as _; + use rsa::{ + RsaPublicKey, + pkcs1::DecodeRsaPublicKey as _, + pss::{Signature as RsaPssSignature, VerifyingKey as RsaPssVerifyingKey}, + }; + use sha2::{Sha256, Sha384, Sha512}; + use signature::Verifier as _; + use x509_parser::prelude::FromDer as _; + + use super::{ProviderError, X509SignatureAlgorithm}; + use crate::xmldsig::{ + DigestAlgorithm, DsigError, SignatureAlgorithm, VerificationKey, VerifyingKey as _, + }; + + pub(super) fn verify_signature( + algorithm: X509SignatureAlgorithm, + signed_data: &[u8], + signature: &[u8], + issuer_spki_der: &[u8], + ) -> Result { + match algorithm { + X509SignatureAlgorithm::Dsa(DigestAlgorithm::Sha1) => { + let Some(signature) = dsa_der_to_xmldsig(signature) else { + return Ok(false); + }; + verify_xml_signature( + SignatureAlgorithm::DsaSha1, + signed_data, + &signature, + issuer_spki_der, + ) + } + X509SignatureAlgorithm::RsaPkcs1v15(digest) => { + let Some(algorithm) = rsa_pkcs1_algorithm(digest) else { + return unsupported(X509SignatureAlgorithm::RsaPkcs1v15(digest)); + }; + verify_xml_signature(algorithm, signed_data, signature, issuer_spki_der) + } + X509SignatureAlgorithm::Ecdsa(digest) => { + let Some(algorithm) = ecdsa_algorithm(digest) else { + return unsupported(X509SignatureAlgorithm::Ecdsa(digest)); + }; + verify_xml_signature(algorithm, signed_data, signature, issuer_spki_der) + } + X509SignatureAlgorithm::RsaPss { + digest, + mgf_digest, + salt_len, + } if digest == mgf_digest => { + verify_rsa_pss(digest, salt_len, signed_data, signature, issuer_spki_der) + } + X509SignatureAlgorithm::RsaPss { .. } => unsupported(algorithm), + X509SignatureAlgorithm::Ed25519 => { + let Ok(key) = ed25519_dalek::VerifyingKey::from_public_key_der(issuer_spki_der) + else { + return Ok(false); + }; + let Ok(signature) = ed25519_dalek::Signature::try_from(signature) else { + return Ok(false); + }; + Ok(key.verify_strict(signed_data, &signature).is_ok()) + } + _ => unsupported(algorithm), + } + } + + fn verify_xml_signature( + algorithm: SignatureAlgorithm, + signed_data: &[u8], + signature: &[u8], + issuer_spki_der: &[u8], + ) -> Result { + let key = VerificationKey { + algorithm, + public_key_bytes: issuer_spki_der.to_vec(), + certificate_der: None, + name: None, + }; + match key.verify(algorithm, signed_data, signature) { + Ok(verified) => Ok(verified), + Err(DsigError::Provider(error)) => Err(error), + Err(_) => Ok(false), + } + } + + fn verify_rsa_pss( + digest: DigestAlgorithm, + salt_len: usize, + signed_data: &[u8], + signature: &[u8], + issuer_spki_der: &[u8], + ) -> Result { + let Some(key) = rsa_public_key_from_spki(issuer_spki_der) else { + return Ok(false); + }; + let Ok(signature) = RsaPssSignature::try_from(signature) else { + return Ok(false); + }; + let verified = match digest { + DigestAlgorithm::Sha256 => { + RsaPssVerifyingKey::::new_with_salt_len(key, salt_len) + .verify(signed_data, &signature) + } + DigestAlgorithm::Sha384 => { + RsaPssVerifyingKey::::new_with_salt_len(key, salt_len) + .verify(signed_data, &signature) + } + DigestAlgorithm::Sha512 => { + RsaPssVerifyingKey::::new_with_salt_len(key, salt_len) + .verify(signed_data, &signature) + } + DigestAlgorithm::Sha1 => { + return unsupported(X509SignatureAlgorithm::RsaPss { + digest, + mgf_digest: digest, + salt_len, + }); + } + }; + Ok(verified.is_ok()) + } + + fn rsa_public_key_from_spki(spki_der: &[u8]) -> Option { + if let Ok(key) = RsaPublicKey::from_public_key_der(spki_der) { + return Some(key); + } + + // RFC 4055 permits id-RSASSA-PSS in SubjectPublicKeyInfo. The generic + // PKCS#8 decoder accepts rsaEncryption SPKIs, so extract the same + // PKCS#1 key payload explicitly when the container carries the PSS OID. + let (_, spki) = x509_parser::x509::SubjectPublicKeyInfo::from_der(spki_der).ok()?; + if spki.algorithm.algorithm.to_id_string() != "1.2.840.113549.1.1.10" { + return None; + } + RsaPublicKey::from_pkcs1_der(&spki.subject_public_key.data).ok() + } + + fn dsa_der_to_xmldsig(signature: &[u8]) -> Option> { + let signature = dsa::Signature::from_der(signature).ok()?; + let r = signature.r().to_be_bytes(); + let s = signature.s().to_be_bytes(); + let r = &r[r.iter().position(|byte| *byte != 0).unwrap_or(r.len())..]; + let s = &s[s.iter().position(|byte| *byte != 0).unwrap_or(s.len())..]; + if r.len() > 20 || s.len() > 20 { + return None; + } + let mut fixed = vec![0_u8; 40]; + fixed[20 - r.len()..20].copy_from_slice(r); + fixed[40 - s.len()..].copy_from_slice(s); + Some(fixed) + } + + const fn rsa_pkcs1_algorithm(digest: DigestAlgorithm) -> Option { + match digest { + DigestAlgorithm::Sha1 => Some(SignatureAlgorithm::RsaSha1), + DigestAlgorithm::Sha256 => Some(SignatureAlgorithm::RsaSha256), + DigestAlgorithm::Sha384 => Some(SignatureAlgorithm::RsaSha384), + DigestAlgorithm::Sha512 => Some(SignatureAlgorithm::RsaSha512), + } + } + + const fn ecdsa_algorithm(digest: DigestAlgorithm) -> Option { + match digest { + DigestAlgorithm::Sha256 => Some(SignatureAlgorithm::EcdsaSha256), + DigestAlgorithm::Sha384 => Some(SignatureAlgorithm::EcdsaSha384), + DigestAlgorithm::Sha1 | DigestAlgorithm::Sha512 => None, + } + } + + fn unsupported(algorithm: X509SignatureAlgorithm) -> Result { + Err(ProviderError::Unsupported { + operation: super::ProviderOperation::VerifyCertificate, + algorithm: Some(algorithm.oid().to_owned()), + }) + } +} + #[cfg(feature = "xmlenc")] fn is_supported_data_encryption_uri(algorithm: &str) -> bool { matches!( @@ -916,6 +1202,7 @@ mod tests { #[cfg(feature = "xmldsig")] struct CountingRandomProvider { random_calls: AtomicUsize, + reject_digest: Option, } #[cfg(feature = "xmldsig")] @@ -938,6 +1225,12 @@ mod tests { algorithm: DigestAlgorithm, data: &[u8], ) -> Result, ProviderError> { + if self.reject_digest == Some(algorithm) { + return Err(ProviderError::Unsupported { + operation: ProviderOperation::Digest, + algorithm: Some(algorithm.uri().to_owned()), + }); + } RUST_CRYPTO_PROVIDER.digest(algorithm, data) } @@ -1093,6 +1386,7 @@ mod tests { .expect("RSA fixture must parse"); let provider = CountingRandomProvider { random_calls: AtomicUsize::new(0), + reject_digest: None, }; let signature = provider @@ -1103,6 +1397,127 @@ mod tests { assert!(provider.random_calls.load(Ordering::Relaxed) > 0); } + #[cfg(feature = "xmldsig")] + #[test] + fn ecdsa_signing_uses_the_selected_providers_digest() { + use crate::xmldsig::{ + EcdsaP256SigningKey, EcdsaP384SigningKey, SignatureAlgorithm, SigningKeyError, + }; + + // SignatureMethod chooses the hash independently of the EC key curve. + // Both built-in ECDSA keys must therefore ask the selected provider for + // that digest instead of hashing behind the provider boundary. + let cases: [( + Box, + SignatureAlgorithm, + DigestAlgorithm, + ); 2] = [ + ( + Box::new( + EcdsaP256SigningKey::from_pkcs8_pem(include_str!( + "../tests/fixtures/keys/ec/ec-prime256v1-key.pem" + )) + .expect("P-256 fixture must parse"), + ), + SignatureAlgorithm::EcdsaSha384, + DigestAlgorithm::Sha384, + ), + ( + Box::new( + EcdsaP384SigningKey::from_pkcs8_pem(include_str!( + "../tests/fixtures/keys/ec/ec-prime384v1-key.pem" + )) + .expect("P-384 fixture must parse"), + ), + SignatureAlgorithm::EcdsaSha256, + DigestAlgorithm::Sha256, + ), + ]; + + for (key, signature_algorithm, digest_algorithm) in cases { + let provider = CountingRandomProvider { + random_calls: AtomicUsize::new(0), + reject_digest: Some(digest_algorithm), + }; + let error = provider + .sign(key.as_ref(), signature_algorithm, b"signed info") + .expect_err("provider digest rejection must stop ECDSA signing"); + + assert!(matches!( + error, + SigningKeyError::Provider(ProviderError::Unsupported { + operation: ProviderOperation::Digest, + algorithm: Some(ref uri), + }) if uri == digest_algorithm.uri() + )); + } + } + + #[cfg(feature = "xmldsig")] + #[test] + fn rustcrypto_provider_verifies_parameterized_rsa_pss_certificates() { + use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng}; + use rsa::{RsaPrivateKey, pkcs8::EncodePublicKey, pss::SigningKey as RsaPssSigningKey}; + use sha2::Sha256; + use signature::{RandomizedSigner, SignatureEncoding}; + + // X.509 RSASSA-PSS carries salt and MGF parameters that cannot be + // represented by the XMLDSig SignatureAlgorithm enum. + let mut rng = ChaCha20Rng::from_seed([0x5a; 32]); + let private_key = + RsaPrivateKey::new(&mut rng, 2048).expect("deterministic RSA key generation"); + let public_key = private_key + .to_public_key() + .to_public_key_der() + .expect("RSA public key must encode as SPKI"); + let signing_key = RsaPssSigningKey::::new_with_salt_len(private_key, 32); + let signed_data = b"certificate tbs bytes"; + let signature = signing_key + .try_sign_with_rng(&mut rng, signed_data) + .expect("RSA-PSS signing must succeed") + .to_vec(); + + assert!( + RUST_CRYPTO_PROVIDER + .verify_x509_signature( + X509SignatureAlgorithm::RsaPss { + digest: DigestAlgorithm::Sha256, + mgf_digest: DigestAlgorithm::Sha256, + salt_len: 32, + }, + signed_data, + &signature, + public_key.as_bytes(), + ) + .expect("standard RSA-PSS parameters must be supported") + ); + + let mut pss_spki = public_key.as_bytes().to_vec(); + let rsa_encryption_oid = [ + 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, + ]; + let oid_offset = pss_spki + .windows(rsa_encryption_oid.len()) + .position(|window| window == rsa_encryption_oid) + .expect("RSA SPKI must identify rsaEncryption"); + pss_spki[oid_offset + rsa_encryption_oid.len() - 1] = 0x0a; + + assert!( + RUST_CRYPTO_PROVIDER + .verify_x509_signature( + X509SignatureAlgorithm::RsaPss { + digest: DigestAlgorithm::Sha256, + mgf_digest: DigestAlgorithm::Sha256, + salt_len: 32, + }, + signed_data, + &signature, + &pss_spki, + ) + .expect("RFC 4055 PSS SubjectPublicKeyInfo must be supported") + ); + } + #[cfg(feature = "xmlenc")] #[test] fn legacy_oaep_mgf_constraint_is_symmetric() { diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index d9496763..7e0655fc 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -362,6 +362,11 @@ impl DefaultKeyResolver { X509ChainBuildError::Provider(error) => { KeyResolutionError::Chain(super::X509ChainError::Provider(error)) } + X509ChainBuildError::UnsupportedSignatureAlgorithm { oid } => { + KeyResolutionError::Chain(super::X509ChainError::UnsupportedSignatureAlgorithm { + oid, + }) + } _ => KeyResolutionError::InvalidCertificate, })?; let mut first_error = None; @@ -866,6 +871,33 @@ mod tests { crate::provider::default_provider().verify(key, algorithm, data, signature) } + fn verify_x509_signature( + &self, + algorithm: crate::provider::X509SignatureAlgorithm, + data: &[u8], + signature: &[u8], + issuer_spki_der: &[u8], + ) -> Result { + let call = self.verification_calls.fetch_add(1, Ordering::Relaxed); + if self.reject_verification_call == Some(call) + || self + .rejected_verification_data + .as_deref() + .is_some_and(|rejected| rejected == data) + { + return Err(crate::provider::ProviderError::Unsupported { + operation: crate::provider::ProviderOperation::VerifyCertificate, + algorithm: Some(algorithm.oid().to_owned()), + }); + } + crate::provider::default_provider().verify_x509_signature( + algorithm, + data, + signature, + issuer_spki_der, + ) + } + #[cfg(feature = "xmlenc")] fn encrypt_data( &self, @@ -1597,7 +1629,7 @@ mod tests { DsigError::KeyResolution(KeyResolutionError::Chain( super::super::X509ChainError::Provider( crate::provider::ProviderError::Unsupported { - operation: crate::provider::ProviderOperation::Verify, + operation: crate::provider::ProviderOperation::VerifyCertificate, .. } ) diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index c1bd19d1..8470d079 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -1205,6 +1205,7 @@ pub(crate) enum X509ChainBuildError { Cycle, IssuerSignatureMismatch, AmbiguousIssuer, + UnsupportedSignatureAlgorithm { oid: String }, Provider(crate::provider::ProviderError), } @@ -1224,6 +1225,11 @@ impl From for ParseError { X509ChainBuildError::AmbiguousIssuer => { "X509Data certificate chain contains ambiguous issuer certificates" } + X509ChainBuildError::UnsupportedSignatureAlgorithm { oid } => { + return Self::InvalidStructure(format!( + "X509Data certificate chain uses unsupported signature algorithm {oid}" + )); + } X509ChainBuildError::Provider(error) => return Self::Provider(error), }; Self::InvalidStructure(reason.into()) @@ -1279,6 +1285,9 @@ pub(crate) fn build_x509_certificate_chain_from( Err(super::X509ChainError::Provider(error)) => { return Err(X509ChainBuildError::Provider(error)); } + Err(super::X509ChainError::UnsupportedSignatureAlgorithm { oid }) => { + return Err(X509ChainBuildError::UnsupportedSignatureAlgorithm { oid }); + } Err(_) => return Err(X509ChainBuildError::IssuerSignatureMismatch), } } @@ -1357,6 +1366,9 @@ pub(crate) fn build_x509_certificate_paths_to_trusted_prefix( Err(super::X509ChainError::Provider(error)) => { return Err(X509ChainBuildError::Provider(error)); } + Err(super::X509ChainError::UnsupportedSignatureAlgorithm { oid }) => { + return Err(X509ChainBuildError::UnsupportedSignatureAlgorithm { oid }); + } Err(_) => return Err(X509ChainBuildError::IssuerSignatureMismatch), } } diff --git a/src/xmldsig/sign.rs b/src/xmldsig/sign.rs index 0c4b193c..2bf44005 100644 --- a/src/xmldsig/sign.rs +++ b/src/xmldsig/sign.rs @@ -15,7 +15,7 @@ use rsa::pkcs1v15::Signature as RsaPkcs1v15Signature; use rsa::pkcs1v15::SigningKey as RsaPkcs1v15SigningKey; use rsa::signature::{RandomizedSigner, SignatureEncoding}; use rsa::traits::PublicKeyParts; -use sha2::{Digest, Sha256, Sha384, Sha512}; +use sha2::{Sha256, Sha384, Sha512}; use signature::hazmat::PrehashSigner; use std::collections::HashSet; use x509_parser::prelude::FromDer; @@ -439,15 +439,29 @@ impl SigningKey for EcdsaP256SigningKey { algorithm: SignatureAlgorithm, canonical_signed_info: &[u8], ) -> Result, SigningKeyError> { - let prehash = match algorithm { - SignatureAlgorithm::EcdsaSha256 => Sha256::digest(canonical_signed_info).to_vec(), - SignatureAlgorithm::EcdsaSha384 => Sha384::digest(canonical_signed_info).to_vec(), + self.sign_with_provider( + crate::provider::default_provider(), + algorithm, + canonical_signed_info, + ) + } + + fn sign_with_provider( + &self, + provider: &dyn crate::provider::CryptoProvider, + algorithm: SignatureAlgorithm, + canonical_signed_info: &[u8], + ) -> Result, SigningKeyError> { + let digest_algorithm = match algorithm { + SignatureAlgorithm::EcdsaSha256 => DigestAlgorithm::Sha256, + SignatureAlgorithm::EcdsaSha384 => DigestAlgorithm::Sha384, _ => { return Err(SigningKeyError::UnsupportedAlgorithm { uri: algorithm.uri().to_string(), }); } }; + let prehash = provider.digest(digest_algorithm, canonical_signed_info)?; let signature: P256Signature = self .key .sign_prehash(&prehash) @@ -495,15 +509,29 @@ impl SigningKey for EcdsaP384SigningKey { algorithm: SignatureAlgorithm, canonical_signed_info: &[u8], ) -> Result, SigningKeyError> { - let prehash = match algorithm { - SignatureAlgorithm::EcdsaSha256 => Sha256::digest(canonical_signed_info).to_vec(), - SignatureAlgorithm::EcdsaSha384 => Sha384::digest(canonical_signed_info).to_vec(), + self.sign_with_provider( + crate::provider::default_provider(), + algorithm, + canonical_signed_info, + ) + } + + fn sign_with_provider( + &self, + provider: &dyn crate::provider::CryptoProvider, + algorithm: SignatureAlgorithm, + canonical_signed_info: &[u8], + ) -> Result, SigningKeyError> { + let digest_algorithm = match algorithm { + SignatureAlgorithm::EcdsaSha256 => DigestAlgorithm::Sha256, + SignatureAlgorithm::EcdsaSha384 => DigestAlgorithm::Sha384, _ => { return Err(SigningKeyError::UnsupportedAlgorithm { uri: algorithm.uri().to_string(), }); } }; + let prehash = provider.digest(digest_algorithm, canonical_signed_info)?; let signature: P384Signature = self .key .sign_prehash(&prehash) diff --git a/src/xmldsig/signature.rs b/src/xmldsig/signature.rs index 0981676c..ce5b093e 100644 --- a/src/xmldsig/signature.rs +++ b/src/xmldsig/signature.rs @@ -375,7 +375,7 @@ fn ecdsa_curve_and_component_len( ("1.2.840.10045.3.1.7", 256) => Ok((EcCurve::P256, 32)), ("1.3.132.0.34", 384) => Ok((EcCurve::P384, 48)), // x509-parser reports the byte-aligned SEC1 point size for P-521. - ("1.3.132.0.35", 521 | 528) => Ok((EcCurve::P521, 66)), + ("1.3.132.0.35", 528) => Ok((EcCurve::P521, 66)), _ => Err(SignatureVerificationError::InvalidKeyDer), } } diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index 11f59672..32526b79 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -1125,11 +1125,14 @@ fn verify_signature_with_context( .map(|node: Node<'_, '_>| node.id()) .collect(); let mut canonical_signed_info = Vec::new(); + let signed_info_limit = canonicalized_data_budget + .remaining() + .min(execution_budget.remaining_c14n_output()); canonicalize_bounded_with_xml_base_budget( &doc, Some(&|node| signed_info_subtree.contains(&node.id())), &signed_info.c14n_method, - canonicalized_data_budget.remaining(), + signed_info_limit, execution_budget.xml_base_resolution(), &mut canonical_signed_info, ) @@ -1144,6 +1147,9 @@ fn verify_signature_with_context( SignatureVerificationPipelineError::Canonicalization(error) } })?; + execution_budget + .charge_c14n_output(canonical_signed_info.len()) + .map_err(ReferenceProcessingError::Transform)?; canonicalized_data_budget.charge(canonical_signed_info.len())?; let signature_value = decode_signature_value(signature_children.signature_value_node)?; @@ -2212,6 +2218,42 @@ mod tests { ); } + #[test] + fn verification_policy_shares_canonicalization_budget_with_signed_info() { + // Reference transforms and SignedInfo canonicalization are one operation. + // Each output fits independently, but their aggregate must not receive + // two separate copies of the configured canonicalization allowance. + let payload_text = "x".repeat(700); + let canonical_payload = format!("{payload_text}"); + let digest = base64::engine::general_purpose::STANDARD.encode(compute_digest( + DigestAlgorithm::Sha256, + canonical_payload.as_bytes(), + )); + let xml = format!( + r##"{canonical_payload}{digest}AQ=="## + ); + let policy = crate::policy::VerificationPolicy { + resources: crate::policy::ResourcePolicy { + max_canonicalized_bytes: 1_024, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::VerificationPolicy::default() + }; + + let error = VerifyContext::new() + .key(&AcceptingKey) + .policy(policy) + .verify(&xml) + .expect_err("SignedInfo must consume the remaining operation C14N budget"); + + assert!(matches!( + error, + SignatureVerificationPipelineError::Reference( + ReferenceProcessingError::CanonicalizedDataTooLarge { max_bytes: 1_024 } + ) + )); + } + #[test] fn verification_policy_bounds_detached_xml_nodes() { // Caller-owned detached octets become a second XML document during a diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index df31ec9d..2f29d4f6 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -2,16 +2,16 @@ use std::time::{SystemTime, UNIX_EPOCH}; -use der::Decode; use x509_parser::{ certificate::X509Certificate, extensions::ParsedExtension, prelude::FromDer, - revocation_list::CertificateRevocationList, time::ASN1Time, + revocation_list::CertificateRevocationList, time::ASN1Time, x509::AlgorithmIdentifier, }; use super::{ - SignatureAlgorithm, VerificationKey, X509DataInfo, + X509DataInfo, parse::{distinguished_names_equal, x509_name_to_rfc4514}, }; +use crate::provider::X509SignatureAlgorithm; /// Inputs controlling X.509 certificate-chain validation. #[derive(Debug, Clone)] @@ -75,6 +75,12 @@ pub enum X509ChainError { /// A certificate signature does not verify under its issuer key. #[error("certificate signature at chain position {0} is invalid or unsupported")] InvalidSignature(usize), + /// The certificate or CRL declares an algorithm this build cannot verify. + #[error("unsupported X.509 signature algorithm: {oid}")] + UnsupportedSignatureAlgorithm { + /// AlgorithmIdentifier object identifier. + oid: String, + }, /// A CRL is not valid for the selected verification time or issuer. #[error("CRL {0} is invalid or cannot be authenticated")] InvalidCrl(usize), @@ -242,7 +248,7 @@ fn verify_certificate_signature_with_provider( return Ok(false); } verify_x509_signature_with_provider( - &certificate.signature_algorithm.algorithm.to_id_string(), + &certificate.signature_algorithm, &certificate.signature_value.data, certificate.tbs_certificate.as_ref(), issuer.public_key().raw, @@ -305,7 +311,7 @@ fn verify_crl_signature_with_provider( return Ok(false); } verify_x509_signature_with_provider( - &crl.signature_algorithm.algorithm.to_id_string(), + &crl.signature_algorithm, &crl.signature_value.data, crl.tbs_cert_list.as_ref(), issuer.public_key().raw, @@ -314,56 +320,100 @@ fn verify_crl_signature_with_provider( } fn verify_x509_signature_with_provider( - algorithm_oid: &str, + algorithm_identifier: &AlgorithmIdentifier<'_>, signature_der: &[u8], signed_data: &[u8], issuer_spki_der: &[u8], provider: &dyn crate::provider::CryptoProvider, ) -> Result { - let Some(algorithm) = x509_signature_algorithm(algorithm_oid) else { - return Ok(false); - }; - let signature = if algorithm == SignatureAlgorithm::DsaSha1 { - let Ok(signature) = dsa::Signature::from_der(signature_der) else { - return Ok(false); - }; - let r = signature.r().to_be_bytes(); - let s = signature.s().to_be_bytes(); - let r = &r[r.iter().position(|byte| *byte != 0).unwrap_or(r.len())..]; - let s = &s[s.iter().position(|byte| *byte != 0).unwrap_or(s.len())..]; - if r.len() > 20 || s.len() > 20 { - return Ok(false); + let algorithm = x509_signature_algorithm(algorithm_identifier)?; + provider + .verify_x509_signature(algorithm, signed_data, signature_der, issuer_spki_der) + .map_err(Into::into) +} + +fn x509_signature_algorithm( + identifier: &AlgorithmIdentifier<'_>, +) -> Result { + let oid = identifier.algorithm.to_id_string(); + let algorithm = match oid.as_str() { + "1.2.840.10040.4.3" => X509SignatureAlgorithm::Dsa(super::DigestAlgorithm::Sha1), + "1.2.840.113549.1.1.5" | "1.3.14.3.2.29" => { + X509SignatureAlgorithm::RsaPkcs1v15(super::DigestAlgorithm::Sha1) } - let mut fixed = vec![0_u8; 40]; - fixed[20 - r.len()..20].copy_from_slice(r); - fixed[40 - s.len()..].copy_from_slice(s); - fixed - } else { - signature_der.to_vec() - }; - let key = VerificationKey { - algorithm, - public_key_bytes: issuer_spki_der.to_vec(), - certificate_der: None, - name: None, + "1.2.840.113549.1.1.11" => { + X509SignatureAlgorithm::RsaPkcs1v15(super::DigestAlgorithm::Sha256) + } + "1.2.840.113549.1.1.12" => { + X509SignatureAlgorithm::RsaPkcs1v15(super::DigestAlgorithm::Sha384) + } + "1.2.840.113549.1.1.13" => { + X509SignatureAlgorithm::RsaPkcs1v15(super::DigestAlgorithm::Sha512) + } + "1.2.840.113549.1.1.10" => parse_rsa_pss_algorithm(identifier)?, + "1.2.840.10045.4.3.2" => X509SignatureAlgorithm::Ecdsa(super::DigestAlgorithm::Sha256), + "1.2.840.10045.4.3.3" => X509SignatureAlgorithm::Ecdsa(super::DigestAlgorithm::Sha384), + "1.3.101.112" => X509SignatureAlgorithm::Ed25519, + _ => return Err(X509ChainError::UnsupportedSignatureAlgorithm { oid }), }; - match provider.verify(&key, algorithm, signed_data, &signature) { - Ok(verified) => Ok(verified), - Err(super::DsigError::Provider(error)) => Err(error.into()), - Err(_) => Ok(false), + Ok(algorithm) +} + +fn parse_rsa_pss_algorithm( + identifier: &AlgorithmIdentifier<'_>, +) -> Result { + let parameters = identifier + .parameters + .as_ref() + .ok_or_else(|| X509ChainError::InvalidDer { + kind: "RSASSA-PSS parameters", + message: "missing parameters".into(), + })?; + let parameters = x509_parser::signature_algorithm::RsaSsaPssParams::try_from(parameters) + .map_err(|error| X509ChainError::InvalidDer { + kind: "RSASSA-PSS parameters", + message: error.to_string(), + })?; + if parameters.trailer_field() != 1 { + return Err(X509ChainError::InvalidDer { + kind: "RSASSA-PSS parameters", + message: "trailerField must be 1".into(), + }); } + let digest = x509_digest_algorithm(¶meters.hash_algorithm_oid().to_id_string())?; + let mask = parameters + .mask_gen_algorithm() + .map_err(|error| X509ChainError::InvalidDer { + kind: "RSASSA-PSS parameters", + message: error.to_string(), + })?; + if mask.mgf.to_id_string() != "1.2.840.113549.1.1.8" { + return Err(X509ChainError::UnsupportedSignatureAlgorithm { + oid: mask.mgf.to_id_string(), + }); + } + let mgf_digest = x509_digest_algorithm(&mask.hash.to_id_string())?; + let salt_len = + usize::try_from(parameters.salt_length()).map_err(|_| X509ChainError::InvalidDer { + kind: "RSASSA-PSS parameters", + message: "saltLength does not fit this platform".into(), + })?; + Ok(X509SignatureAlgorithm::RsaPss { + digest, + mgf_digest, + salt_len, + }) } -fn x509_signature_algorithm(oid: &str) -> Option { +fn x509_digest_algorithm(oid: &str) -> Result { match oid { - "1.2.840.10040.4.3" => Some(SignatureAlgorithm::DsaSha1), - "1.2.840.113549.1.1.5" | "1.3.14.3.2.29" => Some(SignatureAlgorithm::RsaSha1), - "1.2.840.113549.1.1.11" => Some(SignatureAlgorithm::RsaSha256), - "1.2.840.113549.1.1.12" => Some(SignatureAlgorithm::RsaSha384), - "1.2.840.113549.1.1.13" => Some(SignatureAlgorithm::RsaSha512), - "1.2.840.10045.4.3.2" => Some(SignatureAlgorithm::EcdsaSha256), - "1.2.840.10045.4.3.3" => Some(SignatureAlgorithm::EcdsaSha384), - _ => None, + "1.3.14.3.2.26" => Ok(super::DigestAlgorithm::Sha1), + "2.16.840.1.101.3.4.2.1" => Ok(super::DigestAlgorithm::Sha256), + "2.16.840.1.101.3.4.2.2" => Ok(super::DigestAlgorithm::Sha384), + "2.16.840.1.101.3.4.2.3" => Ok(super::DigestAlgorithm::Sha512), + _ => Err(X509ChainError::UnsupportedSignatureAlgorithm { + oid: oid.to_owned(), + }), } } @@ -521,6 +571,9 @@ mod tests { use sha2::{Digest, Sha256, Sha384}; use signature::hazmat::PrehashSigner; use std::time::Duration; + use x509_parser::oid_registry::{ + OID_SIG_ECDSA_WITH_SHA256, OID_SIG_ECDSA_WITH_SHA384, OID_SIG_ECDSA_WITH_SHA512, + }; #[test] fn x509_ecdsa_hash_oid_does_not_select_the_issuer_curve() { @@ -541,7 +594,7 @@ mod tests { .expect("P-384 SPKI must encode"); assert!( verify_x509_signature_with_provider( - "1.2.840.10045.4.3.2", + &AlgorithmIdentifier::new(OID_SIG_ECDSA_WITH_SHA256, None), p384_signature.to_der().as_bytes(), data, p384_spki.as_bytes(), @@ -561,7 +614,7 @@ mod tests { .expect("P-256 SPKI must encode"); assert!( verify_x509_signature_with_provider( - "1.2.840.10045.4.3.3", + &AlgorithmIdentifier::new(OID_SIG_ECDSA_WITH_SHA384, None), p256_signature.to_der().as_bytes(), data, p256_spki.as_bytes(), @@ -616,6 +669,72 @@ mod tests { )); } + #[test] + fn certificate_path_edge_preserves_ed25519_verification() { + // Provider routing must preserve the certificate algorithms accepted by + // the previous x509-parser verifier rather than narrowing them to the + // XMLDSig SignatureMethod enum. + let issuer_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ED25519) + .expect("Ed25519 issuer key generation should succeed"); + 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, "Ed25519 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, issuer_key) + .expect("Ed25519 issuer certificate should be self-signable"); + let leaf_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ED25519) + .expect("Ed25519 leaf key generation should succeed"); + let leaf = rcgen::CertificateParams::new(Vec::new()) + .expect("empty leaf SAN list should be valid") + .signed_by(&leaf_key, &issuer) + .expect("Ed25519 issuer should sign leaf certificate"); + + assert!(certificate_signature_matches(leaf.der(), issuer.der())); + } + + #[test] + fn unsupported_x509_signature_algorithm_remains_diagnosable() { + // ECDSA-with-SHA512 is not implemented by the selected provider yet; + // that capability gap is distinct from a cryptographically invalid + // certificate signature and must remain visible to the caller. + let identifier = AlgorithmIdentifier::new(OID_SIG_ECDSA_WITH_SHA512, None); + + assert_eq!( + x509_signature_algorithm(&identifier), + Err(X509ChainError::UnsupportedSignatureAlgorithm { + oid: "1.2.840.10045.4.3.4".into(), + }) + ); + } + + #[test] + fn parses_rsa_pss_certificate_parameters_without_xml_dsig_loss() { + // RFC 4055 carries the digest, MGF digest, and salt length inside the + // AlgorithmIdentifier. Preserve all three values at the provider edge. + let der = [ + 0x30, 0x41, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0a, 0x30, + 0x34, 0xa0, 0x0f, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, + 0x02, 0x01, 0x05, 0x00, 0xa1, 0x1c, 0x30, 0x1a, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, + 0xf7, 0x0d, 0x01, 0x01, 0x08, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, + 0x03, 0x04, 0x02, 0x01, 0x05, 0x00, 0xa2, 0x03, 0x02, 0x01, 0x20, + ]; + let (rest, identifier) = AlgorithmIdentifier::from_der(&der) + .expect("standard SHA-256 RSA-PSS AlgorithmIdentifier must parse"); + assert!(rest.is_empty()); + + assert_eq!( + x509_signature_algorithm(&identifier), + Ok(X509SignatureAlgorithm::RsaPss { + digest: super::super::DigestAlgorithm::Sha256, + mgf_digest: super::super::DigestAlgorithm::Sha256, + salt_len: 32, + }) + ); + } + #[test] fn dsa_rollover_replaces_embedded_root_before_depth_validation() { let leaf = include_bytes!( From 168b648cc72999382a918f2402d777312ce35279 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 9 Aug 2026 21:22:43 +0300 Subject: [PATCH 37/63] fix(xmldsig): preserve alternate X.509 paths - defer unsupported certificate branches during bounded path search\n- route every modeled certificate OID to the selected provider\n- retain typed diagnostics when no supported path completes --- docs/xmldsig.md | 4 +- src/xmldsig/keys.rs | 123 ++++++++++++++++++++++++++++++++++++++++--- src/xmldsig/parse.rs | 45 ++++++++++++++-- src/xmldsig/x509.rs | 61 +++++++++++++++++---- 4 files changed, 209 insertions(+), 24 deletions(-) diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 5354b409..9e3e1259 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -45,7 +45,9 @@ reference digests, document signatures, `X509Digest` selector evaluation, X.509 edges, complete certificate paths, and CRL authentication performed by `DefaultKeyResolver`. Certificate authentication uses a separate typed algorithm contract so RSA-PSS parameters and Ed25519 are not collapsed into the narrower XMLDSig `SignatureMethod` enum. Unsupported certificate -OIDs remain typed path errors rather than ordinary signature mismatches. +OIDs remain typed path errors rather than ordinary signature mismatches. Every certificate OID +represented by that contract reaches the selected provider; the built-in provider may reject a +capability such as ECDSA-SHA512 while a custom provider can implement it. Custom resolvers that evaluate cryptographic key metadata should override `KeyResolver::resolve_with_policy_and_provider`; source-only resolvers can retain the default hook. diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 7e0655fc..7fccfdc7 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -1627,13 +1627,8 @@ mod tests { assert!(matches!( error, DsigError::KeyResolution(KeyResolutionError::Chain( - super::super::X509ChainError::Provider( - crate::provider::ProviderError::Unsupported { - operation: crate::provider::ProviderOperation::VerifyCertificate, - .. - } - ) - )) + super::super::X509ChainError::UnsupportedSignatureAlgorithm { ref oid } + )) if oid == "1.2.840.10045.4.3.2" )); assert_eq!(provider.verification_calls.load(Ordering::Relaxed), 1); } @@ -1946,6 +1941,120 @@ mod tests { assert!(resolved.is_some()); } + #[test] + fn x509_path_builder_skips_branch_local_unsupported_algorithms() { + // An untrusted intermediate can share both the subject and public key + // of the valid path while using an unsupported signature algorithm on + // its own parent edge. That branch must not suppress the valid path. + let root = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("unsupported-edge root", true), + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("root certificate should be self-signable"); + let signing_intermediate = rcgen::CertifiedIssuer::signed_by( + generated_certificate_params("shared unsupported-edge issuer", true), + rcgen::KeyPair::generate().expect("signing issuer key generation should succeed"), + &root, + ) + .expect("root should sign the intermediate certificate"); + let key_unsupported_intermediate = rcgen::CertifiedIssuer::signed_by( + generated_certificate_params("shared unsupported-edge issuer", true), + rcgen::KeyPair::generate().expect("unsupported issuer key generation should succeed"), + &root, + ) + .expect("root should sign the alternate intermediate certificate"); + let leaf = generated_certificate_params("unsupported-edge leaf", false) + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &signing_intermediate, + ) + .expect("signing intermediate should sign the leaf"); + + let ordered = x509_info( + vec![ + leaf.der().to_vec(), + key_unsupported_intermediate.der().to_vec(), + signing_intermediate.der().to_vec(), + root.der().to_vec(), + ], + 0, + ); + let key_selective_provider = RejectSecondSha512Provider { + sha512_calls: AtomicUsize::new(0), + verification_calls: AtomicUsize::new(0), + reject_verification_call: Some(0), + rejected_verification_data: None, + }; + assert_eq!( + super::super::parse::build_x509_certificate_chain_from( + &ordered, + 0, + &key_selective_provider, + ) + .expect("one unsupported issuer key must not suppress a usable candidate"), + vec![0, 2, 3] + ); + + let mut unsupported_intermediate = signing_intermediate.der().to_vec(); + let ecdsa_sha256_oid = [0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x02]; + let offsets = unsupported_intermediate + .windows(ecdsa_sha256_oid.len()) + .enumerate() + .filter_map(|(offset, window)| (window == ecdsa_sha256_oid).then_some(offset)) + .collect::>(); + assert_eq!( + offsets.len(), + 2, + "certificate must repeat its signature OID" + ); + for offset in offsets { + unsupported_intermediate[offset + ecdsa_sha256_oid.len() - 1] = 0x04; + } + + let anchored = x509_info( + vec![ + root.der().to_vec(), + leaf.der().to_vec(), + signing_intermediate.der().to_vec(), + unsupported_intermediate, + ], + 1, + ); + assert_eq!( + build_x509_certificate_paths_to_trusted_prefix( + &anchored, + 1, + 1, + 4, + 8, + crate::provider::default_provider(), + ) + .expect("a branch-local provider gap must not abort path enumeration"), + vec![vec![1, 2, 0]] + ); + + let unsupported_only = x509_info( + vec![ + root.der().to_vec(), + leaf.der().to_vec(), + anchored.certificates[3].clone(), + ], + 1, + ); + assert!(matches!( + build_x509_certificate_paths_to_trusted_prefix( + &unsupported_only, + 1, + 1, + 4, + 8, + crate::provider::default_provider(), + ), + Err(X509ChainBuildError::UnsupportedSignatureAlgorithm { ref oid }) + if oid == "1.2.840.10045.4.3.4" + )); + } + #[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 8470d079..03145b20 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -1274,6 +1274,7 @@ pub(crate) fn build_x509_certificate_chain_from( [issuer_idx] => *issuer_idx, _ => { let mut verified = Vec::new(); + let mut unsupported_oid = None; for issuer_idx in candidates { match certificate_signature_matches_with_provider( &info.certificates[current_idx], @@ -1282,18 +1283,31 @@ pub(crate) fn build_x509_certificate_chain_from( ) { Ok(true) => verified.push(issuer_idx), Ok(false) => {} + Err(super::X509ChainError::Provider( + crate::provider::ProviderError::Unsupported { + operation: crate::provider::ProviderOperation::VerifyCertificate, + algorithm: Some(oid), + }, + )) => { + unsupported_oid.get_or_insert(oid); + } Err(super::X509ChainError::Provider(error)) => { return Err(X509ChainBuildError::Provider(error)); } Err(super::X509ChainError::UnsupportedSignatureAlgorithm { oid }) => { - return Err(X509ChainBuildError::UnsupportedSignatureAlgorithm { oid }); + unsupported_oid.get_or_insert(oid); } Err(_) => return Err(X509ChainBuildError::IssuerSignatureMismatch), } } match verified.as_slice() { [issuer_idx] => *issuer_idx, - [] => return Err(X509ChainBuildError::IssuerSignatureMismatch), + [] => { + if let Some(oid) = unsupported_oid { + return Err(X509ChainBuildError::UnsupportedSignatureAlgorithm { oid }); + } + return Err(X509ChainBuildError::IssuerSignatureMismatch); + } _ => return Err(X509ChainBuildError::AmbiguousIssuer), } } @@ -1335,6 +1349,7 @@ pub(crate) fn build_x509_certificate_paths_to_trusted_prefix( let mut completed = Vec::new(); let mut generated_paths = 1usize; let mut depth_exceeded = false; + let mut unsupported_oid = None; let mut issuer_cache = vec![None; info.parsed_certificates.len()]; while let Some(path) = pending.pop() { let current_idx = *path @@ -1363,11 +1378,26 @@ pub(crate) fn build_x509_certificate_paths_to_trusted_prefix( ) { Ok(true) => verified.push(issuer_idx), Ok(false) => {} + Err(super::X509ChainError::Provider( + crate::provider::ProviderError::Unsupported { + operation: crate::provider::ProviderOperation::VerifyCertificate, + algorithm: Some(oid), + }, + )) => { + // Unsupported is an algorithm capability, so changing + // the issuer key cannot make this child verifiable. + // Prune this DFS branch but retain sibling paths. + unsupported_oid.get_or_insert(oid); + break; + } Err(super::X509ChainError::Provider(error)) => { return Err(X509ChainBuildError::Provider(error)); } Err(super::X509ChainError::UnsupportedSignatureAlgorithm { oid }) => { - return Err(X509ChainBuildError::UnsupportedSignatureAlgorithm { oid }); + // The mapper rejected this child's AlgorithmIdentifier; + // no issuer candidate can alter it on the current path. + unsupported_oid.get_or_insert(oid); + break; } Err(_) => return Err(X509ChainBuildError::IssuerSignatureMismatch), } @@ -1393,8 +1423,13 @@ pub(crate) fn build_x509_certificate_paths_to_trusted_prefix( } } - if completed.is_empty() && depth_exceeded { - return Err(X509ChainBuildError::DepthExceeded); + if completed.is_empty() { + if let Some(oid) = unsupported_oid { + return Err(X509ChainBuildError::UnsupportedSignatureAlgorithm { oid }); + } + if depth_exceeded { + return Err(X509ChainBuildError::DepthExceeded); + } } Ok(completed) } diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index 2f29d4f6..181b3ccb 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -338,6 +338,9 @@ fn x509_signature_algorithm( let oid = identifier.algorithm.to_id_string(); let algorithm = match oid.as_str() { "1.2.840.10040.4.3" => X509SignatureAlgorithm::Dsa(super::DigestAlgorithm::Sha1), + "2.16.840.1.101.3.4.3.2" => X509SignatureAlgorithm::Dsa(super::DigestAlgorithm::Sha256), + "2.16.840.1.101.3.4.3.3" => X509SignatureAlgorithm::Dsa(super::DigestAlgorithm::Sha384), + "2.16.840.1.101.3.4.3.4" => X509SignatureAlgorithm::Dsa(super::DigestAlgorithm::Sha512), "1.2.840.113549.1.1.5" | "1.3.14.3.2.29" => { X509SignatureAlgorithm::RsaPkcs1v15(super::DigestAlgorithm::Sha1) } @@ -351,8 +354,10 @@ fn x509_signature_algorithm( X509SignatureAlgorithm::RsaPkcs1v15(super::DigestAlgorithm::Sha512) } "1.2.840.113549.1.1.10" => parse_rsa_pss_algorithm(identifier)?, + "1.2.840.10045.4.1" => X509SignatureAlgorithm::Ecdsa(super::DigestAlgorithm::Sha1), "1.2.840.10045.4.3.2" => X509SignatureAlgorithm::Ecdsa(super::DigestAlgorithm::Sha256), "1.2.840.10045.4.3.3" => X509SignatureAlgorithm::Ecdsa(super::DigestAlgorithm::Sha384), + "1.2.840.10045.4.3.4" => X509SignatureAlgorithm::Ecdsa(super::DigestAlgorithm::Sha512), "1.3.101.112" => X509SignatureAlgorithm::Ed25519, _ => return Err(X509ChainError::UnsupportedSignatureAlgorithm { oid }), }; @@ -564,6 +569,8 @@ fn verify_crls( #[cfg(test)] mod tests { + use std::str::FromStr as _; + use super::*; use crate::xmldsig::{KeyInfoSource, parse::XMLDSIG_NS, parse_key_info}; use p256::pkcs8::EncodePublicKey; @@ -571,9 +578,7 @@ mod tests { use sha2::{Digest, Sha256, Sha384}; use signature::hazmat::PrehashSigner; use std::time::Duration; - use x509_parser::oid_registry::{ - OID_SIG_ECDSA_WITH_SHA256, OID_SIG_ECDSA_WITH_SHA384, OID_SIG_ECDSA_WITH_SHA512, - }; + use x509_parser::oid_registry::{OID_SIG_ECDSA_WITH_SHA256, OID_SIG_ECDSA_WITH_SHA384, Oid}; #[test] fn x509_ecdsa_hash_oid_does_not_select_the_issuer_curve() { @@ -696,17 +701,51 @@ mod tests { } #[test] - fn unsupported_x509_signature_algorithm_remains_diagnosable() { - // ECDSA-with-SHA512 is not implemented by the selected provider yet; - // that capability gap is distinct from a cryptographically invalid - // certificate signature and must remain visible to the caller. - let identifier = AlgorithmIdentifier::new(OID_SIG_ECDSA_WITH_SHA512, None); + fn every_modeled_non_parameterized_x509_algorithm_reaches_the_provider() { + // Parsing and provider capability are separate contracts. Once an OID + // has a typed representation, custom providers must get the chance to + // implement it even when RustCrypto does not. + for (oid, expected) in [ + ( + "2.16.840.1.101.3.4.3.2", + X509SignatureAlgorithm::Dsa(super::super::DigestAlgorithm::Sha256), + ), + ( + "2.16.840.1.101.3.4.3.3", + X509SignatureAlgorithm::Dsa(super::super::DigestAlgorithm::Sha384), + ), + ( + "2.16.840.1.101.3.4.3.4", + X509SignatureAlgorithm::Dsa(super::super::DigestAlgorithm::Sha512), + ), + ( + "1.2.840.10045.4.1", + X509SignatureAlgorithm::Ecdsa(super::super::DigestAlgorithm::Sha1), + ), + ( + "1.2.840.10045.4.3.4", + X509SignatureAlgorithm::Ecdsa(super::super::DigestAlgorithm::Sha512), + ), + ] { + let identifier = AlgorithmIdentifier::new( + Oid::from_str(oid).expect("static signature OID must parse"), + None, + ); + assert_eq!(x509_signature_algorithm(&identifier), Ok(expected), "{oid}"); + } + } + + #[test] + fn unknown_x509_signature_algorithm_remains_diagnosable() { + let oid = "1.2.3.4.5"; + let identifier = AlgorithmIdentifier::new( + Oid::from_str(oid).expect("static unknown OID must parse"), + None, + ); assert_eq!( x509_signature_algorithm(&identifier), - Err(X509ChainError::UnsupportedSignatureAlgorithm { - oid: "1.2.840.10045.4.3.4".into(), - }) + Err(X509ChainError::UnsupportedSignatureAlgorithm { oid: oid.into() }) ); } From 2213a8d51f75516b2f71727531f50c338a57bf53 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 9 Aug 2026 22:59:25 +0300 Subject: [PATCH 38/63] fix(xmldsig): enforce X.509 constraints - Enforce RFC 4055 and RFC 5280 certificate restrictions - Bind selector categories to one signature-valid path - Reject unrepresentable legacy OAEP MGF parameters - Clarify public validation and Manifest semantics --- README.md | 2 +- docs/xmldsig.md | 32 ++- docs/xmlenc.md | 2 + src/provider.rs | 126 +++++++-- src/xmldsig/keys.rs | 192 +++++++++++++- src/xmldsig/parse.rs | 299 ++++++++++++--------- src/xmldsig/x509.rs | 605 +++++++++++++++++++++++++++++++++++++++++- src/xmlenc/encrypt.rs | 31 +++ 8 files changed, 1121 insertions(+), 168 deletions(-) diff --git a/README.md b/README.md index 994f468d..d166877c 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Currently implemented (core paths): - ECDSA SHA-256/SHA-384 verification for P-256, P-384, and P-521 keys - Legacy DSA-SHA1 and HMAC-SHA1 verification, including truncated HMAC output - RSA PKCS#1 v1.5 and ECDSA SHA-256/SHA-384 signing with P-256/P-384 PKCS#8 keys -- Opt-in X.509 certificate-chain validation with explicit trust anchors, validity checks, CA constraints, authenticated CRLs, and typed RSA-PSS/Ed25519 certificate-signature support +- Opt-in X.509 certificate-chain validation with explicit trust anchors, validity and path-length checks, NameConstraints, fail-closed critical-extension handling, authenticated CRLs, and typed RSA-PSS/Ed25519 certificate-signature support - Caller-supplied external references and X.509 `RetrievalMethod` resolution with bounded RFC 3986 `xml:base` processing and no 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 diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 9e3e1259..76d46cd6 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -47,7 +47,9 @@ Certificate authentication uses a separate typed algorithm contract so RSA-PSS p Ed25519 are not collapsed into the narrower XMLDSig `SignatureMethod` enum. Unsupported certificate OIDs remain typed path errors rather than ordinary signature mismatches. Every certificate OID represented by that contract reaches the selected provider; the built-in provider may reject a -capability such as ECDSA-SHA512 while a custom provider can implement it. +capability such as ECDSA-SHA512 while a custom provider can implement it. For an +`id-RSASSA-PSS` issuer key, the built-in provider also enforces the SPKI hash, MGF, minimum salt, +and trailer-field restrictions before verifying a certificate signature. Custom resolvers that evaluate cryptographic key metadata should override `KeyResolver::resolve_with_policy_and_provider`; source-only resolvers can retain the default hook. @@ -65,6 +67,11 @@ Configured chain depth and candidate-path limits are validated after resolver de the operation policy. Candidate-path accounting includes every generated partial path, and self-issued rollover certificates continue toward a distinct same-name issuer when its signature validates; neither condition can bypass the configured work bounds or trust anchor requirement. +Path validation excludes self-issued rollover CAs from `pathLenConstraint`, applies supported RFC +5280 NameConstraints to every subordinate certificate, and rejects critical extensions whose +semantics are not implemented. When `X509Data` supplies multiple selector categories, every +category must match certificates on the same selected, policy-valid path rather than unrelated +certificates from the lookup pool. `VerifyResult::status` reports core validation: `Valid` means the cryptographic signature and every `` reference succeeded. `Invalid(reason)` means core validation completed but @@ -82,10 +89,11 @@ 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 -`Invalid(reason)` and an API error as a rejected document; never continue an authentication flow -after either outcome. +Malformed XMLDSig structure, unsupported algorithms in core signature processing, disallowed URIs +in `` references, and inconsistent `KeyInfo` metadata are processing errors rather +than validity statuses. Manifest policy violations and unsupported transforms remain independent +per-reference statuses as described above. Treat both `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 @@ -112,11 +120,13 @@ CRL checking is meaningful only inside authenticated X.509 path validation. A po CRLs without enabling certificate-chain validation is rejected during context construction rather than silently accepting a control the resolver cannot enforce. -Internal DTD declarations are disabled by default and require -`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)`. Signing uses the corresponding -`SigningPolicy::xml.allow_internal_dtd` decision across its complete pipeline. External entity resolution +Internal DTD declarations are disabled by default. Verification requires the operation's +`VerificationPolicy::xml.allow_internal_dtd` decision; the +`VerifyContext::allow_internal_dtd(true)` convenience method updates that same policy snapshot +rather than bypassing a separate policy gate. The decision applies consistently to the signed +document and caller-supplied detached XML parsed by node-set transforms. Direct transform callers +can set the corresponding option with `TransformOptions::allow_internal_dtd(true)`. Signing uses +`SigningPolicy::xml.allow_internal_dtd` across its complete pipeline. 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. @@ -130,5 +140,5 @@ the built-in P-256 and P-384 signing keys support either ECDSA hash identifier. DSA-SHA1 and HMAC-SHA1 (including XMLDSig's byte-aligned 80-160-bit truncation range) are verify-only legacy algorithms. X.509 path and CRL authentication additionally supports standard RSA-PSS with SHA-256/SHA-384/ -SHA-512 parameters and Ed25519. DSA-SHA256, broader HMAC verification/signing, XMLDSig +SHA-512 parameters, including RFC 4055 issuer-key restrictions, and Ed25519. DSA-SHA256, broader HMAC verification/signing, XMLDSig `SignatureMethod` RSA-PSS, and implicit external resource loading are not currently supported. diff --git a/docs/xmlenc.md b/docs/xmlenc.md index b2fd3e6b..9416175c 100644 --- a/docs/xmlenc.md +++ b/docs/xmlenc.md @@ -39,6 +39,8 @@ the operating-system RNG and wrapped once per recipient. The crate's secure RSA- SHA-256/MGF1-SHA-256. XMLEnc 1.1 itself defaults omitted parameters to SHA-1/MGF1-SHA-1, so `xml-sec` always emits explicit `ds:DigestMethod` and `xenc11:MGF` values rather than relying on those implicit legacy defaults. SHA-1 OAEP remains available only through explicit parameters. +The legacy `rsa-oaep-mgf1p` URI fixes MGF1 to SHA-1; configuration validation rejects any other +MGF digest before provider dispatch because that URI has no wire field capable of representing it. `encrypt_document` selects the root or an element by `Id`, `ID`, or `id`, then replaces either the complete element or only its child content according to `EncryptedDataType`. See diff --git a/src/provider.rs b/src/provider.rs index 2fc814b2..8aa8691d 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -652,10 +652,15 @@ mod rustcrypto_x509 { digest, mgf_digest, salt_len, - } if digest == mgf_digest => { - verify_rsa_pss(digest, salt_len, signed_data, signature, issuer_spki_der) + } => { + let Some(key) = rsa_pss_public_key_from_spki(issuer_spki_der, algorithm) else { + return Ok(false); + }; + if digest != mgf_digest { + return unsupported(algorithm); + } + verify_rsa_pss(digest, salt_len, signed_data, signature, key) } - X509SignatureAlgorithm::RsaPss { .. } => unsupported(algorithm), X509SignatureAlgorithm::Ed25519 => { let Ok(key) = ed25519_dalek::VerifyingKey::from_public_key_der(issuer_spki_der) else { @@ -694,11 +699,8 @@ mod rustcrypto_x509 { salt_len: usize, signed_data: &[u8], signature: &[u8], - issuer_spki_der: &[u8], + key: RsaPublicKey, ) -> Result { - let Some(key) = rsa_public_key_from_spki(issuer_spki_der) else { - return Ok(false); - }; let Ok(signature) = RsaPssSignature::try_from(signature) else { return Ok(false); }; @@ -726,19 +728,60 @@ mod rustcrypto_x509 { Ok(verified.is_ok()) } - fn rsa_public_key_from_spki(spki_der: &[u8]) -> Option { - if let Ok(key) = RsaPublicKey::from_public_key_der(spki_der) { - return Some(key); + fn rsa_pss_public_key_from_spki( + spki_der: &[u8], + signature_algorithm: X509SignatureAlgorithm, + ) -> Option { + let (_, spki) = x509_parser::x509::SubjectPublicKeyInfo::from_der(spki_der).ok()?; + match spki.algorithm.algorithm.to_id_string().as_str() { + "1.2.840.113549.1.1.1" => RsaPublicKey::from_public_key_der(spki_der).ok(), + "1.2.840.113549.1.1.10" => { + if let Some(parameters) = spki.algorithm.parameters.as_ref() + && !rsa_pss_key_parameters_allow(parameters, signature_algorithm) + { + return None; + } + RsaPublicKey::from_pkcs1_der(&spki.subject_public_key.data).ok() + } + _ => None, } + } - // RFC 4055 permits id-RSASSA-PSS in SubjectPublicKeyInfo. The generic - // PKCS#8 decoder accepts rsaEncryption SPKIs, so extract the same - // PKCS#1 key payload explicitly when the container carries the PSS OID. - let (_, spki) = x509_parser::x509::SubjectPublicKeyInfo::from_der(spki_der).ok()?; - if spki.algorithm.algorithm.to_id_string() != "1.2.840.113549.1.1.10" { - return None; + fn rsa_pss_key_parameters_allow( + parameters: &x509_parser::asn1_rs::Any<'_>, + signature_algorithm: X509SignatureAlgorithm, + ) -> bool { + let X509SignatureAlgorithm::RsaPss { + digest, + mgf_digest, + salt_len, + } = signature_algorithm + else { + return false; + }; + let Ok(parameters) = + x509_parser::signature_algorithm::RsaSsaPssParams::try_from(parameters) + else { + return false; + }; + let Ok(mask) = parameters.mask_gen_algorithm() else { + return false; + }; + parameters.trailer_field() == 1 + && x509_digest_from_oid(¶meters.hash_algorithm_oid().to_id_string()) == Some(digest) + && mask.mgf.to_id_string() == "1.2.840.113549.1.1.8" + && x509_digest_from_oid(&mask.hash.to_id_string()) == Some(mgf_digest) + && usize::try_from(parameters.salt_length()).is_ok_and(|minimum| salt_len >= minimum) + } + + fn x509_digest_from_oid(oid: &str) -> Option { + match oid { + "1.3.14.3.2.26" => Some(DigestAlgorithm::Sha1), + "2.16.840.1.101.3.4.2.1" => Some(DigestAlgorithm::Sha256), + "2.16.840.1.101.3.4.2.2" => Some(DigestAlgorithm::Sha384), + "2.16.840.1.101.3.4.2.3" => Some(DigestAlgorithm::Sha512), + _ => None, } - RsaPublicKey::from_pkcs1_der(&spki.subject_public_key.data).ok() } fn dsa_der_to_xmldsig(signature: &[u8]) -> Option> { @@ -1456,10 +1499,12 @@ mod tests { #[cfg(feature = "xmldsig")] #[test] fn rustcrypto_provider_verifies_parameterized_rsa_pss_certificates() { + use der::{Decode as _, Encode as _}; use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng}; use rsa::{RsaPrivateKey, pkcs8::EncodePublicKey, pss::SigningKey as RsaPssSigningKey}; use sha2::Sha256; use signature::{RandomizedSigner, SignatureEncoding}; + use x509_cert::spki::{AlgorithmIdentifierOwned, ObjectIdentifier}; // X.509 RSASSA-PSS carries salt and MGF parameters that cannot be // represented by the XMLDSig SignatureAlgorithm enum. @@ -1492,15 +1537,20 @@ mod tests { .expect("standard RSA-PSS parameters must be supported") ); - let mut pss_spki = public_key.as_bytes().to_vec(); - let rsa_encryption_oid = [ - 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, - ]; - let oid_offset = pss_spki - .windows(rsa_encryption_oid.len()) - .position(|window| window == rsa_encryption_oid) - .expect("RSA SPKI must identify rsaEncryption"); - pss_spki[oid_offset + rsa_encryption_oid.len() - 1] = 0x0a; + let mut pss_spki = x509_cert::SubjectPublicKeyInfo::from_der(public_key.as_bytes()) + .expect("RSA SPKI must decode"); + let pss_parameters = der::asn1::Any::from_der(&[ + 0x30, 0x34, 0xa0, 0x0f, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, + 0x04, 0x02, 0x01, 0x05, 0x00, 0xa1, 0x1c, 0x30, 0x1a, 0x06, 0x09, 0x2a, 0x86, 0x48, + 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x08, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, + 0x65, 0x03, 0x04, 0x02, 0x01, 0x05, 0x00, 0xa2, 0x03, 0x02, 0x01, 0x20, + ]) + .expect("standard SHA-256 PSS parameters must decode"); + pss_spki.algorithm = AlgorithmIdentifierOwned { + oid: ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.10"), + parameters: Some(pss_parameters), + }; + let pss_spki = pss_spki.to_der().expect("PSS SPKI must encode"); assert!( RUST_CRYPTO_PROVIDER @@ -1516,6 +1566,30 @@ mod tests { ) .expect("RFC 4055 PSS SubjectPublicKeyInfo must be supported") ); + + for incompatible in [ + X509SignatureAlgorithm::RsaPss { + digest: DigestAlgorithm::Sha384, + mgf_digest: DigestAlgorithm::Sha256, + salt_len: 32, + }, + X509SignatureAlgorithm::RsaPss { + digest: DigestAlgorithm::Sha256, + mgf_digest: DigestAlgorithm::Sha384, + salt_len: 32, + }, + X509SignatureAlgorithm::RsaPss { + digest: DigestAlgorithm::Sha256, + mgf_digest: DigestAlgorithm::Sha256, + salt_len: 16, + }, + ] { + assert!( + !RUST_CRYPTO_PROVIDER + .verify_x509_signature(incompatible, signed_data, &signature, &pss_spki,) + .expect("incompatible PSS key restrictions are invalid, not unsupported") + ); + } } #[cfg(feature = "xmlenc")] diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 7fccfdc7..a22f0d34 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -17,6 +17,7 @@ use super::{ X509ChainOptions, X509DataInfo, parse::{ EC_P256_OID, EC_P384_OID, ParseError, X509ChainBuildError, + build_x509_certificate_paths_to_selector_targets, 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, @@ -337,6 +338,7 @@ impl DefaultKeyResolver { trusted_prefix_len, trust, provider, + None, )?; Ok(available) } @@ -348,7 +350,8 @@ impl DefaultKeyResolver { trusted_prefix_len: usize, trust: &crate::policy::KeyTrustPolicy, provider: &dyn crate::provider::CryptoProvider, - ) -> Result<(), KeyResolutionError> { + selectors: Option<&X509DataInfo>, + ) -> Result { let candidates = build_x509_certificate_paths_to_trusted_prefix( available, signing_index, @@ -370,20 +373,80 @@ impl DefaultKeyResolver { _ => KeyResolutionError::InvalidCertificate, })?; let mut first_error = None; + let mut valid_path_without_selector_match = false; for candidate in candidates { available.certificate_chain = candidate; match self.verify_x509_policy(available, trust, provider) { - Ok(()) => return Ok(()), + Ok(()) => { + if match selectors { + Some(selectors) => { + selected_x509_path_matches_selectors(available, selectors, provider)? + } + None => true, + } { + return Ok(true); + } + valid_path_without_selector_match = true; + } Err(error) => { first_error.get_or_insert(error); } } } + if valid_path_without_selector_match { + return Ok(false); + } Err(first_error.unwrap_or(KeyResolutionError::Chain( super::X509ChainError::UntrustedRoot, ))) } + fn select_x509_selector_path( + &self, + available: &mut X509DataInfo, + signing_index: usize, + matching_indices: &[usize], + trust: &crate::policy::KeyTrustPolicy, + provider: &dyn crate::provider::CryptoProvider, + selectors: &X509DataInfo, + ) -> Result { + let targets = matching_indices + .iter() + .copied() + .filter(|index| *index != signing_index) + .collect::>(); + if targets.is_empty() { + return Ok(false); + } + let candidates = build_x509_certificate_paths_to_selector_targets( + available, + signing_index, + &targets, + trust.max_x509_chain_depth, + trust.max_x509_candidate_paths, + provider, + ) + .map_err(|error| match error { + X509ChainBuildError::AmbiguousIssuer => KeyResolutionError::AmbiguousCertificate, + X509ChainBuildError::Provider(error) => { + KeyResolutionError::Chain(super::X509ChainError::Provider(error)) + } + X509ChainBuildError::UnsupportedSignatureAlgorithm { oid } => { + KeyResolutionError::Chain(super::X509ChainError::UnsupportedSignatureAlgorithm { + oid, + }) + } + _ => KeyResolutionError::InvalidCertificate, + })?; + for candidate in candidates { + available.certificate_chain = candidate; + if selected_x509_path_matches_selectors(available, selectors, provider)? { + return Ok(true); + } + } + Ok(false) + } + fn resolve_configured_x509( &self, info: &X509DataInfo, @@ -480,6 +543,7 @@ impl DefaultKeyResolver { } } }; + let matching_indices = matches.iter().map(|(index, _)| *index).collect::>(); // `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. @@ -487,18 +551,37 @@ impl DefaultKeyResolver { if signing_index < trusted_prefix_len || !trust.verify_x509_chains { vec![signing_index] } else { - self.select_valid_x509_path( + if !self.select_valid_x509_path( &mut available, signing_index, trusted_prefix_len, trust, provider, - )?; + Some(info), + )? { + return Ok(None); + } available.certificate_chain.clone() }; if trust.verify_x509_chains && signing_index < trusted_prefix_len { self.verify_x509_policy(&available, trust, provider)?; } + if !trust.verify_x509_chains || signing_index < trusted_prefix_len { + let direct_match = selected_x509_path_matches_selectors(&available, info, provider)?; + if !direct_match + && (signing_index < trusted_prefix_len + || !self.select_x509_selector_path( + &mut available, + signing_index, + &matching_indices, + trust, + provider, + info, + )?) + { + return Ok(None); + } + } Ok(Some(available)) } @@ -689,6 +772,39 @@ fn map_x509_selector_error(error: ParseError) -> DsigError { } } +fn selected_x509_path_matches_selectors( + available: &X509DataInfo, + selectors: &X509DataInfo, + provider: &dyn crate::provider::CryptoProvider, +) -> Result { + let selected = X509DataInfo { + subject_names: selectors.subject_names.clone(), + issuer_serials: selectors.issuer_serials.clone(), + skis: selectors.skis.clone(), + digests: selectors.digests.clone(), + certificates: available + .certificate_chain + .iter() + .map(|index| available.certificates[*index].clone()) + .collect(), + parsed_certificates: available + .certificate_chain + .iter() + .map(|index| available.parsed_certificates[*index].clone()) + .collect(), + ..X509DataInfo::default() + }; + x509_selector_categories_match_chain(&selected, provider).map_err(|error| match error { + ParseError::Provider(error) => { + KeyResolutionError::Chain(super::X509ChainError::Provider(error)) + } + ParseError::UnsupportedAlgorithm { uri } => { + KeyResolutionError::UnsupportedDigestAlgorithm(uri) + } + _ => KeyResolutionError::InvalidCertificate, + }) +} + fn rsa_key_value_to_spki_der( modulus: &[u8], exponent: &[u8], @@ -1995,6 +2111,34 @@ mod tests { vec![0, 2, 3] ); + let anchored_same_edge = x509_info( + vec![ + root.der().to_vec(), + leaf.der().to_vec(), + key_unsupported_intermediate.der().to_vec(), + signing_intermediate.der().to_vec(), + ], + 1, + ); + let first_candidate_unsupported = RejectSecondSha512Provider { + sha512_calls: AtomicUsize::new(0), + verification_calls: AtomicUsize::new(0), + reject_verification_call: Some(0), + rejected_verification_data: None, + }; + assert_eq!( + build_x509_certificate_paths_to_trusted_prefix( + &anchored_same_edge, + 1, + 1, + 4, + 8, + &first_candidate_unsupported, + ) + .expect("a later same-DN issuer must survive an earlier provider capability miss"), + vec![vec![1, 3, 0]] + ); + let mut unsupported_intermediate = signing_intermediate.der().to_vec(); let ecdsa_sha256_oid = [0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x02]; let offsets = unsupported_intermediate @@ -2173,6 +2317,46 @@ mod tests { assert_eq!(result.status, super::super::DsigStatus::Valid); } + #[test] + fn selectors_must_all_match_the_selected_certificate_path() { + // Selector categories may identify different certificates only when + // those certificates belong to the one path chosen for the signer. + let signing_certificate = certificate_der(include_str!( + "../../tests/fixtures/keys/rsa/rsa-2048-cert.pem" + )); + let issuer_certificate = + certificate_der(include_str!("../../tests/fixtures/keys/ca2cert.pem")); + let unrelated = generated_certificate_params("unrelated selector certificate", false) + .self_signed( + &rcgen::KeyPair::generate().expect("unrelated key generation should succeed"), + ) + .expect("unrelated certificate should be self-signable") + .der() + .to_vec(); + let digest = crate::provider::default_provider() + .digest(super::super::DigestAlgorithm::Sha256, &unrelated) + .expect("SHA-256 selector digest must be available"); + let key_info_xml = format!( + "CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US{}", + STANDARD.encode(digest) + ); + let document = roxmltree::Document::parse(&key_info_xml) + .expect("generated selector KeyInfo must be XML"); + let key_info = super::super::parse_key_info(document.root_element()) + .expect("generated selector KeyInfo must be structurally valid"); + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + lookup_certs: vec![signing_certificate, issuer_certificate, unrelated], + ..KeyResolverConfig::default() + }); + + assert!( + resolver + .resolve(Some(&key_info), SignatureAlgorithm::RsaSha256) + .expect("disjoint selector matches are a key miss") + .is_none() + ); + } + #[test] fn unmatched_x509_selector_does_not_resolve() { // A selector mismatch must not fall back to arbitrary configured key material. diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index 03145b20..8af63489 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -1334,10 +1334,60 @@ pub(crate) fn build_x509_certificate_paths_to_trusted_prefix( max_depth: usize, max_candidate_paths: usize, provider: &dyn crate::provider::CryptoProvider, +) -> Result>, X509ChainBuildError> { + if trusted_prefix_len > info.certificates.len() { + return Err(X509ChainBuildError::InconsistentMetadata); + } + build_x509_certificate_paths( + info, + signing_idx, + |index| index < trusted_prefix_len, + false, + max_depth, + max_candidate_paths, + provider, + ) +} + +/// Enumerate signature-valid paths that reach any candidate selector target. +/// A matching intermediate is retained as a candidate and traversal continues +/// so callers can test selector categories against every longer path as well. +pub(crate) fn build_x509_certificate_paths_to_selector_targets( + info: &X509DataInfo, + signing_idx: usize, + targets: &[usize], + max_depth: usize, + max_candidate_paths: usize, + provider: &dyn crate::provider::CryptoProvider, +) -> Result>, X509ChainBuildError> { + if targets + .iter() + .any(|index| *index >= info.certificates.len()) + { + return Err(X509ChainBuildError::InconsistentMetadata); + } + build_x509_certificate_paths( + info, + signing_idx, + |index| targets.contains(&index), + true, + max_depth, + max_candidate_paths, + provider, + ) +} + +fn build_x509_certificate_paths( + info: &X509DataInfo, + signing_idx: usize, + is_terminal: impl Fn(usize) -> bool, + continue_after_terminal: bool, + max_depth: usize, + max_candidate_paths: usize, + provider: &dyn crate::provider::CryptoProvider, ) -> 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); } @@ -1355,9 +1405,11 @@ pub(crate) fn build_x509_certificate_paths_to_trusted_prefix( let current_idx = *path .last() .expect("candidate path starts with signing certificate index"); - if current_idx < trusted_prefix_len { - completed.push(path); - continue; + if is_terminal(current_idx) { + completed.push(path.clone()); + if !continue_after_terminal { + continue; + } } if path.len() == max_depth { depth_exceeded = true; @@ -1384,11 +1436,10 @@ pub(crate) fn build_x509_certificate_paths_to_trusted_prefix( algorithm: Some(oid), }, )) => { - // Unsupported is an algorithm capability, so changing - // the issuer key cannot make this child verifiable. - // Prune this DFS branch but retain sibling paths. + // Provider capability can depend on the issuer SPKI, + // so retain the diagnostic but try every same-DN key. unsupported_oid.get_or_insert(oid); - break; + continue; } Err(super::X509ChainError::Provider(error)) => { return Err(X509ChainBuildError::Provider(error)); @@ -1605,133 +1656,143 @@ pub(crate) fn x509_selector_categories_match_chain( Ok(subject_match && issuer_serial_match && ski_match && digest_match) } -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, - ) -> 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 x509_attribute_values_equal( + left: &x509_cert::attr::AttributeTypeAndValue, + right: &x509_cert::attr::AttributeTypeAndValue, +) -> bool { + if left.oid != right.oid { + return false; } - - fn rdns_equal( - left: &x509_cert::name::RelativeDistinguishedName, - right: &x509_cert::name::RelativeDistinguishedName, - ) -> bool { - if left.len() != right.len() { - 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(' ') } - // 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 - }) - }) + _ => left.value == right.value, } +} - fn trailing_whitespace_is_escaped(value: &str) -> bool { - let Some(prefix) = value.as_bytes().strip_suffix(b" ") else { - return false; - }; - prefix +fn x509_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() - .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; - } + .enumerate() + .find(|(index, right_attribute)| { + !matched[*index] && x509_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; + }; + prefix + .iter() + .rev() + .take_while(|byte| **byte == b'\\') + .count() + % 2 + == 1 +} + +fn parse_distinguished_name(value: &str) -> Option { + let mut normalized = String::with_capacity(value.len()); + let mut chars = value + .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; } - - while normalized - .chars() - .next_back() - .is_some_and(|last| matches!(last, ' ' | '\t' | '\r' | '\n')) - && !trailing_whitespace_is_escaped(&normalized) - { - normalized.pop(); + 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); + } - normalized + while normalized + .chars() + .next_back() + .is_some_and(|last| matches!(last, ' ' | '\t' | '\r' | '\n')) + && !trailing_whitespace_is_escaped(&normalized) + { + normalized.pop(); } + normalized.parse().ok() +} - let parse_name = |value: &str| remove_separator_padding(value).parse::().ok(); - parse_name(left) - .zip(parse_name(right)) +pub(crate) fn distinguished_names_equal(left: &str, right: &str) -> bool { + parse_distinguished_name(left) + .zip(parse_distinguished_name(right)) .is_some_and(|(left, right)| { left.len() == right.len() && left .iter_rdn() .zip(right.iter_rdn()) - .all(|(left, right)| rdns_equal(left, right)) + .all(|(left, right)| x509_rdns_equal(left, right)) + }) +} + +pub(crate) fn distinguished_name_within_subtree(name: &str, subtree: &str) -> bool { + parse_distinguished_name(name) + .zip(parse_distinguished_name(subtree)) + .is_some_and(|(name, subtree)| { + subtree.len() <= name.len() + && name + .iter_rdn() + .zip(subtree.iter_rdn()) + .all(|(name, subtree)| x509_rdns_equal(name, subtree)) }) } diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index 181b3ccb..84a0a8a1 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -3,13 +3,17 @@ use std::time::{SystemTime, UNIX_EPOCH}; use x509_parser::{ - certificate::X509Certificate, extensions::ParsedExtension, prelude::FromDer, - revocation_list::CertificateRevocationList, time::ASN1Time, x509::AlgorithmIdentifier, + certificate::X509Certificate, + extensions::{GeneralName, NameConstraints, ParsedExtension}, + prelude::FromDer, + revocation_list::CertificateRevocationList, + time::ASN1Time, + x509::AlgorithmIdentifier, }; use super::{ X509DataInfo, - parse::{distinguished_names_equal, x509_name_to_rfc4514}, + parse::{distinguished_name_within_subtree, distinguished_names_equal, x509_name_to_rfc4514}, }; use crate::provider::X509SignatureAlgorithm; @@ -64,6 +68,30 @@ pub enum X509ChainError { /// Maximum permitted subordinate CA count. limit: u32, }, + /// A subordinate certificate is outside a CA's permitted name space. + #[error( + "certificate at chain position {position} violates name constraints from position {constraining_position}" + )] + NameConstraintViolation { + /// Position of the subordinate certificate. + position: usize, + /// Position of the CA carrying NameConstraints. + constraining_position: usize, + }, + /// A critical certificate extension is not implemented by path validation. + #[error("certificate at chain position {position} has unsupported critical extension {oid}")] + UnsupportedCriticalExtension { + /// Position of the certificate in the validated path. + position: usize, + /// Extension object identifier. + oid: String, + }, + /// NameConstraints is not a critical CA extension as required by RFC 5280. + #[error("certificate at chain position {position} has invalid NameConstraints placement")] + InvalidNameConstraints { + /// Position of the certificate carrying the invalid extension. + position: usize, + }, /// A certificate key usage extension forbids the required operation. #[error("certificate at chain position {position} does not permit {required}")] InvalidKeyUsage { @@ -204,7 +232,10 @@ fn validate_path( } else { validate_ca_constraints(cert, position)?; } + validate_critical_extensions(cert, position)?; } + validate_path_length_constraints(&path)?; + validate_name_constraints(&path)?; for (position, pair) in path.windows(2).enumerate() { let [child, issuer] = pair else { @@ -471,8 +502,7 @@ fn validate_ca_constraints( cert: &X509Certificate<'_>, position: usize, ) -> Result<(), X509ChainError> { - let constraints = cert - .extensions() + cert.extensions() .iter() .find_map(|extension| match extension.parsed_extension() { ParsedExtension::BasicConstraints(value) => Some(value), @@ -495,8 +525,33 @@ fn validate_ca_constraints( }); } - if let Some(limit) = constraints.path_len_constraint { - let subordinate_ca_count = position.saturating_sub(1); + Ok(()) +} + +fn basic_constraints( + cert: &X509Certificate<'_>, +) -> Option { + cert.extensions() + .iter() + .find_map(|extension| match extension.parsed_extension() { + ParsedExtension::BasicConstraints(value) => Some(value.clone()), + _ => None, + }) +} + +fn validate_path_length_constraints(path: &[X509Certificate<'_>]) -> Result<(), X509ChainError> { + for (position, cert) in path.iter().enumerate().skip(1) { + let Some(limit) = basic_constraints(cert).and_then(|value| value.path_len_constraint) + else { + continue; + }; + let subordinate_ca_count = path[1..position] + .iter() + .filter(|subordinate| { + basic_constraints(subordinate).is_some_and(|value| value.ca) + && !certificate_names_equal(subordinate.subject(), subordinate.issuer()) + }) + .count(); if subordinate_ca_count > limit as usize { return Err(X509ChainError::PathLengthExceeded { position, limit }); } @@ -504,6 +559,284 @@ fn validate_ca_constraints( Ok(()) } +fn validate_critical_extensions( + cert: &X509Certificate<'_>, + position: usize, +) -> Result<(), X509ChainError> { + for extension in cert + .extensions() + .iter() + .filter(|extension| extension.critical) + { + let oid = extension.oid.to_id_string(); + if !matches!( + oid.as_str(), + "2.5.29.15" | "2.5.29.17" | "2.5.29.19" | "2.5.29.30" + ) { + return Err(X509ChainError::UnsupportedCriticalExtension { position, oid }); + } + if matches!( + extension.parsed_extension(), + ParsedExtension::UnsupportedExtension { .. } + | ParsedExtension::ParseError { .. } + | ParsedExtension::Unparsed + ) { + return Err(X509ChainError::UnsupportedCriticalExtension { position, oid }); + } + } + Ok(()) +} + +fn validate_name_constraints(path: &[X509Certificate<'_>]) -> Result<(), X509ChainError> { + for (position, certificate) in path.iter().enumerate() { + if let Some(extension) = certificate + .extensions() + .iter() + .find(|extension| extension.oid.to_id_string() == "2.5.29.30") + && (position == 0 || !extension.critical) + { + return Err(X509ChainError::InvalidNameConstraints { position }); + } + } + for (constraining_position, issuer) in path.iter().enumerate().skip(1) { + let Some(constraints) = + issuer + .extensions() + .iter() + .find_map(|extension| match extension.parsed_extension() { + ParsedExtension::NameConstraints(value) => Some(value), + _ => None, + }) + else { + continue; + }; + ensure_supported_name_constraints(constraints, constraining_position)?; + for (position, subordinate) in path[..constraining_position].iter().enumerate() { + // The target certificate is always checked. Self-issued CA rollover + // certificates between it and the constraint issuer are exempt. + if position != 0 && certificate_names_equal(subordinate.subject(), subordinate.issuer()) + { + continue; + } + validate_certificate_names(subordinate, constraints, position, constraining_position)?; + } + } + Ok(()) +} + +fn ensure_supported_name_constraints( + constraints: &NameConstraints<'_>, + position: usize, +) -> Result<(), X509ChainError> { + for subtree in constraints + .permitted_subtrees + .iter() + .flatten() + .chain(constraints.excluded_subtrees.iter().flatten()) + { + if matches!( + subtree.base, + GeneralName::OtherName(..) + | GeneralName::X400Address(..) + | GeneralName::EDIPartyName(..) + | GeneralName::RegisteredID(..) + | GeneralName::Invalid(..) + ) { + return Err(X509ChainError::UnsupportedCriticalExtension { + position, + oid: "2.5.29.30".into(), + }); + } + } + Ok(()) +} + +fn validate_certificate_names( + certificate: &X509Certificate<'_>, + constraints: &NameConstraints<'_>, + position: usize, + constraining_position: usize, +) -> Result<(), X509ChainError> { + let subject = GeneralName::DirectoryName(certificate.subject().clone()); + validate_general_name(&subject, constraints, position, constraining_position)?; + for attribute in certificate.subject().iter_email() { + let email = attribute + .as_str() + .map_err(|error| X509ChainError::InvalidDer { + kind: "certificate subject emailAddress", + message: error.to_string(), + })?; + validate_general_name( + &GeneralName::RFC822Name(email), + constraints, + position, + constraining_position, + )?; + } + if let Some(names) = + certificate + .extensions() + .iter() + .find_map(|extension| match extension.parsed_extension() { + ParsedExtension::SubjectAlternativeName(value) => Some(&value.general_names), + _ => None, + }) + { + for name in names { + validate_general_name(name, constraints, position, constraining_position)?; + } + } + Ok(()) +} + +fn validate_general_name( + name: &GeneralName<'_>, + constraints: &NameConstraints<'_>, + position: usize, + constraining_position: usize, +) -> Result<(), X509ChainError> { + let permitted = constraints + .permitted_subtrees + .iter() + .flatten() + .filter(|subtree| general_names_have_same_form(name, &subtree.base)); + let mut has_permitted_form = false; + let mut matches_permitted = false; + for subtree in permitted { + has_permitted_form = true; + matches_permitted |= general_name_within_subtree(name, &subtree.base)?; + } + let excluded = constraints + .excluded_subtrees + .iter() + .flatten() + .filter(|subtree| general_names_have_same_form(name, &subtree.base)) + .try_fold(false, |matched, subtree| { + general_name_within_subtree(name, &subtree.base).map(|current| matched || current) + })?; + if excluded || (has_permitted_form && !matches_permitted) { + return Err(X509ChainError::NameConstraintViolation { + position, + constraining_position, + }); + } + Ok(()) +} + +fn general_names_have_same_form(left: &GeneralName<'_>, right: &GeneralName<'_>) -> bool { + matches!( + (left, right), + (GeneralName::RFC822Name(_), GeneralName::RFC822Name(_)) + | (GeneralName::DNSName(_), GeneralName::DNSName(_)) + | (GeneralName::DirectoryName(_), GeneralName::DirectoryName(_)) + | (GeneralName::URI(_), GeneralName::URI(_)) + | (GeneralName::IPAddress(_), GeneralName::IPAddress(_)) + ) +} + +fn general_name_within_subtree( + name: &GeneralName<'_>, + subtree: &GeneralName<'_>, +) -> Result { + Ok(match (name, subtree) { + (GeneralName::DNSName(name), GeneralName::DNSName(subtree)) => { + dns_name_within_subtree(name, subtree, true) + } + (GeneralName::RFC822Name(name), GeneralName::RFC822Name(subtree)) => { + email_within_subtree(name, subtree) + } + (GeneralName::DirectoryName(name), GeneralName::DirectoryName(subtree)) => { + let name = x509_name_to_rfc4514(name).map_err(|error| X509ChainError::InvalidDer { + kind: "certificate name constraint", + message: error.to_string(), + })?; + let subtree = + x509_name_to_rfc4514(subtree).map_err(|error| X509ChainError::InvalidDer { + kind: "certificate name constraint", + message: error.to_string(), + })?; + distinguished_name_within_subtree(&name, &subtree) + } + (GeneralName::URI(name), GeneralName::URI(subtree)) => { + uri_host(name).is_some_and(|host| dns_name_within_subtree(host, subtree, false)) + } + (GeneralName::IPAddress(name), GeneralName::IPAddress(subtree)) => { + ip_address_within_subtree(name, subtree) + } + _ => false, + }) +} + +fn dns_name_within_subtree(name: &str, subtree: &str, include_subdomains: bool) -> bool { + let name = name.trim_end_matches('.'); + let subtree = subtree.trim_end_matches('.'); + if let Some(domain) = subtree.strip_prefix('.') { + return name.len() > domain.len() + && name.as_bytes()[name.len() - domain.len() - 1] == b'.' + && name[name.len() - domain.len()..].eq_ignore_ascii_case(domain); + } + name.eq_ignore_ascii_case(subtree) + || (include_subdomains + && name.len() > subtree.len() + && name.as_bytes()[name.len() - subtree.len() - 1] == b'.' + && name[name.len() - subtree.len()..].eq_ignore_ascii_case(subtree)) +} + +fn email_within_subtree(name: &str, subtree: &str) -> bool { + let Some((local, domain)) = name.rsplit_once('@') else { + return false; + }; + if let Some((expected_local, expected_domain)) = subtree.rsplit_once('@') { + return local == expected_local && domain.eq_ignore_ascii_case(expected_domain); + } + dns_name_within_subtree(domain, subtree, false) +} + +fn uri_host(uri: &str) -> Option<&str> { + let authority = uri.split_once("://")?.1; + let authority = authority.split(['/', '?', '#']).next()?; + let host_port = authority + .rsplit_once('@') + .map_or(authority, |(_, host)| host); + if host_port.starts_with('[') { + return None; + } + let host = host_port + .rsplit_once(':') + .filter(|(_, port)| !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit())) + .map_or(host_port, |(host, _)| host); + (!host.is_empty() && host.parse::().is_err()).then_some(host) +} + +fn ip_address_within_subtree(address: &[u8], subtree: &[u8]) -> bool { + if subtree.len() != address.len().saturating_mul(2) || !matches!(address.len(), 4 | 16) { + return false; + } + let (network, mask) = subtree.split_at(address.len()); + if !ip_mask_is_contiguous(mask) { + return false; + } + address + .iter() + .zip(network) + .zip(mask) + .all(|((address, network), mask)| address & mask == network & mask) +} + +fn ip_mask_is_contiguous(mask: &[u8]) -> bool { + let mut zero_seen = false; + for byte in mask { + for bit in (0..8).rev() { + let set = byte & (1 << bit) != 0; + if zero_seen && set { + return false; + } + zero_seen |= !set; + } + } + true +} + fn verify_crls( path: &[X509Certificate<'_>], crl_der: &[Vec], @@ -580,6 +913,40 @@ mod tests { use std::time::Duration; use x509_parser::oid_registry::{OID_SIG_ECDSA_WITH_SHA256, OID_SIG_ECDSA_WITH_SHA384, Oid}; + 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 verify_generated_path( + certificates: Vec>, + trusted_anchor: Vec, + ) -> Result<(), X509ChainError> { + let info = X509DataInfo { + certificate_chain: (0..certificates.len()).collect(), + certificates, + ..X509DataInfo::default() + }; + let anchors = vec![trusted_anchor]; + verify_x509_certificate_chain( + &info, + &X509ChainOptions { + trusted_certs: &anchors, + verification_time: SystemTime::now(), + max_chain_depth: info.certificate_chain.len(), + check_crls: false, + }, + ) + } + #[test] fn x509_ecdsa_hash_oid_does_not_select_the_issuer_curve() { // RFC 5758 signature OIDs select the digest while SubjectPublicKeyInfo @@ -809,6 +1176,230 @@ mod tests { .expect("the stale DSA root must be replaced by the configured anchor"); } + #[test] + fn path_length_excludes_self_issued_rollover_certificates() { + // RFC 5280 excludes self-issued rollover CAs from pathLenConstraint; + // only non-self-issued intermediate CA certificates consume the limit. + let mut root_params = generated_certificate_params("rollover path authority", true); + root_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Constrained(0)); + let root = rcgen::CertifiedIssuer::self_signed( + root_params, + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("root should be self-signable"); + let rollover_params = generated_certificate_params("rollover path authority", true); + let rollover_key = + rcgen::KeyPair::generate().expect("rollover key generation should succeed"); + let rollover_certificate = rollover_params + .signed_by(&rollover_key, &root) + .expect("root should sign same-name rollover certificate"); + let rollover_issuer = rcgen::Issuer::from_params(&rollover_params, &rollover_key); + let leaf = generated_certificate_params("rollover path leaf", false) + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &rollover_issuer, + ) + .expect("rollover key should sign leaf certificate"); + + verify_generated_path( + vec![ + leaf.der().to_vec(), + rollover_certificate.der().to_vec(), + root.der().to_vec(), + ], + root.der().to_vec(), + ) + .expect("self-issued rollover must not consume a zero path-length allowance"); + } + + #[test] + fn ca_name_constraints_reject_disallowed_dns_names() { + let mut root_params = generated_certificate_params("constrained authority", true); + root_params.name_constraints = Some(rcgen::NameConstraints { + permitted_subtrees: vec![rcgen::GeneralSubtree::DnsName("example.com".into())], + excluded_subtrees: vec![rcgen::GeneralSubtree::DnsName("blocked.example.com".into())], + }); + let root = rcgen::CertifiedIssuer::self_signed( + root_params, + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("constrained root should be self-signable"); + + for (dns_name, accepted) in [ + ("www.example.com", true), + ("blocked.example.com", false), + ("www.example.net", false), + ] { + let leaf = rcgen::CertificateParams::new(vec![dns_name.into()]) + .expect("DNS SAN should be valid") + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &root, + ) + .expect("root should sign leaf certificate"); + assert_eq!( + verify_generated_path( + vec![leaf.der().to_vec(), root.der().to_vec()], + root.der().to_vec(), + ) + .is_ok(), + accepted, + "unexpected name-constraint result for {dns_name}" + ); + } + } + + #[test] + fn name_constraint_matchers_cover_email_uri_and_ip_forms() { + // RFC 5280 gives each GeneralName form distinct subtree semantics; + // exercise those rules directly so a DNS-only implementation cannot pass. + assert!(email_within_subtree("ops@example.com", "example.com")); + assert!(email_within_subtree("ops@example.com", "ops@example.com")); + assert!(!email_within_subtree( + "other@example.com", + "ops@example.com" + )); + assert_eq!( + uri_host("https://user@api.example.com:8443/path"), + Some("api.example.com") + ); + assert!(dns_name_within_subtree( + uri_host("https://api.example.com/path").expect("URI must expose a DNS host"), + ".example.com", + false, + )); + assert!(ip_address_within_subtree( + &[192, 0, 2, 42], + &[192, 0, 2, 0, 255, 255, 255, 0], + )); + assert!(!ip_address_within_subtree( + &[192, 0, 3, 42], + &[192, 0, 2, 0, 255, 255, 255, 0], + )); + assert!(!ip_address_within_subtree( + &[192, 0, 2, 42], + &[192, 0, 2, 0, 255, 0, 255, 0], + )); + } + + #[test] + fn name_constraints_cover_subject_email_and_directory_name() { + // RFC 5280 requires subject emailAddress attributes to be checked even + // without a SAN, and directoryName constraints compare RDN subtrees. + let mut permitted_directory = rcgen::DistinguishedName::new(); + permitted_directory.push(rcgen::DnType::OrganizationName, "Example Corp"); + let mut root_params = generated_certificate_params("name authority", true); + root_params.name_constraints = Some(rcgen::NameConstraints { + permitted_subtrees: vec![ + rcgen::GeneralSubtree::Rfc822Name("example.com".into()), + rcgen::GeneralSubtree::DirectoryName(permitted_directory), + ], + excluded_subtrees: Vec::new(), + }); + let root = rcgen::CertifiedIssuer::self_signed( + root_params, + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("constrained root should be self-signable"); + + for (organization, email, accepted) in [ + ("Example Corp", "ops@example.com", true), + ("Other Corp", "ops@example.com", false), + ("Example Corp", "ops@example.net", false), + ] { + let mut leaf_params = generated_certificate_params("name-constrained leaf", false); + leaf_params.distinguished_name = rcgen::DistinguishedName::new(); + leaf_params + .distinguished_name + .push(rcgen::DnType::OrganizationName, organization); + leaf_params + .distinguished_name + .push(rcgen::DnType::CommonName, "name-constrained leaf"); + leaf_params.distinguished_name.push( + rcgen::DnType::CustomDnType(vec![1, 2, 840, 113549, 1, 9, 1]), + rcgen::DnValue::Ia5String( + email + .try_into() + .expect("test email must be a valid IA5String"), + ), + ); + let leaf = leaf_params + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &root, + ) + .expect("root should sign leaf certificate"); + let result = verify_generated_path( + vec![leaf.der().to_vec(), root.der().to_vec()], + root.der().to_vec(), + ); + assert_eq!( + result.is_ok(), + accepted, + "unexpected subject constraint result for {organization} / {email}: {result:?}", + ); + } + } + + #[test] + fn unknown_critical_certificate_extension_fails_closed() { + let root = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("critical-extension root", true), + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("root should be self-signable"); + let mut leaf_params = generated_certificate_params("critical-extension leaf", false); + let mut extension = + rcgen::CustomExtension::from_oid_content(&[1, 2, 3, 4], vec![0x05, 0x00]); + extension.set_criticality(true); + leaf_params.custom_extensions.push(extension); + let leaf = leaf_params + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &root, + ) + .expect("root should sign leaf certificate"); + + assert!( + verify_generated_path( + vec![leaf.der().to_vec(), root.der().to_vec()], + root.der().to_vec(), + ) + .is_err(), + "an unprocessed critical extension must reject the path" + ); + } + + #[test] + fn name_constraints_are_rejected_on_end_entity_certificates() { + // RFC 5280 limits NameConstraints to critical CA extensions; merely + // parsing the extension on an end entity must not count as processing it. + let root = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("name-placement root", true), + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("root should be self-signable"); + let mut leaf_params = generated_certificate_params("name-placement leaf", false); + leaf_params.name_constraints = Some(rcgen::NameConstraints { + permitted_subtrees: vec![rcgen::GeneralSubtree::DnsName("example.com".into())], + excluded_subtrees: Vec::new(), + }); + let leaf = leaf_params + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &root, + ) + .expect("root should sign leaf certificate"); + + assert!(matches!( + verify_generated_path( + vec![leaf.der().to_vec(), root.der().to_vec()], + root.der().to_vec(), + ), + Err(X509ChainError::InvalidNameConstraints { position: 0 }) + )); + } + #[test] fn dsa_certificate_rejects_mismatched_inner_signature_algorithm() { // The signed TBSCertificate algorithm is a separate RFC 5280 invariant; diff --git a/src/xmlenc/encrypt.rs b/src/xmlenc/encrypt.rs index 32122aae..212a38e6 100644 --- a/src/xmlenc/encrypt.rs +++ b/src/xmlenc/encrypt.rs @@ -278,6 +278,13 @@ impl EncryptedDataBuilder { key_name, .. } => { + if parameters.algorithm == super::KeyTransportAlgorithm::RsaOaepMgf1p + && parameters.mgf_digest != super::OaepDigestAlgorithm::Sha1 + { + return Err(XmlEncError::InvalidEncryptionConfig( + "legacy RSA-OAEP fixes MGF1 to SHA-1".into(), + )); + } if self .policy .key_transport_algorithms @@ -805,6 +812,7 @@ fn replace_range(xml: &str, range: std::ops::Range, replacement: &str) -> mod tests { use getrandom::SysRng; use getrandom::rand_core::UnwrapErr; + use rsa::pkcs8::DecodePublicKey as _; use rsa::{RsaPrivateKey, RsaPublicKey}; use super::*; @@ -902,6 +910,29 @@ mod tests { ); } + #[test] + fn legacy_oaep_rejects_non_sha1_mgf_during_configuration_validation() { + // The legacy URI has no MGF child on the wire, so accepting another + // digest here would let a permissive provider emit ambiguous ciphertext. + let public = RsaPublicKey::from_public_key_pem(include_str!( + "../../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem" + )) + .expect("tracked RSA public key must parse"); + let parameters = RsaOaepParameters { + algorithm: super::super::KeyTransportAlgorithm::RsaOaepMgf1p, + digest: OaepDigestAlgorithm::Sha256, + mgf_digest: OaepDigestAlgorithm::Sha256, + label: Vec::new(), + }; + let builder = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .add_recipient(EncryptionRecipient::rsa_oaep(public).oaep_parameters(parameters)); + + assert!(matches!( + builder.validate_configuration(), + Err(XmlEncError::InvalidEncryptionConfig(_)) + )); + } + #[test] fn encrypt_document_replaces_element_and_self_closing_content() { let key = [0x55; 16]; From c1caa4c1da0af5c5919c8edf5b362843ae6102f9 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 9 Aug 2026 23:24:01 +0300 Subject: [PATCH 39/63] fix(x509): harden name and CRL matching - distinguish unevaluable URI constraints from ordinary non-matches - match IA5 distinguished-name values using RFC 5280 rules - select rollover CRLs by key identity and authenticated applicability - document policy budgets and critical-extension support --- README.md | 2 +- docs/xmldsig.md | 13 ++- src/provider.rs | 8 +- src/xmldsig/parse.rs | 46 ++++++++- src/xmldsig/x509.rs | 168 ++++++++++++++++++++++++++++---- src/xmlenc/encrypt.rs | 24 ++++- tests/x509_chain_integration.rs | 73 +++++++++++++- 7 files changed, 308 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index d166877c..97600958 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Currently implemented (core paths): - ECDSA SHA-256/SHA-384 verification for P-256, P-384, and P-521 keys - Legacy DSA-SHA1 and HMAC-SHA1 verification, including truncated HMAC output - RSA PKCS#1 v1.5 and ECDSA SHA-256/SHA-384 signing with P-256/P-384 PKCS#8 keys -- Opt-in X.509 certificate-chain validation with explicit trust anchors, validity and path-length checks, NameConstraints, fail-closed critical-extension handling, authenticated CRLs, and typed RSA-PSS/Ed25519 certificate-signature support +- Opt-in X.509 certificate-chain validation with explicit trust anchors, validity and path-length checks, NameConstraints, authenticated CRLs, and typed RSA-PSS/Ed25519 certificate-signature support. Critical KeyUsage, SubjectAlternativeName, BasicConstraints, and NameConstraints are processed; every other critical extension fails closed. - Caller-supplied external references and X.509 `RetrievalMethod` resolution with bounded RFC 3986 `xml:base` processing and no 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 diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 76d46cd6..23e75c89 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -69,7 +69,12 @@ self-issued rollover certificates continue toward a distinct same-name issuer wh validates; neither condition can bypass the configured work bounds or trust anchor requirement. Path validation excludes self-issued rollover CAs from `pathLenConstraint`, applies supported RFC 5280 NameConstraints to every subordinate certificate, and rejects critical extensions whose -semantics are not implemented. When `X509Data` supplies multiple selector categories, every +semantics are not implemented. Processed critical certificate extensions are KeyUsage +(`2.5.29.15`), SubjectAlternativeName (`2.5.29.17`), BasicConstraints (`2.5.29.19`), and +NameConstraints (`2.5.29.30`). A chain using critical ExtendedKeyUsage (`2.5.29.37`) or +CertificatePolicies (`2.5.29.32`) therefore fails closed until those semantics are implemented; +encode them as non-critical only when that matches the issuing PKI's security contract. +When `X509Data` supplies multiple selector categories, every category must match certificates on the same selected, policy-valid path rather than unrelated certificates from the lookup pool. @@ -88,6 +93,9 @@ structurally excluded Manifest blocks can also produce an empty list. Callers mu 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. +They also share `ResourcePolicy::max_references` with ``: every parsed Manifest +reference consumes that operation-wide budget, including entries whose transforms are unsupported +and retained only as independent failure results. Malformed XMLDSig structure, unsupported algorithms in core signature processing, disallowed URIs in `` references, and inconsistent `KeyInfo` metadata are processing errors rather @@ -139,6 +147,9 @@ verification selects P-256, P-384, or P-521 from the SPKI independently of the h the built-in P-256 and P-384 signing keys support either ECDSA hash identifier. DSA-SHA1 and HMAC-SHA1 (including XMLDSig's byte-aligned 80-160-bit truncation range) are verify-only legacy algorithms. +RSA-SHA1 verification is default-deny: callers must explicitly enable +`VerificationPolicy::key_trust.allow_legacy_rsa_sha1`; selecting the algorithm in untrusted XML +does not opt the operation into legacy cryptography. RSA-SHA1 signing remains unsupported. X.509 path and CRL authentication additionally supports standard RSA-PSS with SHA-256/SHA-384/ SHA-512 parameters, including RFC 4055 issuer-key restrictions, and Ed25519. DSA-SHA256, broader HMAC verification/signing, XMLDSig `SignatureMethod` RSA-PSS, and implicit external resource loading are not currently supported. diff --git a/src/provider.rs b/src/provider.rs index 8aa8691d..10caa53b 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -653,7 +653,11 @@ mod rustcrypto_x509 { mgf_digest, salt_len, } => { - let Some(key) = rsa_pss_public_key_from_spki(issuer_spki_der, algorithm) else { + // RFC 4055 key restrictions are part of signature validity. Check + // them before provider capability so an incompatible key is a + // deterministic non-match even when the requested MGF is unsupported. + let Some(key) = compatible_rsa_pss_public_key_from_spki(issuer_spki_der, algorithm) + else { return Ok(false); }; if digest != mgf_digest { @@ -728,7 +732,7 @@ mod rustcrypto_x509 { Ok(verified.is_ok()) } - fn rsa_pss_public_key_from_spki( + fn compatible_rsa_pss_public_key_from_spki( spki_der: &[u8], signature_algorithm: X509SignatureAlgorithm, ) -> Option { diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index 8af63489..00428835 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -16,7 +16,10 @@ //! //! ``` -use der::Decode; +use der::{ + Decode, + asn1::{Ia5StringRef, ObjectIdentifier}, +}; use roxmltree::{Document, Node}; use x509_cert::ext::pkix::name::DirectoryString; use x509_cert::name::Name; @@ -1663,6 +1666,33 @@ fn x509_attribute_values_equal( if left.oid != right.oid { return false; } + const EMAIL_ADDRESS: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.9.1"); + const DOMAIN_COMPONENT: ObjectIdentifier = + ObjectIdentifier::new_unwrap("0.9.2342.19200300.100.1.25"); + if left.oid == EMAIL_ADDRESS { + let (Ok(left), Ok(right)) = ( + Ia5StringRef::try_from(&left.value), + Ia5StringRef::try_from(&right.value), + ) else { + return false; + }; + let (Some((left_local, left_domain)), Some((right_local, right_domain))) = ( + left.as_str().rsplit_once('@'), + right.as_str().rsplit_once('@'), + ) else { + return false; + }; + return left_local == right_local && left_domain.eq_ignore_ascii_case(right_domain); + } + if left.oid == DOMAIN_COMPONENT { + let (Ok(left), Ok(right)) = ( + Ia5StringRef::try_from(&left.value), + Ia5StringRef::try_from(&right.value), + ) else { + return false; + }; + return left.as_str().eq_ignore_ascii_case(right.as_str()); + } match ( DirectoryString::try_from(&left.value), DirectoryString::try_from(&right.value), @@ -3370,6 +3400,20 @@ BA== )); } + #[test] + fn distinguished_name_matching_applies_ia5_matching_rules() { + // RFC 5280 emailAddress matching preserves the local part while the + // domain is case-insensitive; domainComponent is case-insensitive too. + assert!(distinguished_names_equal( + "EMAIL=ops@EXAMPLE.COM,DC=EXAMPLE,DC=COM", + "EMAIL=ops@example.com,DC=example,DC=com" + )); + assert!(!distinguished_names_equal( + "EMAIL=OPS@example.com,DC=example,DC=com", + "EMAIL=ops@example.com,DC=example,DC=com" + )); + } + #[test] fn distinguished_name_matching_handles_rfc4514_escaped_values() { // Certificate values containing RFC 4514 separators and boundary spaces diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index 84a0a8a1..aa3d6378 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -657,8 +657,10 @@ fn validate_certificate_names( position: usize, constraining_position: usize, ) -> Result<(), X509ChainError> { - let subject = GeneralName::DirectoryName(certificate.subject().clone()); - validate_general_name(&subject, constraints, position, constraining_position)?; + if certificate.subject().iter().next().is_some() { + let subject = GeneralName::DirectoryName(certificate.subject().clone()); + validate_general_name(&subject, constraints, position, constraining_position)?; + } for attribute in certificate.subject().iter_email() { let email = attribute .as_str() @@ -704,15 +706,17 @@ fn validate_general_name( let mut matches_permitted = false; for subtree in permitted { has_permitted_form = true; - matches_permitted |= general_name_within_subtree(name, &subtree.base)?; + matches_permitted |= + general_name_within_subtree(name, &subtree.base)? == NameConstraintMatch::Match; } let excluded = constraints .excluded_subtrees .iter() .flatten() .filter(|subtree| general_names_have_same_form(name, &subtree.base)) - .try_fold(false, |matched, subtree| { - general_name_within_subtree(name, &subtree.base).map(|current| matched || current) + .try_fold(false, |rejected, subtree| { + general_name_within_subtree(name, &subtree.base) + .map(|current| rejected || current != NameConstraintMatch::NoMatch) })?; if excluded || (has_permitted_form && !matches_permitted) { return Err(X509ChainError::NameConstraintViolation { @@ -734,16 +738,29 @@ fn general_names_have_same_form(left: &GeneralName<'_>, right: &GeneralName<'_>) ) } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum NameConstraintMatch { + Match, + NoMatch, + Unevaluable, +} + +impl From for NameConstraintMatch { + fn from(matched: bool) -> Self { + if matched { Self::Match } else { Self::NoMatch } + } +} + fn general_name_within_subtree( name: &GeneralName<'_>, subtree: &GeneralName<'_>, -) -> Result { +) -> Result { Ok(match (name, subtree) { (GeneralName::DNSName(name), GeneralName::DNSName(subtree)) => { - dns_name_within_subtree(name, subtree, true) + dns_name_within_subtree(name, subtree, true).into() } (GeneralName::RFC822Name(name), GeneralName::RFC822Name(subtree)) => { - email_within_subtree(name, subtree) + email_within_subtree(name, subtree).into() } (GeneralName::DirectoryName(name), GeneralName::DirectoryName(subtree)) => { let name = x509_name_to_rfc4514(name).map_err(|error| X509ChainError::InvalidDer { @@ -755,15 +772,16 @@ fn general_name_within_subtree( kind: "certificate name constraint", message: error.to_string(), })?; - distinguished_name_within_subtree(&name, &subtree) - } - (GeneralName::URI(name), GeneralName::URI(subtree)) => { - uri_host(name).is_some_and(|host| dns_name_within_subtree(host, subtree, false)) + distinguished_name_within_subtree(&name, &subtree).into() } + (GeneralName::URI(name), GeneralName::URI(subtree)) => uri_host(name) + .map_or(NameConstraintMatch::Unevaluable, |host| { + dns_name_within_subtree(host, subtree, false).into() + }), (GeneralName::IPAddress(name), GeneralName::IPAddress(subtree)) => { - ip_address_within_subtree(name, subtree) + ip_address_within_subtree(name, subtree).into() } - _ => false, + _ => NameConstraintMatch::NoMatch, }) } @@ -837,6 +855,42 @@ fn ip_mask_is_contiguous(mask: &[u8]) -> bool { true } +fn certificate_subject_key_identifier<'a>( + certificate: &'a X509Certificate<'a>, +) -> Option<&'a [u8]> { + certificate + .extensions() + .iter() + .find_map(|extension| match extension.parsed_extension() { + ParsedExtension::SubjectKeyIdentifier(identifier) => Some(identifier.0), + _ => None, + }) +} + +fn crl_authority_key_matches( + crl: &CertificateRevocationList<'_>, + issuer: &X509Certificate<'_>, +) -> Result, X509ChainError> { + let authority_key = crl + .extensions() + .iter() + .find(|extension| extension.oid.to_id_string() == "2.5.29.35") + .map(|extension| match extension.parsed_extension() { + ParsedExtension::AuthorityKeyIdentifier(identifier) => { + Ok(identifier.key_identifier.as_ref().map(|key| key.0)) + } + _ => Err(X509ChainError::InvalidDer { + kind: "CRL AuthorityKeyIdentifier", + message: "extension could not be decoded".into(), + }), + }) + .transpose()? + .flatten(); + Ok(authority_key + .zip(certificate_subject_key_identifier(issuer)) + .map(|(authority, subject)| authority == subject)) +} + fn verify_crls( path: &[X509Certificate<'_>], crl_der: &[Vec], @@ -869,6 +923,16 @@ fn verify_crls( .iter() .filter(|(_, crl)| certificate_names_equal(crl.issuer(), cert.issuer())) { + let authority_key_match = crl_authority_key_matches(crl, issuer)?; + if authority_key_match == Some(false) { + continue; + } + if !verify_crl_signature_with_provider(crl, issuer, provider)? { + if authority_key_match == Some(true) { + return Err(X509ChainError::InvalidCrl(*crl_index)); + } + continue; + } if issuer .key_usage() .map_err(|error| X509ChainError::InvalidDer { @@ -886,7 +950,7 @@ fn verify_crls( && crl .next_update() .is_none_or(|next| verification_time <= next); - if !time_valid || !verify_crl_signature_with_provider(crl, issuer, provider)? { + if !time_valid { return Err(X509ChainError::InvalidCrl(*crl_index)); } if crl.iter_revoked_certificates().any(|revoked| { @@ -1341,6 +1405,70 @@ mod tests { } } + #[test] + fn empty_subject_with_critical_san_skips_directory_name_constraints() { + // RFC 5280 permits an empty subject when a critical SAN carries the + // identity. An absent DirectoryName need not match a permitted subtree. + let mut permitted_directory = rcgen::DistinguishedName::new(); + permitted_directory.push(rcgen::DnType::OrganizationName, "Example Corp"); + let mut root_params = generated_certificate_params("empty-subject authority", true); + root_params.name_constraints = Some(rcgen::NameConstraints { + permitted_subtrees: vec![rcgen::GeneralSubtree::DirectoryName(permitted_directory)], + excluded_subtrees: Vec::new(), + }); + let root = rcgen::CertifiedIssuer::self_signed( + root_params, + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("constrained root should be self-signable"); + let mut leaf_params = rcgen::CertificateParams::new(vec!["allowed.example".into()]) + .expect("DNS SAN should be valid"); + leaf_params.distinguished_name = rcgen::DistinguishedName::new(); + let leaf = leaf_params + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &root, + ) + .expect("root should sign empty-subject leaf"); + + verify_generated_path( + vec![leaf.der().to_vec(), root.der().to_vec()], + root.der().to_vec(), + ) + .expect("only present name forms should be constrained"); + } + + #[test] + fn unevaluable_uri_names_fail_closed_for_both_constraint_forms() { + use x509_parser::extensions::GeneralSubtree; + + // A URI without a DNS host is not a non-match: treating it that way + // would bypass excluded URI subtrees while rejecting permitted ones. + let uri = GeneralName::URI("urn:example:opaque"); + for constraints in [ + NameConstraints { + permitted_subtrees: Some(vec![GeneralSubtree { + base: GeneralName::URI(".example.com"), + }]), + excluded_subtrees: None, + }, + NameConstraints { + permitted_subtrees: None, + excluded_subtrees: Some(vec![GeneralSubtree { + base: GeneralName::URI(".example.com"), + }]), + }, + ] { + assert_eq!( + validate_general_name(&uri, &constraints, 0, 1), + Err(X509ChainError::NameConstraintViolation { + position: 0, + constraining_position: 1, + }) + ); + } + } + #[test] fn unknown_critical_certificate_extension_fails_closed() { let root = rcgen::CertifiedIssuer::self_signed( @@ -1360,13 +1488,15 @@ mod tests { ) .expect("root should sign leaf certificate"); - assert!( + assert_eq!( verify_generated_path( vec![leaf.der().to_vec(), root.der().to_vec()], root.der().to_vec(), - ) - .is_err(), - "an unprocessed critical extension must reject the path" + ), + Err(X509ChainError::UnsupportedCriticalExtension { + position: 0, + oid: "1.2.3.4".into(), + }) ); } diff --git a/src/xmlenc/encrypt.rs b/src/xmlenc/encrypt.rs index 212a38e6..147d90e2 100644 --- a/src/xmlenc/encrypt.rs +++ b/src/xmlenc/encrypt.rs @@ -1118,6 +1118,18 @@ mod tests { // 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 = "]>"; + assert!(matches!( + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key([0_u8; 16]) + .encrypt_document( + document, + DocumentEncryptionOptions { + element_id: None, + allow_dtd: true, + }, + ), + Err(XmlEncError::XmlParse(_)) + )); let mut policy = crate::policy::EncryptionPolicy::default(); policy.xml.allow_internal_dtd = true; EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) @@ -1189,13 +1201,23 @@ mod tests { assert!(matches!( EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) .direct_key([0_u8; 16]) - .policy(policy) + .policy(policy.clone()) .encrypt_binary(b"x"), Err(XmlEncError::PlaintextTooLarge { maximum: 0, actual: 1 }) )); + assert!(matches!( + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .recipient_aes_kw([0_u8; 16], KeyWrapAlgorithm::AesKw128) + .policy(policy) + .encrypt_binary(&[]), + Err(XmlEncError::TooManyRecipients { + maximum: 0, + actual: 1, + }) + )); } #[test] diff --git a/tests/x509_chain_integration.rs b/tests/x509_chain_integration.rs index 1d64bb13..8e8e6cd9 100644 --- a/tests/x509_chain_integration.rs +++ b/tests/x509_chain_integration.rs @@ -7,7 +7,7 @@ use std::{ use base64::{Engine as _, engine::general_purpose::STANDARD}; use rcgen::{ BasicConstraints, CertificateParams, CertificateRevocationListParams, IsCa, Issuer, - KeyIdMethod, KeyPair, KeyUsagePurpose, SerialNumber, date_time_ymd, + KeyIdMethod, KeyPair, KeyUsagePurpose, RevokedCertParams, SerialNumber, date_time_ymd, }; use roxmltree::Document; use xml_sec::xmldsig::{ @@ -596,3 +596,74 @@ fn rejects_crl_signed_by_certificate_without_crl_sign_usage() { }) ); } + +#[test] +fn same_name_ca_rollover_skips_crl_from_the_previous_key() { + // Issuer names are not key identities. A stale same-name CRL must not + // prevent the selected issuer's CRL from revoking the target certificate. + let mut root_params = CertificateParams::new(Vec::new()).unwrap(); + root_params + .distinguished_name + .push(rcgen::DnType::CommonName, "rollover root"); + root_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + root_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + let root = + rcgen::CertifiedIssuer::self_signed(root_params, KeyPair::generate().unwrap()).unwrap(); + + let issuer_params = || { + let mut params = CertificateParams::new(Vec::new()).unwrap(); + params + .distinguished_name + .push(rcgen::DnType::CommonName, "rollover issuer"); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + params + }; + let old_issuer = Issuer::new(issuer_params(), KeyPair::generate().unwrap()); + let current_key = KeyPair::generate().unwrap(); + let current_certificate = issuer_params().signed_by(¤t_key, &root).unwrap(); + let current_issuer = Issuer::new(issuer_params(), current_key); + + let mut leaf_params = CertificateParams::new(Vec::new()).unwrap(); + leaf_params.serial_number = Some(SerialNumber::from(42_u64)); + leaf_params + .distinguished_name + .push(rcgen::DnType::CommonName, "rollover leaf"); + let leaf = leaf_params + .signed_by(&KeyPair::generate().unwrap(), ¤t_issuer) + .unwrap(); + let crl_params = |number, revoked_certs| CertificateRevocationListParams { + this_update: date_time_ymd(2026, 3, 15), + next_update: date_time_ymd(2026, 4, 15), + crl_number: SerialNumber::from(number), + issuing_distribution_point: None, + revoked_certs, + key_identifier_method: KeyIdMethod::Sha256, + }; + let old_crl = crl_params(1_u64, Vec::new()) + .signed_by(&old_issuer) + .unwrap(); + let current_crl = crl_params( + 2_u64, + vec![RevokedCertParams { + serial_number: SerialNumber::from(42_u64), + revocation_time: date_time_ymd(2026, 3, 16), + reason_code: None, + invalidity_date: None, + }], + ) + .signed_by(¤t_issuer) + .unwrap(); + let mut info = generated_info(vec![ + leaf.der().to_vec(), + current_certificate.der().to_vec(), + root.der().to_vec(), + ]); + info.crls = vec![old_crl.der().to_vec(), current_crl.der().to_vec()]; + let anchors = [root.der().to_vec()]; + + assert_eq!( + verify_x509_certificate_chain(&info, &options(&anchors, true)), + Err(X509ChainError::Revoked(0)) + ); +} From 5f46334b41a7f636c625279f0242b4976a6ef9f6 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 9 Aug 2026 23:26:58 +0300 Subject: [PATCH 40/63] docs(xmldsig): clarify legacy policy --- docs/xmldsig.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 23e75c89..f9ef2ed3 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -146,10 +146,14 @@ verification, SHA-256/SHA-384/SHA-512 for signing, and ECDSA with SHA-256 or SHA verification selects P-256, P-384, or P-521 from the SPKI independently of the hash identifier; the built-in P-256 and P-384 signing keys support either ECDSA hash identifier. DSA-SHA1 and HMAC-SHA1 (including XMLDSig's byte-aligned 80-160-bit truncation range) are -verify-only legacy algorithms. +verify-only legacy algorithms. They follow the general +`VerificationPolicy::signature_algorithms` allowlist: the default `None` accepts every implemented +method, while deployments that require explicit legacy opt-in should supply a modern-only allowlist +and add these methods only for operations that need them. RSA-SHA1 verification is default-deny: callers must explicitly enable `VerificationPolicy::key_trust.allow_legacy_rsa_sha1`; selecting the algorithm in untrusted XML -does not opt the operation into legacy cryptography. RSA-SHA1 signing remains unsupported. +does not opt the operation into legacy cryptography, and this gate runs before key resolution. +RSA-SHA1 signing remains unsupported. X.509 path and CRL authentication additionally supports standard RSA-PSS with SHA-256/SHA-384/ SHA-512 parameters, including RFC 4055 issuer-key restrictions, and Ed25519. DSA-SHA256, broader HMAC verification/signing, XMLDSig `SignatureMethod` RSA-PSS, and implicit external resource loading are not currently supported. From 8ff58453ae03fbedba7081c32362156ea846aa36 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 10 Aug 2026 00:10:54 +0300 Subject: [PATCH 41/63] fix(xmlenc): enforce encryption invariants --- docs/xmlenc.md | 12 +++-- src/xmlenc/decrypt.rs | 70 +++++++++++++++++++++++++++ src/xmlenc/encrypt.rs | 108 ++++++++++++++++++++++++++++++++++++++++++ src/xmlenc/parse.rs | 41 ++-------------- src/xmlenc/types.rs | 48 +++++++++++++++++++ 5 files changed, 238 insertions(+), 41 deletions(-) diff --git a/docs/xmlenc.md b/docs/xmlenc.md index 9416175c..02d07acb 100644 --- a/docs/xmlenc.md +++ b/docs/xmlenc.md @@ -41,6 +41,9 @@ SHA-256/MGF1-SHA-256. XMLEnc 1.1 itself defaults omitted parameters to SHA-1/MGF those implicit legacy defaults. SHA-1 OAEP remains available only through explicit parameters. The legacy `rsa-oaep-mgf1p` URI fixes MGF1 to SHA-1; configuration validation rejects any other MGF digest before provider dispatch because that URI has no wire field capable of representing it. +The same `EncryptionMethod` structural validation applies to parsed XML and caller-constructed +typed values: an explicit `xenc11:MGF` is valid only with the XML Encryption 1.1 RSA-OAEP URI and +is rejected before key resolution on the legacy URI. `encrypt_document` selects the root or an element by `Id`, `ID`, or `id`, then replaces either the complete element or only its child content according to `EncryptedDataType`. See @@ -101,7 +104,8 @@ default; legacy documents that need an internal DTD can opt in through `decrypt_document_with_options` and `DocumentDecryptionOptions`. That API never installs an external entity resolver. -`encrypt_document` also checks the exact projected document length after cipher framing, base64, -and `EncryptedData` serialization but before allocating the replacement document. This keeps -generated Element and Content output within the same document policy accepted by reciprocal -decryption. +`encrypt_document` also checks the exact projected document byte length and XML node count after +cipher framing, base64, and `EncryptedData` serialization but before allocating the replacement +document. Element replacement subtracts the complete selected subtree; Content replacement +retains its selected element and subtracts only its descendants. This keeps generated Element and +Content output within the same document policy accepted by reciprocal decryption. diff --git a/src/xmlenc/decrypt.rs b/src/xmlenc/decrypt.rs index ab8d8af3..f5116a52 100644 --- a/src/xmlenc/decrypt.rs +++ b/src/xmlenc/decrypt.rs @@ -216,6 +216,7 @@ impl DecryptionKeyResolver for KekDecryptor { encrypted_key: Option<&EncryptedKey>, ) -> Result, XmlEncError> { let encrypted_key = encrypted_key.ok_or(XmlEncError::KeyNotFound)?; + encrypted_key.encryption_method.validate_structure()?; let wrapped = STANDARD .decode(&encrypted_key.cipher_data.value) .map_err(|error| XmlEncError::Base64(error.to_string()))?; @@ -257,6 +258,7 @@ impl DecryptionKeyResolver for PrivateKeyDecryptor { encrypted_key: Option<&EncryptedKey>, ) -> Result, XmlEncError> { let encrypted_key = encrypted_key.ok_or(XmlEncError::KeyNotFound)?; + encrypted_key.encryption_method.validate_structure()?; let wrapped = STANDARD .decode(&encrypted_key.cipher_data.value) .map_err(|error| XmlEncError::Base64(error.to_string()))?; @@ -574,6 +576,7 @@ fn validate_encrypted_key_policy( encrypted_key: &EncryptedKey, policy: &crate::policy::DecryptionPolicy, ) -> Result<(), XmlEncError> { + encrypted_key.encryption_method.validate_structure()?; let uri = &encrypted_key.encryption_method.algorithm; if let Ok(transport) = KeyTransportAlgorithm::from_uri(uri) { if policy @@ -1498,6 +1501,73 @@ mod tests { assert_eq!(resolver.candidate_calls.get(), 0); } + #[test] + fn typed_legacy_oaep_mgf_is_rejected_before_key_resolution() { + // The legacy RSA-OAEP URI fixes MGF1 to SHA-1 and cannot carry an MGF + // child. Typed input must preserve the parser's structural invariant. + let key = [0x43_u8; 16]; + let ciphertext = crate::provider::default_provider() + .encrypt_data(DataEncryptionAlgorithm::Aes128Gcm, &key, b"data") + .expect("test encryption must succeed"); + 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![EncryptedKey { + id: None, + recipient: None, + key_name: None, + encryption_method: super::super::EncryptionMethod { + algorithm: KeyTransportAlgorithm::RsaOaepMgf1p.uri().into(), + key_size_bits: None, + oaep_digest: Some(OaepDigestAlgorithm::Sha256.uri().into()), + mgf_algorithm: Some(OaepDigestAlgorithm::Sha384.mgf_uri().into()), + oaep_params: None, + }, + cipher_data: super::super::CipherData { + value: STANDARD.encode([0_u8; 256]), + }, + reference_list: None, + carried_key_name: None, + }], + cipher_data: super::super::CipherData { + value: STANDARD.encode(ciphertext), + }, + }; + let resolver = CountingResolver { + candidate_calls: Cell::new(0), + key: key.to_vec(), + }; + + assert!(matches!( + DecryptContext::new(&resolver).decrypt_data(&encrypted), + Err(XmlEncError::InvalidStructure(message)) + if message == "MGF is only valid for XML Encryption 1.1 RSA-OAEP" + )); + assert_eq!(resolver.candidate_calls.get(), 0); + + let private_key = RsaPrivateKey::from_pkcs8_pem(include_str!( + "../../tests/fixtures/keys/rsa/rsa-2048-key.pem" + )) + .expect("tracked RSA private key must parse"); + assert!(matches!( + PrivateKeyDecryptor::new(private_key).resolve_key( + crate::provider::default_provider(), + DataEncryptionAlgorithm::Aes128Gcm, + encrypted.encrypted_keys.first(), + ), + Err(XmlEncError::InvalidStructure(message)) + if message == "MGF is only valid for XML Encryption 1.1 RSA-OAEP" + )); + } + #[test] fn unknown_encrypted_key_algorithm_never_reaches_resolver() { // Extension URIs cannot bypass transport/wrap allowlists by relying on diff --git a/src/xmlenc/encrypt.rs b/src/xmlenc/encrypt.rs index 147d90e2..078fe15f 100644 --- a/src/xmlenc/encrypt.rs +++ b/src/xmlenc/encrypt.rs @@ -161,6 +161,13 @@ impl EncryptedDataBuilder { result.encrypted_data_xml.len(), self.policy.resources.max_encryption_document_bytes, )?; + validate_replacement_document_nodes( + &document, + selected, + &result.encrypted_data_xml, + ReplacementMode::ReplaceElement, + self.policy.resources.max_xml_nodes, + )?; Ok(replace_range(xml, range, &result.encrypted_data_xml)) } EncryptedDataType::Content => { @@ -190,6 +197,13 @@ impl EncryptedDataBuilder { inserted, self.policy.resources.max_encryption_document_bytes, )?; + validate_replacement_document_nodes( + &document, + selected, + &result.encrypted_data_xml, + ReplacementMode::ReplaceContent, + self.policy.resources.max_xml_nodes, + )?; replace_element_content(xml, range, source, boundaries, &result.encrypted_data_xml) } EncryptedDataType::Other(_) => Err(XmlEncError::InvalidEncryptionConfig( @@ -471,6 +485,44 @@ fn validate_replacement_document_len( validate_document_len(actual, maximum) } +fn validate_replacement_document_nodes( + document: &Document<'_>, + selected: Node<'_, '_>, + inserted_xml: &str, + replacement: ReplacementMode, + maximum: usize, +) -> Result<(), XmlEncError> { + let inserted = Document::parse_with_options( + inserted_xml, + ParsingOptions { + allow_dtd: false, + nodes_limit: crate::hard_limits::XML_DOCUMENT_NODE_CEILING, + entity_resolver: None, + }, + )?; + let inserted_nodes = inserted.root_element().descendants().count(); + let selected_nodes = selected.descendants().count(); + let removed_nodes = match replacement { + ReplacementMode::ReplaceElement => selected_nodes, + ReplacementMode::ReplaceContent => selected_nodes.saturating_sub(1), + }; + let actual = document + .root() + .descendants() + .count() + .saturating_sub(removed_nodes) + .saturating_add(inserted_nodes); + if actual > maximum { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: "encrypted document XML nodes", + maximum, + actual, + } + .into()); + } + Ok(()) +} + fn validate_content_key(algorithm: DataEncryptionAlgorithm, key: &[u8]) -> Result<(), XmlEncError> { if key.len() == algorithm.key_len() { Ok(()) @@ -1113,6 +1165,62 @@ mod tests { )); } + #[test] + fn document_encryption_bounds_projected_replacement_nodes() { + fn policy(max_xml_nodes: usize) -> crate::policy::EncryptionPolicy { + crate::policy::EncryptionPolicy { + resources: crate::policy::ResourcePolicy { + max_xml_nodes, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::EncryptionPolicy::default() + } + } + + // The source document fits the low limit, but the generated + // EncryptedData tree does not. Capture its exact projected node count. + let element_actual = match EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key([0_u8; 16]) + .policy(policy(2)) + .encrypt_document("", DocumentEncryptionOptions::default()) + { + Err(XmlEncError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: "encrypted document XML nodes", + maximum: 2, + actual, + })) if actual > 2 => actual, + result => panic!("expected projected element node bound, got {result:?}"), + }; + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key([0_u8; 16]) + .policy(policy(element_actual)) + .encrypt_document("", DocumentEncryptionOptions::default()) + .expect("the exact projected element node limit must be accepted"); + + // Content replacement retains the selected element. In particular, a + // self-closing element expands around EncryptedData without adding an + // extra source node to the projection. + let content_actual = match EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .encryption_type(EncryptedDataType::Content) + .direct_key([0_u8; 16]) + .policy(policy(2)) + .encrypt_document("", DocumentEncryptionOptions::default()) + { + Err(XmlEncError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: "encrypted document XML nodes", + maximum: 2, + actual, + })) if actual > 2 => actual, + result => panic!("expected projected content node bound, got {result:?}"), + }; + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .encryption_type(EncryptedDataType::Content) + .direct_key([0_u8; 16]) + .policy(policy(content_actual)) + .encrypt_document("", DocumentEncryptionOptions::default()) + .expect("the exact projected content node limit must be accepted"); + } + #[test] fn document_dtd_requires_policy_and_per_call_opt_in() { // Internal DTD parsing is a two-party decision: operation policy sets diff --git a/src/xmlenc/parse.rs b/src/xmlenc/parse.rs index a3084e82..98b60987 100644 --- a/src/xmlenc/parse.rs +++ b/src/xmlenc/parse.rs @@ -357,36 +357,15 @@ fn parse_encryption_method_with_limit( } } - let is_legacy_oaep = algorithm == "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p"; - let is_oaep11 = algorithm == "http://www.w3.org/2009/xmlenc11#rsa-oaep"; - if (oaep_params.is_some() || oaep_digest.is_some() || mgf_algorithm.is_some()) - && !is_legacy_oaep - && !is_oaep11 - { - return Err(XmlEncError::InvalidStructure( - "OAEP parameters are only valid for RSA-OAEP EncryptionMethod".into(), - )); - } - if mgf_algorithm.is_some() && !is_oaep11 { - return Err(XmlEncError::InvalidStructure( - "MGF is only valid for XML Encryption 1.1 RSA-OAEP".into(), - )); - } - if let (Some(actual), Some(expected)) = (key_size_bits, fixed_aes_key_size(&algorithm)) - && actual != expected - { - return Err(XmlEncError::InvalidStructure(format!( - "EncryptionMethod {algorithm} requires KeySize {expected}, got {actual}" - ))); - } - - Ok(EncryptionMethod { + let method = EncryptionMethod { algorithm, key_size_bits, oaep_digest, mgf_algorithm, oaep_params, - }) + }; + method.validate_structure()?; + Ok(method) } fn parse_key_size(node: Node<'_, '_>) -> Result { @@ -403,18 +382,6 @@ fn parse_key_size(node: Node<'_, '_>) -> Result { Ok(bits) } -fn fixed_aes_key_size(algorithm: &str) -> Option { - match algorithm { - "http://www.w3.org/2001/04/xmlenc#aes128-cbc" - | "http://www.w3.org/2009/xmlenc11#aes128-gcm" - | "http://www.w3.org/2001/04/xmlenc#kw-aes128" => Some(128), - "http://www.w3.org/2001/04/xmlenc#aes256-cbc" - | "http://www.w3.org/2009/xmlenc11#aes256-gcm" - | "http://www.w3.org/2001/04/xmlenc#kw-aes256" => Some(256), - _ => None, - } -} - fn parse_cipher_data(node: Node<'_, '_>) -> Result { require_element(node, XMLENC_NS, "CipherData")?; let mut children = element_children(node); diff --git a/src/xmlenc/types.rs b/src/xmlenc/types.rs index 27326003..db5392a5 100644 --- a/src/xmlenc/types.rs +++ b/src/xmlenc/types.rs @@ -376,6 +376,54 @@ pub struct EncryptionMethod { pub oaep_params: Option>, } +impl EncryptionMethod { + /// Validate invariants imposed by the selected algorithm URI. + /// + /// Parsed XML and caller-constructed typed values share this check so the + /// public typed API cannot express wire structures that XML parsing rejects. + pub(crate) fn validate_structure(&self) -> Result<(), XmlEncError> { + let is_legacy_oaep = self.algorithm == KeyTransportAlgorithm::RsaOaepMgf1p.uri(); + let is_oaep11 = self.algorithm == KeyTransportAlgorithm::RsaOaep11.uri(); + if (self.oaep_params.is_some() + || self.oaep_digest.is_some() + || self.mgf_algorithm.is_some()) + && !is_legacy_oaep + && !is_oaep11 + { + return Err(XmlEncError::InvalidStructure( + "OAEP parameters are only valid for RSA-OAEP EncryptionMethod".into(), + )); + } + if self.mgf_algorithm.is_some() && !is_oaep11 { + return Err(XmlEncError::InvalidStructure( + "MGF is only valid for XML Encryption 1.1 RSA-OAEP".into(), + )); + } + if let (Some(actual), Some(expected)) = + (self.key_size_bits, fixed_aes_key_size(&self.algorithm)) + && actual != expected + { + return Err(XmlEncError::InvalidStructure(format!( + "EncryptionMethod {} requires KeySize {expected}, got {actual}", + self.algorithm + ))); + } + Ok(()) + } +} + +fn fixed_aes_key_size(algorithm: &str) -> Option { + match algorithm { + "http://www.w3.org/2001/04/xmlenc#aes128-cbc" + | "http://www.w3.org/2009/xmlenc11#aes128-gcm" + | "http://www.w3.org/2001/04/xmlenc#kw-aes128" => Some(128), + "http://www.w3.org/2001/04/xmlenc#aes256-cbc" + | "http://www.w3.org/2009/xmlenc11#aes256-gcm" + | "http://www.w3.org/2001/04/xmlenc#kw-aes256" => Some(256), + _ => None, + } +} + /// Inline ciphertext data. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CipherData { From 082534bf14c2af4e6ad38d0b2029ec15250a4646 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 10 Aug 2026 00:56:29 +0300 Subject: [PATCH 42/63] fix(validation): enforce typed invariants --- docs/xmldsig.md | 12 +- docs/xmlenc.md | 6 +- src/xmldsig/x509.rs | 282 +++++++++++++++++++++++++++++++++++++++--- src/xmlenc/decrypt.rs | 55 ++++++++ src/xmlenc/types.rs | 14 +-- 5 files changed, 335 insertions(+), 34 deletions(-) diff --git a/docs/xmldsig.md b/docs/xmldsig.md index f9ef2ed3..30cc1494 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -69,7 +69,10 @@ self-issued rollover certificates continue toward a distinct same-name issuer wh validates; neither condition can bypass the configured work bounds or trust anchor requirement. Path validation excludes self-issued rollover CAs from `pathLenConstraint`, applies supported RFC 5280 NameConstraints to every subordinate certificate, and rejects critical extensions whose -semantics are not implemented. Processed critical certificate extensions are KeyUsage +semantics are not implemented. Empty certificate subjects require exactly one critical, non-empty +SubjectAlternativeName, and malformed IPv4/IPv6 constraint lengths or non-contiguous CIDR masks +fail the path rather than behaving as ordinary name mismatches. Processed critical certificate +extensions are KeyUsage (`2.5.29.15`), SubjectAlternativeName (`2.5.29.17`), BasicConstraints (`2.5.29.19`), and NameConstraints (`2.5.29.30`). A chain using critical ExtendedKeyUsage (`2.5.29.37`) or CertificatePolicies (`2.5.29.32`) therefore fails closed until those semantics are implemented; @@ -155,5 +158,8 @@ RSA-SHA1 verification is default-deny: callers must explicitly enable does not opt the operation into legacy cryptography, and this gate runs before key resolution. RSA-SHA1 signing remains unsupported. X.509 path and CRL authentication additionally supports standard RSA-PSS with SHA-256/SHA-384/ -SHA-512 parameters, including RFC 4055 issuer-key restrictions, and Ed25519. DSA-SHA256, broader HMAC verification/signing, XMLDSig -`SignatureMethod` RSA-PSS, and implicit external resource loading are not currently supported. +SHA-512 parameters, including RFC 4055 issuer-key restrictions, and Ed25519. Signature +`AlgorithmIdentifier` parameters are validated before provider dispatch: DSA, ECDSA, and Ed25519 +require absent parameters; RSA PKCS#1 accepts NULL or absent; RSA-PSS requires valid typed +parameters. DSA-SHA256, broader HMAC verification/signing, XMLDSig `SignatureMethod` RSA-PSS, and +implicit external resource loading are not currently supported. diff --git a/docs/xmlenc.md b/docs/xmlenc.md index 02d07acb..1c5db2e8 100644 --- a/docs/xmlenc.md +++ b/docs/xmlenc.md @@ -78,8 +78,10 @@ resource loading are rejected; only inline `CipherValue` is accepted. Encryption recipient counts are bounded before allocation. Decryption applies the same aggregate recipient ceiling while parsing, bounds each retained identifier, algorithm URI, key name, OAEP label, and reference URI, and rechecks caller-constructed `EncryptedData` before decoding or key resolution. -That typed-input check bounds both encoded and projected decoded `CipherValue` sizes, so callers -cannot bypass parser allocation limits by constructing the public model directly. +That typed-input check validates the top-level content `EncryptionMethod` and every embedded key +method before resolver dispatch, and bounds both encoded and projected decoded `CipherValue` +sizes. Callers therefore cannot bypass parser structural or allocation limits by constructing the +public model directly. 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 diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index aa3d6378..29657ab4 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -232,6 +232,7 @@ fn validate_path( } else { validate_ca_constraints(cert, position)?; } + validate_subject_identity(cert)?; validate_critical_extensions(cert, position)?; } validate_path_length_constraints(&path)?; @@ -254,6 +255,45 @@ fn validate_path( Ok(()) } +fn validate_subject_identity(cert: &X509Certificate<'_>) -> Result<(), X509ChainError> { + if cert.subject().iter().next().is_some() { + return Ok(()); + } + + let mut san_extensions = cert + .extensions() + .iter() + .filter(|extension| extension.oid.to_id_string() == "2.5.29.17"); + let Some(extension) = san_extensions.next() else { + return Err(invalid_subject_identity( + "an empty subject requires a critical SubjectAlternativeName", + )); + }; + if san_extensions.next().is_some() { + return Err(invalid_subject_identity( + "an empty subject must not contain duplicate SubjectAlternativeName extensions", + )); + } + let ParsedExtension::SubjectAlternativeName(names) = extension.parsed_extension() else { + return Err(invalid_subject_identity( + "SubjectAlternativeName could not be parsed", + )); + }; + if !extension.critical || names.general_names.is_empty() { + return Err(invalid_subject_identity( + "an empty subject requires a critical, non-empty SubjectAlternativeName", + )); + } + Ok(()) +} + +fn invalid_subject_identity(message: &str) -> X509ChainError { + X509ChainError::InvalidDer { + kind: "certificate subject identity", + message: message.into(), + } +} + #[cfg(test)] fn verify_certificate_signature( certificate: &X509Certificate<'_>, @@ -392,9 +432,56 @@ fn x509_signature_algorithm( "1.3.101.112" => X509SignatureAlgorithm::Ed25519, _ => return Err(X509ChainError::UnsupportedSignatureAlgorithm { oid }), }; + match &algorithm { + X509SignatureAlgorithm::Dsa(_) + | X509SignatureAlgorithm::Ecdsa(_) + | X509SignatureAlgorithm::Ed25519 => require_absent_signature_parameters(identifier)?, + X509SignatureAlgorithm::RsaPkcs1v15(_) => { + require_null_or_absent_signature_parameters(identifier)?; + } + X509SignatureAlgorithm::RsaPss { .. } => {} + } Ok(algorithm) } +fn require_absent_signature_parameters( + identifier: &AlgorithmIdentifier<'_>, +) -> Result<(), X509ChainError> { + if identifier.parameters.is_some() { + return Err(invalid_signature_parameters( + identifier, + "parameters must be absent", + )); + } + Ok(()) +} + +fn require_null_or_absent_signature_parameters( + identifier: &AlgorithmIdentifier<'_>, +) -> Result<(), X509ChainError> { + if identifier + .parameters + .as_ref() + .is_some_and(|parameters| parameters.tag() != x509_parser::asn1_rs::Tag::Null) + { + return Err(invalid_signature_parameters( + identifier, + "parameters must be NULL or absent", + )); + } + Ok(()) +} + +fn invalid_signature_parameters( + identifier: &AlgorithmIdentifier<'_>, + requirement: &str, +) -> X509ChainError { + X509ChainError::InvalidDer { + kind: "X.509 signature AlgorithmIdentifier parameters", + message: format!("{}: {requirement}", identifier.algorithm), + } +} + fn parse_rsa_pss_algorithm( identifier: &AlgorithmIdentifier<'_>, ) -> Result { @@ -634,6 +721,9 @@ fn ensure_supported_name_constraints( .flatten() .chain(constraints.excluded_subtrees.iter().flatten()) { + if let GeneralName::IPAddress(bytes) = &subtree.base { + validate_ip_name_constraint(bytes)?; + } if matches!( subtree.base, GeneralName::OtherName(..) @@ -779,7 +869,7 @@ fn general_name_within_subtree( dns_name_within_subtree(host, subtree, false).into() }), (GeneralName::IPAddress(name), GeneralName::IPAddress(subtree)) => { - ip_address_within_subtree(name, subtree).into() + ip_address_within_subtree(name, subtree)?.into() } _ => NameConstraintMatch::NoMatch, }) @@ -826,19 +916,39 @@ fn uri_host(uri: &str) -> Option<&str> { (!host.is_empty() && host.parse::().is_err()).then_some(host) } -fn ip_address_within_subtree(address: &[u8], subtree: &[u8]) -> bool { - if subtree.len() != address.len().saturating_mul(2) || !matches!(address.len(), 4 | 16) { - return false; +fn ip_address_within_subtree(address: &[u8], subtree: &[u8]) -> Result { + if !matches!(address.len(), 4 | 16) { + return Err(X509ChainError::InvalidDer { + kind: "IP subject alternative name", + message: format!("expected 4 or 16 octets, got {}", address.len()), + }); } - let (network, mask) = subtree.split_at(address.len()); - if !ip_mask_is_contiguous(mask) { - return false; + let (network, mask) = validate_ip_name_constraint(subtree)?; + if network.len() != address.len() { + return Ok(false); } - address + Ok(address .iter() .zip(network) .zip(mask) - .all(|((address, network), mask)| address & mask == network & mask) + .all(|((address, network), mask)| address & mask == network & mask)) +} + +fn validate_ip_name_constraint(subtree: &[u8]) -> Result<(&[u8], &[u8]), X509ChainError> { + if !matches!(subtree.len(), 8 | 32) { + return Err(X509ChainError::InvalidDer { + kind: "IP name constraint", + message: format!("expected 8 or 32 octets, got {}", subtree.len()), + }); + } + let (network, mask) = subtree.split_at(subtree.len() / 2); + if !ip_mask_is_contiguous(mask) { + return Err(X509ChainError::InvalidDer { + kind: "IP name constraint", + message: "network mask is not contiguous".into(), + }); + } + Ok((network, mask)) } fn ip_mask_is_contiguous(mask: &[u8]) -> bool { @@ -1166,6 +1276,56 @@ mod tests { } } + #[test] + fn x509_signature_parameters_follow_each_algorithm_profile() { + use x509_parser::asn1_rs::{Any, Tag}; + + // DSA, ECDSA, and Ed25519 signature identifiers require absent + // parameters. A NULL is not equivalent for these algorithm profiles. + for oid in [ + "1.2.840.10040.4.3", + "2.16.840.1.101.3.4.3.2", + "1.2.840.10045.4.1", + "1.2.840.10045.4.3.2", + "1.3.101.112", + ] { + let identifier = AlgorithmIdentifier::new( + Oid::from_str(oid).expect("static signature OID must parse"), + Some(Any::from_tag_and_data(Tag::Null, &[])), + ); + assert!(matches!( + x509_signature_algorithm(&identifier), + Err(X509ChainError::InvalidDer { + kind: "X.509 signature AlgorithmIdentifier parameters", + .. + }) + )); + } + + // RSA PKCS#1 signature identifiers accept absent and NULL parameters + // for interoperability, but no other ASN.1 value. + let rsa_oid = + Oid::from_str("1.2.840.113549.1.1.11").expect("static RSA signature OID must parse"); + for parameters in [None, Some(Any::from_tag_and_data(Tag::Null, &[]))] { + assert!(matches!( + x509_signature_algorithm(&AlgorithmIdentifier::new(rsa_oid.clone(), parameters)), + Ok(X509SignatureAlgorithm::RsaPkcs1v15( + super::super::DigestAlgorithm::Sha256 + )) + )); + } + assert!(matches!( + x509_signature_algorithm(&AlgorithmIdentifier::new( + rsa_oid, + Some(Any::from_tag_and_data(Tag::OctetString, &[])), + )), + Err(X509ChainError::InvalidDer { + kind: "X.509 signature AlgorithmIdentifier parameters", + .. + }) + )); + } + #[test] fn unknown_x509_signature_algorithm_remains_diagnosable() { let oid = "1.2.3.4.5"; @@ -1332,20 +1492,51 @@ mod tests { ".example.com", false, )); - assert!(ip_address_within_subtree( - &[192, 0, 2, 42], - &[192, 0, 2, 0, 255, 255, 255, 0], - )); - assert!(!ip_address_within_subtree( - &[192, 0, 3, 42], - &[192, 0, 2, 0, 255, 255, 255, 0], - )); - assert!(!ip_address_within_subtree( - &[192, 0, 2, 42], - &[192, 0, 2, 0, 255, 0, 255, 0], + assert!( + ip_address_within_subtree(&[192, 0, 2, 42], &[192, 0, 2, 0, 255, 255, 255, 0],) + .expect("valid IPv4 constraint must evaluate") + ); + assert!( + !ip_address_within_subtree(&[192, 0, 3, 42], &[192, 0, 2, 0, 255, 255, 255, 0],) + .expect("valid non-matching IPv4 constraint must evaluate") + ); + assert!(matches!( + ip_address_within_subtree(&[192, 0, 2, 42], &[192, 0, 2, 0, 255, 0, 255, 0],), + Err(X509ChainError::InvalidDer { + kind: "IP name constraint", + .. + }) )); } + #[test] + fn malformed_ip_name_constraints_fail_before_matching() { + use x509_parser::extensions::GeneralSubtree; + + let name = GeneralName::IPAddress(&[192, 0, 2, 42]); + for malformed in [ + &[192, 0, 2, 0, 255, 255, 255][..], + &[192, 0, 2, 0, 255, 0, 255, 0][..], + ] { + for permitted in [true, false] { + let subtree = GeneralSubtree { + base: GeneralName::IPAddress(malformed), + }; + let constraints = NameConstraints { + permitted_subtrees: permitted.then(|| vec![subtree.clone()]), + excluded_subtrees: (!permitted).then(|| vec![subtree]), + }; + assert!(matches!( + validate_general_name(&name, &constraints, 0, 1), + Err(X509ChainError::InvalidDer { + kind: "IP name constraint", + .. + }) + )); + } + } + } + #[test] fn name_constraints_cover_subject_email_and_directory_name() { // RFC 5280 requires subject emailAddress attributes to be checked even @@ -1405,6 +1596,57 @@ mod tests { } } + #[test] + fn empty_subject_requires_a_critical_nonempty_san() { + let root = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("subject identity authority", true), + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("root should be self-signable"); + + let mut missing_san = rcgen::CertificateParams::new(Vec::new()) + .expect("empty SAN list should produce certificate parameters"); + missing_san.distinguished_name = rcgen::DistinguishedName::new(); + let missing_san = missing_san + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &root, + ) + .expect("test issuer should sign an empty-subject certificate"); + + let mut noncritical_san = rcgen::CertificateParams::new(Vec::new()) + .expect("empty SAN list should produce certificate parameters"); + noncritical_san.distinguished_name = rcgen::DistinguishedName::new(); + // GeneralNames ::= SEQUENCE { dNSName [2] "a" }. Using a custom + // extension is intentional because rcgen correctly marks its normal + // SAN extension critical whenever the subject is empty. + noncritical_san + .custom_extensions + .push(rcgen::CustomExtension::from_oid_content( + &[2, 5, 29, 17], + vec![0x30, 0x03, 0x82, 0x01, b'a'], + )); + let noncritical_san = noncritical_san + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &root, + ) + .expect("test issuer should sign a non-critical-SAN certificate"); + + for leaf in [missing_san, noncritical_san] { + assert!(matches!( + verify_generated_path( + vec![leaf.der().to_vec(), root.der().to_vec()], + root.der().to_vec(), + ), + Err(X509ChainError::InvalidDer { + kind: "certificate subject identity", + .. + }) + )); + } + } + #[test] fn empty_subject_with_critical_san_skips_directory_name_constraints() { // RFC 5280 permits an empty subject when a critical SAN carries the diff --git a/src/xmlenc/decrypt.rs b/src/xmlenc/decrypt.rs index f5116a52..5d3f0305 100644 --- a/src/xmlenc/decrypt.rs +++ b/src/xmlenc/decrypt.rs @@ -82,6 +82,7 @@ impl<'a> DecryptContext<'a> { pub fn decrypt_data(&self, encrypted: &EncryptedData) -> Result { self.policy.resources.validate()?; validate_encrypted_data_metadata(encrypted, &self.policy)?; + encrypted.encryption_method.validate_structure()?; validate_recipient_count( encrypted.encrypted_keys.len(), self.policy.resources.max_encryption_recipients, @@ -795,6 +796,11 @@ mod tests { key: Vec, } + struct AllCallsResolver { + calls: Cell, + key: Vec, + } + impl DecryptionKeyResolver for CountingResolver { fn resolve_key( &self, @@ -811,6 +817,18 @@ mod tests { } } + impl DecryptionKeyResolver for AllCallsResolver { + fn resolve_key( + &self, + _provider: &dyn crate::provider::CryptoProvider, + _algorithm: DataEncryptionAlgorithm, + _encrypted_key: Option<&EncryptedKey>, + ) -> Result, XmlEncError> { + self.calls.set(self.calls.get() + 1); + Ok(self.key.clone()) + } + } + impl DecryptionKeyResolver for RecipientKeyResolver { fn resolve_key( &self, @@ -1568,6 +1586,43 @@ mod tests { )); } + #[test] + fn typed_content_method_is_validated_before_key_resolution() { + // Caller-constructed values bypass XML parsing, so a fixed-size AES + // KeySize mismatch must fail at the operation boundary. + let key = [0x44_u8; 16]; + let ciphertext = crate::provider::default_provider() + .encrypt_data(DataEncryptionAlgorithm::Aes128Gcm, &key, b"data") + .expect("test encryption must succeed"); + let encrypted = EncryptedData { + id: None, + encrypted_type: None, + key_name: None, + encryption_method: super::super::EncryptionMethod { + algorithm: DataEncryptionAlgorithm::Aes128Gcm.uri().into(), + key_size_bits: Some(256), + oaep_digest: None, + mgf_algorithm: None, + oaep_params: None, + }, + encrypted_keys: Vec::new(), + cipher_data: super::super::CipherData { + value: STANDARD.encode(ciphertext), + }, + }; + let resolver = AllCallsResolver { + calls: Cell::new(0), + key: key.to_vec(), + }; + + assert!(matches!( + DecryptContext::new(&resolver).decrypt_data(&encrypted), + Err(XmlEncError::InvalidStructure(message)) + if message.contains("requires KeySize 128, got 256") + )); + assert_eq!(resolver.calls.get(), 0); + } + #[test] fn unknown_encrypted_key_algorithm_never_reaches_resolver() { // Extension URIs cannot bypass transport/wrap allowlists by relying on diff --git a/src/xmlenc/types.rs b/src/xmlenc/types.rs index db5392a5..9553295f 100644 --- a/src/xmlenc/types.rs +++ b/src/xmlenc/types.rs @@ -413,15 +413,11 @@ impl EncryptionMethod { } fn fixed_aes_key_size(algorithm: &str) -> Option { - match algorithm { - "http://www.w3.org/2001/04/xmlenc#aes128-cbc" - | "http://www.w3.org/2009/xmlenc11#aes128-gcm" - | "http://www.w3.org/2001/04/xmlenc#kw-aes128" => Some(128), - "http://www.w3.org/2001/04/xmlenc#aes256-cbc" - | "http://www.w3.org/2009/xmlenc11#aes256-gcm" - | "http://www.w3.org/2001/04/xmlenc#kw-aes256" => Some(256), - _ => None, - } + let key_len = DataEncryptionAlgorithm::from_uri(algorithm) + .map(DataEncryptionAlgorithm::key_len) + .or_else(|_| KeyWrapAlgorithm::from_uri(algorithm).map(KeyWrapAlgorithm::key_len)) + .ok()?; + Some(key_len * 8) } /// Inline ciphertext data. From 22d01841445cb99bfb908fb96a05c8ba77fa9e35 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 10 Aug 2026 10:24:56 +0300 Subject: [PATCH 43/63] fix(validation): reject malformed metadata --- README.md | 2 +- docs/xmldsig.md | 3 ++- docs/xmlenc.md | 3 ++- src/xmldsig/x509.rs | 60 +++++++++++++++++++++++++++++++++++++++++++ src/xmlenc/decrypt.rs | 53 ++++++++++++++++++++++++++++++++++++++ src/xmlenc/types.rs | 5 ++++ 6 files changed, 123 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 97600958..26480282 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Currently implemented (core paths): - ECDSA SHA-256/SHA-384 verification for P-256, P-384, and P-521 keys - Legacy DSA-SHA1 and HMAC-SHA1 verification, including truncated HMAC output - RSA PKCS#1 v1.5 and ECDSA SHA-256/SHA-384 signing with P-256/P-384 PKCS#8 keys -- Opt-in X.509 certificate-chain validation with explicit trust anchors, validity and path-length checks, NameConstraints, authenticated CRLs, and typed RSA-PSS/Ed25519 certificate-signature support. Critical KeyUsage, SubjectAlternativeName, BasicConstraints, and NameConstraints are processed; every other critical extension fails closed. +- Opt-in X.509 certificate-chain validation with explicit trust anchors, validity and path-length checks, NameConstraints, authenticated CRLs, and typed RSA-PSS/Ed25519 certificate-signature support. Duplicate extension OIDs are rejected; critical KeyUsage, SubjectAlternativeName, BasicConstraints, and NameConstraints are processed, while every other critical extension fails closed. - Caller-supplied external references and X.509 `RetrievalMethod` resolution with bounded RFC 3986 `xml:base` processing and no 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 diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 30cc1494..8de0b223 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -69,7 +69,8 @@ self-issued rollover certificates continue toward a distinct same-name issuer wh validates; neither condition can bypass the configured work bounds or trust anchor requirement. Path validation excludes self-issued rollover CAs from `pathLenConstraint`, applies supported RFC 5280 NameConstraints to every subordinate certificate, and rejects critical extensions whose -semantics are not implemented. Empty certificate subjects require exactly one critical, non-empty +semantics are not implemented. Repeated extension OIDs are rejected certificate-wide before any +extension-specific interpretation. Empty certificate subjects require exactly one critical, non-empty SubjectAlternativeName, and malformed IPv4/IPv6 constraint lengths or non-contiguous CIDR masks fail the path rather than behaving as ordinary name mismatches. Processed critical certificate extensions are KeyUsage diff --git a/docs/xmlenc.md b/docs/xmlenc.md index 1c5db2e8..903f7dab 100644 --- a/docs/xmlenc.md +++ b/docs/xmlenc.md @@ -43,7 +43,8 @@ The legacy `rsa-oaep-mgf1p` URI fixes MGF1 to SHA-1; configuration validation re MGF digest before provider dispatch because that URI has no wire field capable of representing it. The same `EncryptionMethod` structural validation applies to parsed XML and caller-constructed typed values: an explicit `xenc11:MGF` is valid only with the XML Encryption 1.1 RSA-OAEP URI and -is rejected before key resolution on the legacy URI. +is rejected before key resolution on the legacy URI. Every supplied `KeySize` must be positive; +fixed-width AES methods additionally require it to match the selected algorithm. `encrypt_document` selects the root or an element by `Id`, `ID`, or `id`, then replaces either the complete element or only its child content according to `EncryptedDataType`. See diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index 29657ab4..708edb01 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -48,6 +48,14 @@ pub enum X509ChainError { /// Parser diagnostic. message: String, }, + /// A certificate repeats an extension OID, which RFC 5280 forbids. + #[error("certificate at chain position {position} repeats extension {oid}")] + DuplicateExtension { + /// Position of the malformed certificate in the candidate path. + position: usize, + /// Repeated extension object identifier. + oid: String, + }, /// The ordered embedded path cannot be completed to a configured anchor. #[error("certificate chain does not terminate at a trusted certificate")] UntrustedRoot, @@ -224,6 +232,7 @@ fn validate_path( .collect::, _>>()?; for (position, cert) in path.iter().enumerate() { + validate_unique_extensions(cert, position)?; if !cert.validity().is_valid_at(verification_time) { return Err(X509ChainError::CertificateNotValid(position)); } @@ -255,6 +264,20 @@ fn validate_path( Ok(()) } +fn validate_unique_extensions( + cert: &X509Certificate<'_>, + position: usize, +) -> Result<(), X509ChainError> { + let mut seen = std::collections::HashSet::with_capacity(cert.extensions().len()); + for extension in cert.extensions() { + let oid = extension.oid.to_id_string(); + if !seen.insert(oid.clone()) { + return Err(X509ChainError::DuplicateExtension { position, oid }); + } + } + Ok(()) +} + fn validate_subject_identity(cert: &X509Certificate<'_>) -> Result<(), X509ChainError> { if cert.subject().iter().next().is_some() { return Ok(()); @@ -1742,6 +1765,43 @@ mod tests { ); } + #[test] + fn duplicate_certificate_extension_oids_fail_closed() { + // RFC 5280 forbids repeated extension OIDs. Enforce that certificate-wide + // invariant before individual extension consumers select a first match. + let root = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("duplicate-extension root", true), + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("root should be self-signable"); + let mut leaf_params = generated_certificate_params("duplicate-extension leaf", false); + for _ in 0..2 { + leaf_params + .custom_extensions + .push(rcgen::CustomExtension::from_oid_content( + &[1, 2, 3, 4], + vec![0x05, 0x00], + )); + } + let leaf = leaf_params + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &root, + ) + .expect("root should sign leaf certificate"); + + assert_eq!( + verify_generated_path( + vec![leaf.der().to_vec(), root.der().to_vec()], + root.der().to_vec(), + ), + Err(X509ChainError::DuplicateExtension { + position: 0, + oid: "1.2.3.4".into(), + }) + ); + } + #[test] fn name_constraints_are_rejected_on_end_entity_certificates() { // RFC 5280 limits NameConstraints to critical CA extensions; merely diff --git a/src/xmlenc/decrypt.rs b/src/xmlenc/decrypt.rs index 5d3f0305..4ebea0be 100644 --- a/src/xmlenc/decrypt.rs +++ b/src/xmlenc/decrypt.rs @@ -1586,6 +1586,59 @@ mod tests { )); } + #[test] + fn typed_zero_key_size_is_rejected_before_key_resolution() { + // Parsed KeySize values are positive. Caller-constructed values must + // preserve the same invariant for algorithms without a fixed AES width. + let key = [0x45_u8; 16]; + let ciphertext = crate::provider::default_provider() + .encrypt_data(DataEncryptionAlgorithm::Aes128Gcm, &key, b"data") + .expect("test encryption must succeed"); + 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![EncryptedKey { + id: None, + recipient: None, + key_name: None, + encryption_method: super::super::EncryptionMethod { + algorithm: KeyTransportAlgorithm::RsaOaep11.uri().into(), + key_size_bits: Some(0), + oaep_digest: Some(OaepDigestAlgorithm::Sha256.uri().into()), + mgf_algorithm: Some(OaepDigestAlgorithm::Sha256.mgf_uri().into()), + oaep_params: None, + }, + cipher_data: super::super::CipherData { + value: STANDARD.encode([0_u8; 256]), + }, + reference_list: None, + carried_key_name: None, + }], + cipher_data: super::super::CipherData { + value: STANDARD.encode(ciphertext), + }, + }; + let resolver = CountingResolver { + candidate_calls: Cell::new(0), + key: key.to_vec(), + }; + + assert!(matches!( + DecryptContext::new(&resolver).decrypt_data(&encrypted), + Err(XmlEncError::InvalidStructure(message)) + if message == "KeySize must be a positive integer" + )); + assert_eq!(resolver.candidate_calls.get(), 0); + } + #[test] fn typed_content_method_is_validated_before_key_resolution() { // Caller-constructed values bypass XML parsing, so a fixed-size AES diff --git a/src/xmlenc/types.rs b/src/xmlenc/types.rs index 9553295f..5996d4e7 100644 --- a/src/xmlenc/types.rs +++ b/src/xmlenc/types.rs @@ -382,6 +382,11 @@ impl EncryptionMethod { /// Parsed XML and caller-constructed typed values share this check so the /// public typed API cannot express wire structures that XML parsing rejects. pub(crate) fn validate_structure(&self) -> Result<(), XmlEncError> { + if self.key_size_bits == Some(0) { + return Err(XmlEncError::InvalidStructure( + "KeySize must be a positive integer".into(), + )); + } let is_legacy_oaep = self.algorithm == KeyTransportAlgorithm::RsaOaepMgf1p.uri(); let is_oaep11 = self.algorithm == KeyTransportAlgorithm::RsaOaep11.uri(); if (self.oaep_params.is_some() From 7c59bab7b516309db2628d062608a928d76fe8fc Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 10 Aug 2026 10:55:05 +0300 Subject: [PATCH 44/63] fix(validation): reject malformed inputs --- README.md | 2 +- docs/xmldsig.md | 6 +- scripts/install-xmlsec1.sh | 44 +++++---- src/xmldsig/x509.rs | 160 +++++++++++++++++++++++++++++-- tests/fixtures/xmldsig/README.md | 2 + tests/install_xmlsec1.rs | 31 ++++++ 6 files changed, 215 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 26480282..b3131eab 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Currently implemented (core paths): - ECDSA SHA-256/SHA-384 verification for P-256, P-384, and P-521 keys - Legacy DSA-SHA1 and HMAC-SHA1 verification, including truncated HMAC output - RSA PKCS#1 v1.5 and ECDSA SHA-256/SHA-384 signing with P-256/P-384 PKCS#8 keys -- Opt-in X.509 certificate-chain validation with explicit trust anchors, validity and path-length checks, NameConstraints, authenticated CRLs, and typed RSA-PSS/Ed25519 certificate-signature support. Duplicate extension OIDs are rejected; critical KeyUsage, SubjectAlternativeName, BasicConstraints, and NameConstraints are processed, while every other critical extension fails closed. +- Opt-in X.509 certificate-chain validation with explicit trust anchors, validity and path-length checks, NameConstraints, authenticated CRLs, and typed RSA-PSS/Ed25519 certificate-signature support. Duplicate extension OIDs, malformed SAN identities, and invalid name constraints are rejected; critical KeyUsage, SubjectAlternativeName, BasicConstraints, and NameConstraints are processed, while every other critical extension fails closed. - Caller-supplied external references and X.509 `RetrievalMethod` resolution with bounded RFC 3986 `xml:base` processing and no 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 diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 8de0b223..9eab504d 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -71,8 +71,10 @@ Path validation excludes self-issued rollover CAs from `pathLenConstraint`, appl 5280 NameConstraints to every subordinate certificate, and rejects critical extensions whose semantics are not implemented. Repeated extension OIDs are rejected certificate-wide before any extension-specific interpretation. Empty certificate subjects require exactly one critical, non-empty -SubjectAlternativeName, and malformed IPv4/IPv6 constraint lengths or non-contiguous CIDR masks -fail the path rather than behaving as ordinary name mismatches. Processed critical certificate +SubjectAlternativeName, and malformed GeneralName entries fail path validation regardless of whether +the subject is empty. Invalid DNS-based constraints, including email-domain and URI-host forms, +malformed IPv4/IPv6 encodings, and non-contiguous CIDR masks fail the path rather than behaving as +ordinary name mismatches. Processed critical certificate extensions are KeyUsage (`2.5.29.15`), SubjectAlternativeName (`2.5.29.17`), BasicConstraints (`2.5.29.19`), and NameConstraints (`2.5.29.30`). A chain using critical ExtendedKeyUsage (`2.5.29.37`) or diff --git a/scripts/install-xmlsec1.sh b/scripts/install-xmlsec1.sh index 6e02fa13..c3d30f60 100755 --- a/scripts/install-xmlsec1.sh +++ b/scripts/install-xmlsec1.sh @@ -9,6 +9,24 @@ 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" +xmlsec_version_output() { + 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 +} + +xmlsec_version_is_expected() { + local output="$1" + local program="" + local version="" + read -r program version _ <<< "$output" || true + [[ "$program" == "xmlsec1" && "$version" == "$XMLSEC1_VERSION" ]] +} + if [[ "$prefix" != /* ]]; then printf 'XMLSEC1_PREFIX must be an absolute path: %s\n' "$prefix" >&2 exit 1 @@ -16,8 +34,13 @@ 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 + if version_output="$(xmlsec_version_output)" \ + && xmlsec_version_is_expected "$version_output"; then + printf '%s\n' "$version_output" + printf 'xmlsec1 %s is already installed at %s\n' "$XMLSEC1_VERSION" "$prefix" + exit 0 + fi + printf 'cached xmlsec1 at %s failed version validation; rebuilding\n' "$prefix" >&2 fi work_dir="$(mktemp -d "${TMPDIR:-/tmp}/xmlsec1-${XMLSEC1_VERSION}.XXXXXX")" @@ -91,21 +114,8 @@ fi mv "$staged_prefix" "$prefix" promoted_install=true -if [[ "$(uname -s)" == "Darwin" ]]; then - version_output="$( - DYLD_LIBRARY_PATH="$prefix/lib${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" \ - "$prefix/bin/xmlsec1" --version - )" -else - 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 +version_output="$(xmlsec_version_output)" +if ! xmlsec_version_is_expected "$version_output"; then printf 'xmlsec1 version mismatch: expected xmlsec1 %s, got %s\n' \ "$XMLSEC1_VERSION" "${version_output:-}" >&2 exit 1 diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index 708edb01..d654d45b 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -279,18 +279,19 @@ fn validate_unique_extensions( } fn validate_subject_identity(cert: &X509Certificate<'_>) -> Result<(), X509ChainError> { - if cert.subject().iter().next().is_some() { - return Ok(()); - } - + let subject_is_empty = cert.subject().iter().next().is_none(); let mut san_extensions = cert .extensions() .iter() .filter(|extension| extension.oid.to_id_string() == "2.5.29.17"); let Some(extension) = san_extensions.next() else { - return Err(invalid_subject_identity( - "an empty subject requires a critical SubjectAlternativeName", - )); + return if subject_is_empty { + Err(invalid_subject_identity( + "an empty subject requires a critical SubjectAlternativeName", + )) + } else { + Ok(()) + }; }; if san_extensions.next().is_some() { return Err(invalid_subject_identity( @@ -302,7 +303,16 @@ fn validate_subject_identity(cert: &X509Certificate<'_>) -> Result<(), X509Chain "SubjectAlternativeName could not be parsed", )); }; - if !extension.critical || names.general_names.is_empty() { + if names + .general_names + .iter() + .any(|name| matches!(name, GeneralName::Invalid(..))) + { + return Err(invalid_subject_identity( + "SubjectAlternativeName contains a malformed GeneralName", + )); + } + if subject_is_empty && (!extension.critical || names.general_names.is_empty()) { return Err(invalid_subject_identity( "an empty subject requires a critical, non-empty SubjectAlternativeName", )); @@ -744,8 +754,15 @@ fn ensure_supported_name_constraints( .flatten() .chain(constraints.excluded_subtrees.iter().flatten()) { - if let GeneralName::IPAddress(bytes) = &subtree.base { - validate_ip_name_constraint(bytes)?; + match &subtree.base { + GeneralName::DNSName(value) | GeneralName::URI(value) => { + validate_dns_name_constraint(value)?; + } + GeneralName::RFC822Name(value) => validate_email_name_constraint(value)?, + GeneralName::IPAddress(bytes) => { + validate_ip_name_constraint(bytes)?; + } + _ => {} } if matches!( subtree.base, @@ -764,6 +781,49 @@ fn ensure_supported_name_constraints( Ok(()) } +fn validate_email_name_constraint(value: &str) -> Result<(), X509ChainError> { + if let Some((local, domain)) = value.rsplit_once('@') { + if local.is_empty() || local.contains('@') || domain.starts_with('.') { + return Err(invalid_string_name_constraint(value)); + } + validate_dns_name_constraint(domain) + } else { + validate_dns_name_constraint(value) + } +} + +fn validate_dns_name_constraint(value: &str) -> Result<(), X509ChainError> { + let domain = value.strip_prefix('.').unwrap_or(value); + if domain.is_empty() + || domain.len() > 253 + || domain.split('.').any(|label| { + label.is_empty() + || label.len() > 63 + || !label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + || !label + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + || !label + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric) + }) + { + return Err(invalid_string_name_constraint(value)); + } + Ok(()) +} + +fn invalid_string_name_constraint(value: &str) -> X509ChainError { + X509ChainError::InvalidDer { + kind: "string name constraint", + message: format!("invalid RFC 5280 DNS-based constraint: {value:?}"), + } +} + fn validate_certificate_names( certificate: &X509Certificate<'_>, constraints: &NameConstraints<'_>, @@ -1560,6 +1620,47 @@ mod tests { } } + #[test] + fn malformed_string_name_constraints_fail_before_matching() { + use x509_parser::extensions::GeneralSubtree; + + // Matchers assume admitted string constraints have RFC 5280 syntax. + // Invalid values must not degrade into ordinary non-matches. + for malformed in [ + GeneralName::DNSName(""), + GeneralName::DNSName("example..com"), + GeneralName::RFC822Name("@example.com"), + GeneralName::URI("https://example.com"), + ] { + let constraints = NameConstraints { + permitted_subtrees: None, + excluded_subtrees: Some(vec![GeneralSubtree { base: malformed }]), + }; + assert!(matches!( + ensure_supported_name_constraints(&constraints, 1), + Err(X509ChainError::InvalidDer { + kind: "string name constraint", + .. + }) + )); + } + + for valid in [ + GeneralName::DNSName("example.com"), + GeneralName::DNSName(".example.com"), + GeneralName::RFC822Name("ops@example.com"), + GeneralName::RFC822Name("example.com"), + GeneralName::URI(".example.com"), + ] { + let constraints = NameConstraints { + permitted_subtrees: Some(vec![GeneralSubtree { base: valid }]), + excluded_subtrees: None, + }; + ensure_supported_name_constraints(&constraints, 1) + .expect("valid string constraints must remain supported"); + } + } + #[test] fn name_constraints_cover_subject_email_and_directory_name() { // RFC 5280 requires subject emailAddress attributes to be checked even @@ -1703,6 +1804,45 @@ mod tests { .expect("only present name forms should be constrained"); } + #[test] + fn malformed_general_names_in_san_fail_path_validation() { + let root = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("malformed-SAN root", true), + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("root should be self-signable"); + for empty_subject in [true, false] { + let mut leaf_params = generated_certificate_params("malformed-SAN leaf", false); + if empty_subject { + leaf_params.distinguished_name = rcgen::DistinguishedName::new(); + } + // GeneralNames ::= SEQUENCE { dNSName [2] }. + let mut malformed_san = rcgen::CustomExtension::from_oid_content( + &[2, 5, 29, 17], + vec![0x30, 0x03, 0x82, 0x01, 0xff], + ); + malformed_san.set_criticality(true); + leaf_params.custom_extensions.push(malformed_san); + let leaf = leaf_params + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &root, + ) + .expect("root should sign malformed-SAN leaf"); + + assert!(matches!( + verify_generated_path( + vec![leaf.der().to_vec(), root.der().to_vec()], + root.der().to_vec(), + ), + Err(X509ChainError::InvalidDer { + kind: "certificate subject identity", + .. + }) + )); + } + } + #[test] fn unevaluable_uri_names_fail_closed_for_both_constraint_forms() { use x509_parser::extensions::GeneralSubtree; diff --git a/tests/fixtures/xmldsig/README.md b/tests/fixtures/xmldsig/README.md index d54304b0..e2ec5d0c 100644 --- a/tests/fixtures/xmldsig/README.md +++ b/tests/fixtures/xmldsig/README.md @@ -5,6 +5,8 @@ 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. +`scripts/install-xmlsec1.sh` verifies both that source commit and the live +`xmlsec1 --version` output before reusing a cached development installation. ## Importing Vectors diff --git a/tests/install_xmlsec1.rs b/tests/install_xmlsec1.rs index 1f81d2ae..91ac1a62 100644 --- a/tests/install_xmlsec1.rs +++ b/tests/install_xmlsec1.rs @@ -220,3 +220,34 @@ fn exact_version_output_commits_the_new_installation() { "5fdd47dc35753438bdc38b6e96c1a3805c67a483\n" ); } + +#[test] +fn cached_installation_is_revalidated_before_reuse() { + // A commit marker authenticates the source used at installation time, not + // the executable currently occupying the prefix. + let harness = InstallHarness::new(); + let binary = harness.prefix.join("bin/xmlsec1"); + std::fs::write(&binary, "#!/bin/sh\nprintf 'xmlsec1 1.3.12 (openssl)\\n'\n") + .expect("cached test binary must be writable"); + let mut permissions = std::fs::metadata(&binary) + .expect("cached binary metadata must be readable") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&binary, permissions).expect("cached binary must be executable"); + std::fs::write( + harness.prefix.join(".xmlsec-source-commit"), + "5fdd47dc35753438bdc38b6e96c1a3805c67a483\n", + ) + .expect("cached source marker must be writable"); + + let status = harness.run(None, None, None, None); + + assert!( + status.success(), + "invalid cache must be rebuilt successfully" + ); + assert!( + !harness.prefix.join("sentinel").exists(), + "the stale cached prefix must not be reused" + ); +} From e8958a12805b8e604f89fb1c921a729257d09cba Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 10 Aug 2026 10:58:11 +0300 Subject: [PATCH 45/63] docs(policy): clarify legacy algorithm gates --- docs/xmldsig.md | 11 ++++++----- src/policy.rs | 3 ++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 9eab504d..f1cd132b 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -154,11 +154,12 @@ the built-in P-256 and P-384 signing keys support either ECDSA hash identifier. DSA-SHA1 and HMAC-SHA1 (including XMLDSig's byte-aligned 80-160-bit truncation range) are verify-only legacy algorithms. They follow the general `VerificationPolicy::signature_algorithms` allowlist: the default `None` accepts every implemented -method, while deployments that require explicit legacy opt-in should supply a modern-only allowlist -and add these methods only for operations that need them. -RSA-SHA1 verification is default-deny: callers must explicitly enable -`VerificationPolicy::key_trust.allow_legacy_rsa_sha1`; selecting the algorithm in untrusted XML -does not opt the operation into legacy cryptography, and this gate runs before key resolution. +method subject to its other policy gates, while deployments that require explicit legacy opt-in +should supply a modern-only allowlist and add these methods only for operations that need them. +RSA-SHA1 verification is independently default-deny: `None` in the general allowlist does not +bypass `VerificationPolicy::key_trust.allow_legacy_rsa_sha1`, which callers must explicitly enable. +Selecting the algorithm in untrusted XML does not opt the operation into legacy cryptography, and +this gate runs before key resolution. RSA-SHA1 signing remains unsupported. X.509 path and CRL authentication additionally supports standard RSA-PSS with SHA-256/SHA-384/ SHA-512 parameters, including RFC 4055 issuer-key restrictions, and Ed25519. Signature diff --git a/src/policy.rs b/src/policy.rs index dc0eb8aa..6d36ae42 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -260,7 +260,8 @@ impl KeyTrustPolicy { #[cfg(feature = "xmldsig")] #[derive(Debug, Clone, Default)] pub struct VerificationPolicy { - /// Allowed signature methods; `None` accepts every implemented method. + /// Allowed signature methods; `None` accepts every implemented method subject to + /// independent gates such as [`KeyTrustPolicy::allow_legacy_rsa_sha1`]. pub signature_algorithms: Option>, /// Allowed reference digest methods; `None` accepts every implemented method. pub digest_algorithms: Option>, From f66e7664f65bb5ec49cda768e6391098a8af380f Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 10 Aug 2026 12:45:57 +0300 Subject: [PATCH 46/63] fix(x509): reject malformed parameter sets --- docs/xmldsig.md | 8 ++-- src/provider.rs | 30 ++++++++++-- src/xmldsig/x509.rs | 111 ++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 134 insertions(+), 15 deletions(-) diff --git a/docs/xmldsig.md b/docs/xmldsig.md index f1cd132b..bef97cc9 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -48,8 +48,8 @@ Ed25519 are not collapsed into the narrower XMLDSig `SignatureMethod` enum. Unsu OIDs remain typed path errors rather than ordinary signature mismatches. Every certificate OID represented by that contract reaches the selected provider; the built-in provider may reject a capability such as ECDSA-SHA512 while a custom provider can implement it. For an -`id-RSASSA-PSS` issuer key, the built-in provider also enforces the SPKI hash, MGF, minimum salt, -and trailer-field restrictions before verifying a certificate signature. +`id-RSASSA-PSS` issuer key, the built-in provider requires typed SPKI parameters and enforces their +hash, MGF, minimum salt, and trailer-field restrictions before verifying a certificate signature. Custom resolvers that evaluate cryptographic key metadata should override `KeyResolver::resolve_with_policy_and_provider`; source-only resolvers can retain the default hook. @@ -73,8 +73,8 @@ semantics are not implemented. Repeated extension OIDs are rejected certificate- extension-specific interpretation. Empty certificate subjects require exactly one critical, non-empty SubjectAlternativeName, and malformed GeneralName entries fail path validation regardless of whether the subject is empty. Invalid DNS-based constraints, including email-domain and URI-host forms, -malformed IPv4/IPv6 encodings, and non-contiguous CIDR masks fail the path rather than behaving as -ordinary name mismatches. Processed critical certificate +malformed IPv4/IPv6 encodings, non-contiguous CIDR masks, nonzero `minimum`, and any `maximum` +distance fail the path rather than behaving as ordinary name mismatches. Processed critical certificate extensions are KeyUsage (`2.5.29.15`), SubjectAlternativeName (`2.5.29.17`), BasicConstraints (`2.5.29.19`), and NameConstraints (`2.5.29.30`). A chain using critical ExtendedKeyUsage (`2.5.29.37`) or diff --git a/src/provider.rs b/src/provider.rs index 10caa53b..8cd40f00 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -740,9 +740,8 @@ mod rustcrypto_x509 { match spki.algorithm.algorithm.to_id_string().as_str() { "1.2.840.113549.1.1.1" => RsaPublicKey::from_public_key_der(spki_der).ok(), "1.2.840.113549.1.1.10" => { - if let Some(parameters) = spki.algorithm.parameters.as_ref() - && !rsa_pss_key_parameters_allow(parameters, signature_algorithm) - { + let parameters = spki.algorithm.parameters.as_ref()?; + if !rsa_pss_key_parameters_allow(parameters, signature_algorithm) { return None; } RsaPublicKey::from_pkcs1_der(&spki.subject_public_key.data).ok() @@ -1541,6 +1540,31 @@ mod tests { .expect("standard RSA-PSS parameters must be supported") ); + let mut parameterless_pss_spki = + x509_cert::SubjectPublicKeyInfo::from_der(public_key.as_bytes()) + .expect("RSA SPKI must decode"); + parameterless_pss_spki.algorithm = AlgorithmIdentifierOwned { + oid: ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.10"), + parameters: None, + }; + let parameterless_pss_spki = parameterless_pss_spki + .to_der() + .expect("parameterless PSS SPKI must encode"); + assert!( + !RUST_CRYPTO_PROVIDER + .verify_x509_signature( + X509SignatureAlgorithm::RsaPss { + digest: DigestAlgorithm::Sha256, + mgf_digest: DigestAlgorithm::Sha256, + salt_len: 32, + }, + signed_data, + &signature, + ¶meterless_pss_spki, + ) + .expect("malformed PSS key parameters are invalid, not unsupported") + ); + let mut pss_spki = x509_cert::SubjectPublicKeyInfo::from_der(public_key.as_bytes()) .expect("RSA SPKI must decode"); let pss_parameters = der::asn1::Any::from_der(&[ diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index d654d45b..b17330c4 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -719,17 +719,17 @@ fn validate_name_constraints(path: &[X509Certificate<'_>]) -> Result<(), X509Cha } } for (constraining_position, issuer) in path.iter().enumerate().skip(1) { - let Some(constraints) = - issuer - .extensions() - .iter() - .find_map(|extension| match extension.parsed_extension() { - ParsedExtension::NameConstraints(value) => Some(value), - _ => None, - }) + let Some(extension) = issuer + .extensions() + .iter() + .find(|extension| extension.oid.to_id_string() == "2.5.29.30") else { continue; }; + let ParsedExtension::NameConstraints(constraints) = extension.parsed_extension() else { + continue; + }; + validate_name_constraint_distances(extension.value, constraining_position)?; ensure_supported_name_constraints(constraints, constraining_position)?; for (position, subordinate) in path[..constraining_position].iter().enumerate() { // The target certificate is always checked. Self-issued CA rollover @@ -744,6 +744,34 @@ fn validate_name_constraints(path: &[X509Certificate<'_>]) -> Result<(), X509Cha Ok(()) } +fn validate_name_constraint_distances( + extension_der: &[u8], + position: usize, +) -> Result<(), X509ChainError> { + use der::Decode as _; + + // x509-parser intentionally omits GeneralSubtree distance fields from its + // public model. Decode the raw extension as well so they cannot silently + // acquire the zero-minimum, unbounded semantics implemented below. + let constraints = + x509_cert::ext::pkix::NameConstraints::from_der(extension_der).map_err(|error| { + X509ChainError::InvalidDer { + kind: "NameConstraints", + message: error.to_string(), + } + })?; + let unsupported = constraints + .permitted_subtrees + .iter() + .flatten() + .chain(constraints.excluded_subtrees.iter().flatten()) + .any(|subtree| subtree.minimum != 0 || subtree.maximum.is_some()); + if unsupported { + return Err(X509ChainError::InvalidNameConstraints { position }); + } + Ok(()) +} + fn ensure_supported_name_constraints( constraints: &NameConstraints<'_>, position: usize, @@ -1661,6 +1689,73 @@ mod tests { } } + #[test] + fn unsupported_name_constraint_distances_fail_path_validation() { + use der::{Encode as _, asn1::Ia5String}; + use x509_cert::ext::pkix::{ + NameConstraints as EncodedNameConstraints, + constraints::name::GeneralSubtree as EncodedGeneralSubtree, + name::GeneralName as EncodedGeneralName, + }; + + // x509-parser exposes only GeneralSubtree::base. Exercise the complete + // extension DER so unsupported distance fields cannot disappear before + // RFC 5280 path validation sees them. + for (permitted, minimum, maximum) in [ + (true, 1, None), + (false, 1, None), + (true, 0, Some(1)), + (false, 0, Some(1)), + ] { + let dns_name = if permitted { + "example.com" + } else { + "blocked.example.com" + }; + let subtree = EncodedGeneralSubtree { + base: EncodedGeneralName::DnsName( + Ia5String::new(dns_name.as_bytes()).expect("valid DNS IA5String"), + ), + minimum, + maximum, + }; + let constraints = EncodedNameConstraints { + permitted_subtrees: permitted.then(|| vec![subtree.clone()]), + excluded_subtrees: (!permitted).then(|| vec![subtree]), + }; + let mut extension = rcgen::CustomExtension::from_oid_content( + &[2, 5, 29, 30], + constraints + .to_der() + .expect("NameConstraints must encode as DER"), + ); + extension.set_criticality(true); + + let mut root_params = generated_certificate_params("distance authority", true); + root_params.custom_extensions.push(extension); + let root = rcgen::CertifiedIssuer::self_signed( + root_params, + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("constrained root should be self-signable"); + let leaf = rcgen::CertificateParams::new(vec!["www.example.com".into()]) + .expect("leaf DNS SAN should be valid") + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &root, + ) + .expect("root should sign leaf certificate"); + + assert!(matches!( + verify_generated_path( + vec![leaf.der().to_vec(), root.der().to_vec()], + root.der().to_vec(), + ), + Err(X509ChainError::InvalidNameConstraints { position: 1 }) + )); + } + } + #[test] fn name_constraints_cover_subject_email_and_directory_name() { // RFC 5280 requires subject emailAddress attributes to be checked even From 10c17c577ebdd851e93cdc3f6a8d5684210399ec Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 10 Aug 2026 17:05:46 +0300 Subject: [PATCH 47/63] fix(x509): harden certificate validation --- src/provider.rs | 44 ++++++++ src/xmldsig/signature.rs | 23 +++-- src/xmldsig/x509.rs | 177 ++++++++++++++++++++++++++++++-- tests/x509_chain_integration.rs | 44 +++++++- 4 files changed, 269 insertions(+), 19 deletions(-) diff --git a/src/provider.rs b/src/provider.rs index 8cd40f00..d9c1ba36 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -612,8 +612,10 @@ mod rustcrypto_x509 { use sha2::{Sha256, Sha384, Sha512}; use signature::Verifier as _; use x509_parser::prelude::FromDer as _; + use x509_parser::public_key::RSAPublicKey as X509RsaPublicKey; use super::{ProviderError, X509SignatureAlgorithm}; + use crate::xmldsig::signature::validate_rsa_key_components; use crate::xmldsig::{ DigestAlgorithm, DsigError, SignatureAlgorithm, VerificationKey, VerifyingKey as _, }; @@ -737,6 +739,11 @@ mod rustcrypto_x509 { signature_algorithm: X509SignatureAlgorithm, ) -> Option { let (_, spki) = x509_parser::x509::SubjectPublicKeyInfo::from_der(spki_der).ok()?; + let (rest, raw_key) = X509RsaPublicKey::from_der(&spki.subject_public_key.data).ok()?; + if !rest.is_empty() { + return None; + } + validate_rsa_key_components(raw_key.modulus, raw_key.exponent, 2048).ok()?; match spki.algorithm.algorithm.to_id_string().as_str() { "1.2.840.113549.1.1.1" => RsaPublicKey::from_public_key_der(spki_der).ok(), "1.2.840.113549.1.1.10" => { @@ -1620,6 +1627,43 @@ mod tests { } } + #[cfg(feature = "xmldsig")] + #[test] + fn rustcrypto_provider_rejects_weak_rsa_pss_issuer_keys() { + use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng}; + use rsa::{RsaPrivateKey, pkcs8::EncodePublicKey, pss::SigningKey as RsaPssSigningKey}; + use sha2::Sha256; + use signature::{RandomizedSigner, SignatureEncoding}; + + let mut rng = ChaCha20Rng::from_seed([0x3c; 32]); + let private_key = + RsaPrivateKey::new(&mut rng, 1024).expect("deterministic weak RSA key generation"); + let public_key = private_key + .to_public_key() + .to_public_key_der() + .expect("weak RSA public key must encode as SPKI"); + let signed_data = b"certificate tbs bytes"; + let signature = RsaPssSigningKey::::new_with_salt_len(private_key, 32) + .try_sign_with_rng(&mut rng, signed_data) + .expect("weak RSA-PSS key can still produce a cryptographic signature") + .to_vec(); + + assert!( + !RUST_CRYPTO_PROVIDER + .verify_x509_signature( + X509SignatureAlgorithm::RsaPss { + digest: DigestAlgorithm::Sha256, + mgf_digest: DigestAlgorithm::Sha256, + salt_len: 32, + }, + signed_data, + &signature, + public_key.as_bytes(), + ) + .expect("weak issuer key is invalid, not an unsupported algorithm") + ); + } + #[cfg(feature = "xmlenc")] #[test] fn legacy_oaep_mgf_constraint_is_symmetric() { diff --git a/src/xmldsig/signature.rs b/src/xmldsig/signature.rs index ce5b093e..192f08fc 100644 --- a/src/xmldsig/signature.rs +++ b/src/xmldsig/signature.rs @@ -307,12 +307,20 @@ fn validate_rsa_public_key( minimum_modulus_bits: usize, ) -> Result<(), SignatureVerificationError> { ensure_rsa_signature_algorithm(algorithm)?; - let modulus_start = rsa - .modulus + validate_rsa_key_components(rsa.modulus, rsa.exponent, minimum_modulus_bits) +} + +/// Apply the RSA key-strength invariant shared by XMLDSig and X.509 algorithms. +pub(crate) fn validate_rsa_key_components( + modulus: &[u8], + exponent: &[u8], + minimum_modulus_bits: usize, +) -> Result<(), SignatureVerificationError> { + let modulus_start = modulus .iter() .position(|byte| *byte != 0) .ok_or(SignatureVerificationError::InvalidKeyDer)?; - let modulus = &rsa.modulus[modulus_start..]; + let modulus = &modulus[modulus_start..]; if modulus.is_empty() { return Err(SignatureVerificationError::InvalidKeyDer); } @@ -327,9 +335,12 @@ fn validate_rsa_public_key( return Err(SignatureVerificationError::InvalidKeyDer); } - let exponent = rsa - .try_exponent() - .map_err(|_| SignatureVerificationError::InvalidKeyDer)?; + if exponent.is_empty() || exponent[0] & 0x80 != 0 || exponent.len() > 8 { + return Err(SignatureVerificationError::InvalidKeyDer); + } + let mut exponent_bytes = [0_u8; 8]; + exponent_bytes[8 - exponent.len()..].copy_from_slice(exponent); + let exponent = u64::from_be_bytes(exponent_bytes); if !(3..=((1_u64 << 33) - 1)).contains(&exponent) || exponent % 2 == 0 { return Err(SignatureVerificationError::InvalidKeyDer); } diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index b17330c4..e3f75533 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -232,6 +232,7 @@ fn validate_path( .collect::, _>>()?; for (position, cert) in path.iter().enumerate() { + validate_certificate_serial(cert)?; validate_unique_extensions(cert, position)?; if !cert.validity().is_valid_at(verification_time) { return Err(X509ChainError::CertificateNotValid(position)); @@ -264,6 +265,24 @@ fn validate_path( Ok(()) } +fn validate_certificate_serial(cert: &X509Certificate<'_>) -> Result<(), X509ChainError> { + validate_certificate_serial_bytes(cert.raw_serial()) +} + +fn validate_certificate_serial_bytes(serial: &[u8]) -> Result<(), X509ChainError> { + if serial.is_empty() + || serial.len() > 20 + || serial[0] & 0x80 != 0 + || serial.iter().all(|byte| *byte == 0) + { + return Err(X509ChainError::InvalidDer { + kind: "certificate serial number", + message: "RFC 5280 requires a positive, non-zero value of at most 20 octets".into(), + }); + } + Ok(()) +} + fn validate_unique_extensions( cert: &X509Certificate<'_>, position: usize, @@ -303,14 +322,15 @@ fn validate_subject_identity(cert: &X509Certificate<'_>) -> Result<(), X509Chain "SubjectAlternativeName could not be parsed", )); }; - if names - .general_names - .iter() - .any(|name| matches!(name, GeneralName::Invalid(..))) - { - return Err(invalid_subject_identity( - "SubjectAlternativeName contains a malformed GeneralName", - )); + for name in &names.general_names { + if matches!(name, GeneralName::Invalid(..)) { + return Err(invalid_subject_identity( + "SubjectAlternativeName contains a malformed GeneralName", + )); + } + if let GeneralName::DNSName(name) = name { + validate_presented_dns_name(name)?; + } } if subject_is_empty && (!extension.critical || names.general_names.is_empty()) { return Err(invalid_subject_identity( @@ -821,7 +841,28 @@ fn validate_email_name_constraint(value: &str) -> Result<(), X509ChainError> { } fn validate_dns_name_constraint(value: &str) -> Result<(), X509ChainError> { - let domain = value.strip_prefix('.').unwrap_or(value); + if !dns_name_has_valid_syntax(value, true) { + return Err(invalid_string_name_constraint(value)); + } + Ok(()) +} + +fn validate_presented_dns_name(value: &str) -> Result<(), X509ChainError> { + if !dns_name_has_valid_syntax(value, false) { + return Err(X509ChainError::InvalidDer { + kind: "certificate DNS name", + message: format!("invalid RFC 5280 dNSName: {value:?}"), + }); + } + Ok(()) +} + +fn dns_name_has_valid_syntax(value: &str, allow_leading_dot: bool) -> bool { + let domain = if allow_leading_dot { + value.strip_prefix('.').unwrap_or(value) + } else { + value + }; if domain.is_empty() || domain.len() > 253 || domain.split('.').any(|label| { @@ -840,9 +881,9 @@ fn validate_dns_name_constraint(value: &str) -> Result<(), X509ChainError> { .is_some_and(u8::is_ascii_alphanumeric) }) { - return Err(invalid_string_name_constraint(value)); + return false; } - Ok(()) + true } fn invalid_string_name_constraint(value: &str) -> X509ChainError { @@ -1112,6 +1153,39 @@ fn crl_authority_key_matches( .map(|(authority, subject)| authority == subject)) } +fn validate_crl_extensions( + crl: &CertificateRevocationList<'_>, + crl_index: usize, +) -> Result<(), X509ChainError> { + for extension in crl.extensions() { + let oid = extension.oid.to_id_string(); + // IssuingDistributionPoint changes which certificates and issuers a CRL + // covers. It cannot be ignored while revocation entries are matched by serial. + if oid == "2.5.29.28" || (extension.critical && oid != "2.5.29.35") { + return Err(X509ChainError::InvalidCrl(crl_index)); + } + if oid == "2.5.29.35" + && !matches!( + extension.parsed_extension(), + ParsedExtension::AuthorityKeyIdentifier(_) + ) + { + return Err(X509ChainError::InvalidCrl(crl_index)); + } + } + for revoked in crl.iter_revoked_certificates() { + for extension in revoked.extensions() { + let oid = extension.oid.to_id_string(); + // certificateIssuer carries the issuer identity for indirect CRLs. + // No critical entry extension is safe to ignore during serial matching. + if oid == "2.5.29.29" || extension.critical { + return Err(X509ChainError::InvalidCrl(crl_index)); + } + } + } + Ok(()) +} + fn verify_crls( path: &[X509Certificate<'_>], crl_der: &[Vec], @@ -1134,6 +1208,7 @@ fn verify_crls( message: "trailing data".into(), }); } + validate_crl_extensions(&crl, idx)?; Ok((idx, crl)) }) .collect::, _>>()?; @@ -1584,6 +1659,53 @@ mod tests { } } + #[test] + fn name_constraints_reject_malformed_presented_dns_names() { + let mut root_params = generated_certificate_params("DNS syntax authority", true); + root_params.name_constraints = Some(rcgen::NameConstraints { + permitted_subtrees: vec![rcgen::GeneralSubtree::DnsName("example.com".into())], + excluded_subtrees: Vec::new(), + }); + let root = rcgen::CertifiedIssuer::self_signed( + root_params, + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("constrained root should be self-signable"); + + let mut leaf_params = generated_certificate_params("malformed DNS leaf", false); + let dns_name = b"bad..example.com"; + let mut san_der = vec![ + 0x30, + u8::try_from(dns_name.len() + 2).expect("test SAN must fit short-form DER"), + 0x82, + ]; + san_der.push(u8::try_from(dns_name.len()).expect("test DNS name must fit short-form DER")); + san_der.extend_from_slice(dns_name); + leaf_params + .custom_extensions + .push(rcgen::CustomExtension::from_oid_content( + &[2, 5, 29, 17], + san_der, + )); + let leaf = leaf_params + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &root, + ) + .expect("root should sign malformed-DNS leaf"); + + assert!(matches!( + verify_generated_path( + vec![leaf.der().to_vec(), root.der().to_vec()], + root.der().to_vec(), + ), + Err(X509ChainError::InvalidDer { + kind: "certificate DNS name", + .. + }) + )); + } + #[test] fn name_constraint_matchers_cover_email_uri_and_ip_forms() { // RFC 5280 gives each GeneralName form distinct subtree semantics; @@ -2037,6 +2159,39 @@ mod tests { ); } + #[test] + fn invalid_certificate_serial_numbers_fail_path_validation() { + for serial in [vec![0], vec![1; 21]] { + let root = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("serial root", true), + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("root should be self-signable"); + let mut leaf_params = generated_certificate_params("invalid serial leaf", false); + leaf_params.serial_number = Some(rcgen::SerialNumber::from_slice(&serial)); + let leaf = leaf_params + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &root, + ) + .expect("root should sign leaf certificate"); + + assert!(matches!( + verify_generated_path( + vec![leaf.der().to_vec(), root.der().to_vec()], + root.der().to_vec(), + ), + Err(X509ChainError::InvalidDer { + kind: "certificate serial number", + .. + }) + )); + } + + assert!(validate_certificate_serial_bytes(&[0x80]).is_err()); + assert!(validate_certificate_serial_bytes(&[1; 20]).is_ok()); + } + #[test] fn name_constraints_are_rejected_on_end_entity_certificates() { // RFC 5280 limits NameConstraints to critical CA extensions; merely diff --git a/tests/x509_chain_integration.rs b/tests/x509_chain_integration.rs index 8e8e6cd9..8a582156 100644 --- a/tests/x509_chain_integration.rs +++ b/tests/x509_chain_integration.rs @@ -6,8 +6,9 @@ use std::{ use base64::{Engine as _, engine::general_purpose::STANDARD}; use rcgen::{ - BasicConstraints, CertificateParams, CertificateRevocationListParams, IsCa, Issuer, - KeyIdMethod, KeyPair, KeyUsagePurpose, RevokedCertParams, SerialNumber, date_time_ymd, + BasicConstraints, CertificateParams, CertificateRevocationListParams, CrlDistributionPoint, + CrlIssuingDistributionPoint, CrlScope, IsCa, Issuer, KeyIdMethod, KeyPair, KeyUsagePurpose, + RevokedCertParams, SerialNumber, date_time_ymd, }; use roxmltree::Document; use xml_sec::xmldsig::{ @@ -535,6 +536,45 @@ fn rejects_malformed_crl_when_revocation_checking_is_enabled() { )); } +#[test] +fn rejects_crl_with_unprocessed_critical_scope() { + let mut root_params = CertificateParams::new(Vec::new()).unwrap(); + root_params + .distinguished_name + .push(rcgen::DnType::CommonName, "scoped CRL root"); + root_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + root_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + let root = + rcgen::CertifiedIssuer::self_signed(root_params, KeyPair::generate().unwrap()).unwrap(); + let leaf = CertificateParams::new(Vec::new()) + .unwrap() + .signed_by(&KeyPair::generate().unwrap(), &root) + .unwrap(); + let crl = CertificateRevocationListParams { + this_update: date_time_ymd(2026, 3, 15), + next_update: date_time_ymd(2026, 4, 15), + crl_number: SerialNumber::from(1_u64), + issuing_distribution_point: Some(CrlIssuingDistributionPoint { + distribution_point: CrlDistributionPoint { + uris: vec!["https://example.test/root.crl".into()], + }, + scope: Some(CrlScope::UserCertsOnly), + }), + revoked_certs: Vec::new(), + key_identifier_method: KeyIdMethod::Sha256, + } + .signed_by(&root) + .unwrap(); + let mut info = generated_info(vec![leaf.der().to_vec(), root.der().to_vec()]); + info.crls.push(crl.der().to_vec()); + let anchors = [root.der().to_vec()]; + + assert_eq!( + verify_x509_certificate_chain(&info, &options(&anchors, true)), + Err(X509ChainError::InvalidCrl(0)) + ); +} + #[test] fn rejects_crl_signed_by_certificate_without_crl_sign_usage() { let mut root_params = CertificateParams::new(Vec::new()).unwrap(); From 83a76ad092433b2ed892df609400bf469e566160 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 10 Aug 2026 20:30:51 +0300 Subject: [PATCH 48/63] fix(x509): validate certificate identities - apply serial limits to the integer magnitude rather than DER sign padding - validate every RFC 5280 Internet GeneralName form in SubjectAlternativeName - clarify that PKIX DNS syntax is distinct from TLS wildcard matching --- src/xmldsig/x509.rs | 338 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 326 insertions(+), 12 deletions(-) diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index e3f75533..b7b2547b 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -270,10 +270,12 @@ fn validate_certificate_serial(cert: &X509Certificate<'_>) -> Result<(), X509Cha } fn validate_certificate_serial_bytes(serial: &[u8]) -> Result<(), X509ChainError> { + let magnitude = serial.strip_prefix(&[0]).unwrap_or(serial); if serial.is_empty() - || serial.len() > 20 || serial[0] & 0x80 != 0 - || serial.iter().all(|byte| *byte == 0) + || magnitude.is_empty() + || magnitude.len() > 20 + || magnitude.iter().all(|byte| *byte == 0) { return Err(X509ChainError::InvalidDer { kind: "certificate serial number", @@ -323,14 +325,7 @@ fn validate_subject_identity(cert: &X509Certificate<'_>) -> Result<(), X509Chain )); }; for name in &names.general_names { - if matches!(name, GeneralName::Invalid(..)) { - return Err(invalid_subject_identity( - "SubjectAlternativeName contains a malformed GeneralName", - )); - } - if let GeneralName::DNSName(name) = name { - validate_presented_dns_name(name)?; - } + validate_subject_alternative_name(name)?; } if subject_is_empty && (!extension.critical || names.general_names.is_empty()) { return Err(invalid_subject_identity( @@ -340,6 +335,209 @@ fn validate_subject_identity(cert: &X509Certificate<'_>) -> Result<(), X509Chain Ok(()) } +fn validate_subject_alternative_name(name: &GeneralName<'_>) -> Result<(), X509ChainError> { + match name { + GeneralName::RFC822Name(value) => validate_rfc5280_mailbox(value), + GeneralName::DNSName(value) => { + // RFC 5280 section 4.2.1.6 requires RFC 1034/1123 preferred-name + // syntax here. RFC 9525 wildcard matching is an application-level + // TLS identity rule, not certificate-path profile validation. + validate_rfc5280_dns_name(value) + } + GeneralName::URI(value) => validate_rfc5280_uri(value), + GeneralName::IPAddress(value) if !matches!(value.len(), 4 | 16) => { + Err(invalid_subject_identity( + "SubjectAlternativeName iPAddress must contain 4 or 16 octets", + )) + } + GeneralName::Invalid(..) => Err(invalid_subject_identity( + "SubjectAlternativeName contains a malformed GeneralName", + )), + _ => Ok(()), + } +} + +fn validate_rfc5280_mailbox(value: &str) -> Result<(), X509ChainError> { + let Some((local, domain)) = value.rsplit_once('@') else { + return Err(invalid_subject_identity( + "SubjectAlternativeName rfc822Name must contain a mailbox domain", + )); + }; + if !mailbox_local_part_has_valid_syntax(local) || !dns_name_has_valid_syntax(domain, false) { + return Err(invalid_subject_identity( + "SubjectAlternativeName rfc822Name has invalid RFC 5280 mailbox syntax", + )); + } + Ok(()) +} + +fn mailbox_local_part_has_valid_syntax(local: &str) -> bool { + if let Some(quoted) = local + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + { + if quoted.is_empty() { + return false; + } + let mut escaped = false; + for byte in quoted.bytes() { + if escaped { + if !(0x20..=0x7e).contains(&byte) { + return false; + } + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == b'"' || !(0x20..=0x7e).contains(&byte) { + return false; + } + } + return !escaped; + } + + local.split('.').all(|atom| { + !atom.is_empty() + && atom.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'/' + | b'=' + | b'?' + | b'^' + | b'_' + | b'`' + | b'{' + | b'|' + | b'}' + | b'~' + ) + }) + }) +} + +fn validate_rfc5280_uri(value: &str) -> Result<(), X509ChainError> { + let Some((scheme, scheme_specific)) = value.split_once(':') else { + return Err(invalid_subject_identity( + "SubjectAlternativeName URI must be absolute", + )); + }; + if scheme.is_empty() + || !scheme.as_bytes()[0].is_ascii_alphabetic() + || !scheme + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.')) + || scheme_specific.is_empty() + || !uri_scheme_specific_part_has_valid_syntax(scheme_specific) + { + return Err(invalid_subject_identity( + "SubjectAlternativeName URI has invalid RFC 3986 syntax", + )); + } + + if let Some(authority_and_path) = scheme_specific.strip_prefix("//") { + let authority = authority_and_path + .split(['/', '?', '#']) + .next() + .unwrap_or_default(); + if !uri_authority_has_rfc5280_host(authority) { + return Err(invalid_subject_identity( + "SubjectAlternativeName URI authority requires a fully qualified host", + )); + } + } + Ok(()) +} + +fn uri_scheme_specific_part_has_valid_syntax(value: &str) -> bool { + if value.bytes().any(|byte| { + !byte.is_ascii() + || byte.is_ascii_control() + || byte == b' ' + || !matches!( + byte, + b'A'..=b'Z' + | b'a'..=b'z' + | b'0'..=b'9' + | b'-' + | b'.' + | b'_' + | b'~' + | b':' + | b'/' + | b'?' + | b'#' + | b'[' + | b']' + | b'@' + | b'!' + | b'$' + | b'&' + | b'\'' + | b'(' + | b')' + | b'*' + | b'+' + | b',' + | b';' + | b'=' + | b'%' + ) + }) || value.matches('#').count() > 1 + { + return false; + } + + let bytes = value.as_bytes(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' + && (index + 2 >= bytes.len() + || !bytes[index + 1].is_ascii_hexdigit() + || !bytes[index + 2].is_ascii_hexdigit()) + { + return false; + } + index += if bytes[index] == b'%' { 3 } else { 1 }; + } + true +} + +fn uri_authority_has_rfc5280_host(authority: &str) -> bool { + let host_port = authority + .rsplit_once('@') + .map_or(authority, |(_, host)| host); + if let Some(bracketed) = host_port.strip_prefix('[') { + let Some((host, port)) = bracketed.split_once(']') else { + return false; + }; + return host.parse::().is_ok() + && (port.is_empty() + || port + .strip_prefix(':') + .is_some_and(|value| value.bytes().all(|byte| byte.is_ascii_digit()))); + } + let (host, port_is_valid) = + host_port + .rsplit_once(':') + .map_or((host_port, true), |(host, port)| { + ( + host, + !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit()), + ) + }); + port_is_valid + && (host.parse::().is_ok() || dns_name_has_valid_syntax(host, false)) +} + fn invalid_subject_identity(message: &str) -> X509ChainError { X509ChainError::InvalidDer { kind: "certificate subject identity", @@ -847,7 +1045,7 @@ fn validate_dns_name_constraint(value: &str) -> Result<(), X509ChainError> { Ok(()) } -fn validate_presented_dns_name(value: &str) -> Result<(), X509ChainError> { +fn validate_rfc5280_dns_name(value: &str) -> Result<(), X509ChainError> { if !dns_name_has_valid_syntax(value, false) { return Err(X509ChainError::InvalidDer { kind: "certificate DNS name", @@ -1660,7 +1858,7 @@ mod tests { } #[test] - fn name_constraints_reject_malformed_presented_dns_names() { + fn rfc5280_dns_names_require_preferred_name_syntax() { let mut root_params = generated_certificate_params("DNS syntax authority", true); root_params.name_constraints = Some(rcgen::NameConstraints { permitted_subtrees: vec![rcgen::GeneralSubtree::DnsName("example.com".into())], @@ -1704,6 +1902,10 @@ mod tests { .. }) )); + + for dns_name in ["*.example.com", "_signing.example.com"] { + assert!(validate_rfc5280_dns_name(dns_name).is_err(), "{dns_name}"); + } } #[test] @@ -2060,6 +2262,98 @@ mod tests { } } + #[test] + fn typed_subject_alternative_names_require_rfc5280_syntax() { + let root = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("typed-SAN root", true), + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("root should be self-signable"); + + for (tag, value) in [ + (0x81, b"operator@".as_slice()), + (0x81, b"first..last@example.com".as_slice()), + (0x86, b"relative/path".as_slice()), + (0x86, b"https://example.com/%zz".as_slice()), + (0x87, &[192, 0, 2][..]), + ] { + for empty_subject in [false, true] { + let mut leaf_params = generated_certificate_params("typed-SAN leaf", false); + if empty_subject { + leaf_params.distinguished_name = rcgen::DistinguishedName::new(); + } + let mut san_der = vec![ + 0x30, + u8::try_from(value.len() + 2).expect("test SAN must fit short-form DER"), + tag, + u8::try_from(value.len()).expect("test GeneralName must fit short-form DER"), + ]; + san_der.extend_from_slice(value); + let mut san = rcgen::CustomExtension::from_oid_content(&[2, 5, 29, 17], san_der); + san.set_criticality(true); + leaf_params.custom_extensions.push(san); + let leaf = leaf_params + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &root, + ) + .expect("root should sign typed-SAN leaf"); + + assert!(matches!( + verify_generated_path( + vec![leaf.der().to_vec(), root.der().to_vec()], + root.der().to_vec(), + ), + Err(X509ChainError::InvalidDer { + kind: "certificate subject identity", + .. + }) + )); + } + } + + for (tag, value) in [ + (0x81, b"operator@example.com".as_slice()), + (0x81, br#""operator desk"@example.com"#.as_slice()), + (0x86, b"urn:example:operator".as_slice()), + ( + 0x86, + b"https://operator@example.com:8443/path?q=1#id".as_slice(), + ), + (0x87, &[192, 0, 2, 1][..]), + ( + 0x87, + &[0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1][..], + ), + ] { + let mut leaf_params = rcgen::CertificateParams::new(Vec::new()) + .expect("empty SAN list should produce certificate parameters"); + leaf_params.distinguished_name = rcgen::DistinguishedName::new(); + let mut san_der = vec![ + 0x30, + u8::try_from(value.len() + 2).expect("test SAN must fit short-form DER"), + tag, + u8::try_from(value.len()).expect("test GeneralName must fit short-form DER"), + ]; + san_der.extend_from_slice(value); + let mut san = rcgen::CustomExtension::from_oid_content(&[2, 5, 29, 17], san_der); + san.set_criticality(true); + leaf_params.custom_extensions.push(san); + let leaf = leaf_params + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &root, + ) + .expect("root should sign typed-SAN leaf"); + + verify_generated_path( + vec![leaf.der().to_vec(), root.der().to_vec()], + root.der().to_vec(), + ) + .expect("valid typed SAN identity must satisfy an empty subject"); + } + } + #[test] fn unevaluable_uri_names_fail_closed_for_both_constraint_forms() { use x509_parser::extensions::GeneralSubtree; @@ -2190,6 +2484,26 @@ mod tests { assert!(validate_certificate_serial_bytes(&[0x80]).is_err()); assert!(validate_certificate_serial_bytes(&[1; 20]).is_ok()); + + let root = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("serial-padding root", true), + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("root should be self-signable"); + let mut leaf_params = generated_certificate_params("serial-padding leaf", false); + leaf_params.serial_number = Some(rcgen::SerialNumber::from_slice(&[0x80; 20])); + let leaf = leaf_params + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &root, + ) + .expect("root should sign a maximum-magnitude serial"); + + verify_generated_path( + vec![leaf.der().to_vec(), root.der().to_vec()], + root.der().to_vec(), + ) + .expect("a 20-octet magnitude may require a DER sign-padding octet"); } #[test] From ab23ba6bf160e776b90bee5590cf9cfa40ce0b92 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 10 Aug 2026 21:47:06 +0300 Subject: [PATCH 49/63] fix(validation): enforce trust boundaries - Validate RFC 5280 mailbox forms across SANs, subject DNs, and name constraints - Apply CRL extension semantics only after issuer authentication - Bound aggregate typed XML Encryption CipherValue input --- src/xmldsig/x509.rs | 62 +++++++++++++++++++++++++++------ src/xmlenc/decrypt.rs | 50 ++++++++++++++++++++++++++ tests/x509_chain_integration.rs | 52 +++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 11 deletions(-) diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index b7b2547b..5ddc066e 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -300,6 +300,21 @@ fn validate_unique_extensions( } fn validate_subject_identity(cert: &X509Certificate<'_>) -> Result<(), X509ChainError> { + for attribute in cert.subject().iter_email() { + let email = attribute + .as_str() + .map_err(|error| X509ChainError::InvalidDer { + kind: "certificate subject emailAddress", + message: error.to_string(), + })?; + if !mailbox_has_valid_syntax(email) { + return Err(X509ChainError::InvalidDer { + kind: "certificate subject emailAddress", + message: format!("invalid RFC 5280 mailbox syntax: {email:?}"), + }); + } + } + let subject_is_empty = cert.subject().iter().next().is_none(); let mut san_extensions = cert .extensions() @@ -358,12 +373,7 @@ fn validate_subject_alternative_name(name: &GeneralName<'_>) -> Result<(), X509C } fn validate_rfc5280_mailbox(value: &str) -> Result<(), X509ChainError> { - let Some((local, domain)) = value.rsplit_once('@') else { - return Err(invalid_subject_identity( - "SubjectAlternativeName rfc822Name must contain a mailbox domain", - )); - }; - if !mailbox_local_part_has_valid_syntax(local) || !dns_name_has_valid_syntax(domain, false) { + if !mailbox_has_valid_syntax(value) { return Err(invalid_subject_identity( "SubjectAlternativeName rfc822Name has invalid RFC 5280 mailbox syntax", )); @@ -371,6 +381,29 @@ fn validate_rfc5280_mailbox(value: &str) -> Result<(), X509ChainError> { Ok(()) } +fn mailbox_has_valid_syntax(value: &str) -> bool { + value.rsplit_once('@').is_some_and(|(local, domain)| { + mailbox_local_part_has_valid_syntax(local) && mailbox_domain_has_valid_syntax(domain) + }) +} + +fn mailbox_domain_has_valid_syntax(domain: &str) -> bool { + let Some(literal) = domain + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + else { + return dns_name_has_valid_syntax(domain, false); + }; + if literal + .get(..5) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("IPv6:")) + { + literal[5..].parse::().is_ok() + } else { + literal.parse::().is_ok() + } +} + fn mailbox_local_part_has_valid_syntax(local: &str) -> bool { if let Some(quoted) = local .strip_prefix('"') @@ -1028,11 +1061,11 @@ fn ensure_supported_name_constraints( } fn validate_email_name_constraint(value: &str) -> Result<(), X509ChainError> { - if let Some((local, domain)) = value.rsplit_once('@') { - if local.is_empty() || local.contains('@') || domain.starts_with('.') { + if value.contains('@') { + if !mailbox_has_valid_syntax(value) { return Err(invalid_string_name_constraint(value)); } - validate_dns_name_constraint(domain) + Ok(()) } else { validate_dns_name_constraint(value) } @@ -1087,7 +1120,7 @@ fn dns_name_has_valid_syntax(value: &str, allow_leading_dot: bool) -> bool { fn invalid_string_name_constraint(value: &str) -> X509ChainError { X509ChainError::InvalidDer { kind: "string name constraint", - message: format!("invalid RFC 5280 DNS-based constraint: {value:?}"), + message: format!("invalid RFC 5280 string name constraint: {value:?}"), } } @@ -1406,7 +1439,6 @@ fn verify_crls( message: "trailing data".into(), }); } - validate_crl_extensions(&crl, idx)?; Ok((idx, crl)) }) .collect::, _>>()?; @@ -1427,6 +1459,9 @@ fn verify_crls( } continue; } + // Extension semantics can reject an applicable CRL, but unrelated + // untrusted CRL material must not influence the selected path. + validate_crl_extensions(crl, *crl_index)?; if issuer .key_usage() .map_err(|error| X509ChainError::InvalidDer { @@ -1982,6 +2017,7 @@ mod tests { GeneralName::DNSName(""), GeneralName::DNSName("example..com"), GeneralName::RFC822Name("@example.com"), + GeneralName::RFC822Name("bad..local@example.com"), GeneralName::URI("https://example.com"), ] { let constraints = NameConstraints { @@ -2104,6 +2140,7 @@ mod tests { ("Example Corp", "ops@example.com", true), ("Other Corp", "ops@example.com", false), ("Example Corp", "ops@example.net", false), + ("Example Corp", "bad..local@example.com", false), ] { let mut leaf_params = generated_certificate_params("name-constrained leaf", false); leaf_params.distinguished_name = rcgen::DistinguishedName::new(); @@ -2275,6 +2312,7 @@ mod tests { (0x81, b"first..last@example.com".as_slice()), (0x86, b"relative/path".as_slice()), (0x86, b"https://example.com/%zz".as_slice()), + (0x86, b"file:///path".as_slice()), (0x87, &[192, 0, 2][..]), ] { for empty_subject in [false, true] { @@ -2314,6 +2352,8 @@ mod tests { for (tag, value) in [ (0x81, b"operator@example.com".as_slice()), + (0x81, b"operator@[192.0.2.1]".as_slice()), + (0x81, b"operator@[IPv6:2001:db8::1]".as_slice()), (0x81, br#""operator desk"@example.com"#.as_slice()), (0x86, b"urn:example:operator".as_slice()), ( diff --git a/src/xmlenc/decrypt.rs b/src/xmlenc/decrypt.rs index 4ebea0be..ce67a428 100644 --- a/src/xmlenc/decrypt.rs +++ b/src/xmlenc/decrypt.rs @@ -104,6 +104,7 @@ impl<'a> DecryptContext<'a> { encrypted, algorithm, self.policy.resources.max_encryption_plaintext_bytes, + self.policy.resources.max_encryption_document_bytes, )?; let ciphertext = STANDARD .decode(&encrypted.cipher_data.value) @@ -631,6 +632,7 @@ fn validate_typed_cipher_values( encrypted: &EncryptedData, algorithm: DataEncryptionAlgorithm, maximum_plaintext: usize, + maximum_cipher_values: usize, ) -> Result<(), XmlEncError> { let maximum_ciphertext = match algorithm { DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc => { @@ -651,9 +653,28 @@ fn validate_typed_cipher_values( }); } + let mut aggregate_encoded = encrypted.cipher_data.value.len(); + if aggregate_encoded > maximum_cipher_values { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: "aggregate encryption CipherValue bytes", + maximum: maximum_cipher_values, + actual: aggregate_encoded, + } + .into()); + } + let maximum_wrapped_key = projected_decoded_len_for_encoded_len(MAX_CIPHER_VALUE_BASE64_LEN); for encrypted_key in &encrypted.encrypted_keys { validate_cipher_value_len(&encrypted_key.cipher_data.value, maximum_wrapped_key)?; + aggregate_encoded = aggregate_encoded.saturating_add(encrypted_key.cipher_data.value.len()); + if aggregate_encoded > maximum_cipher_values { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: "aggregate encryption CipherValue bytes", + maximum: maximum_cipher_values, + actual: aggregate_encoded, + } + .into()); + } } Ok(()) } @@ -1517,6 +1538,35 @@ mod tests { Err(XmlEncError::InvalidStructure(_)) )); assert_eq!(resolver.candidate_calls.get(), 0); + + encrypted.encrypted_keys[0].cipher_data.value = "AAAA".into(); + let aggregate_encoded_len = + encrypted.cipher_data.value.len() + encrypted.encrypted_keys[0].cipher_data.value.len(); + let policy = crate::policy::DecryptionPolicy { + resources: crate::policy::ResourcePolicy { + max_encryption_plaintext_bytes: 4, + max_encryption_document_bytes: aggregate_encoded_len - 1, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::DecryptionPolicy::default() + }; + let resolver = CountingResolver { + candidate_calls: Cell::new(0), + key: key.to_vec(), + }; + assert!(matches!( + DecryptContext::new(&resolver) + .policy(policy) + .decrypt_data(&encrypted), + Err(XmlEncError::Policy( + crate::policy::PolicyViolation::ResourceLimit { + resource: "aggregate encryption CipherValue bytes", + maximum, + actual, + } + )) if maximum == aggregate_encoded_len - 1 && actual == aggregate_encoded_len + )); + assert_eq!(resolver.candidate_calls.get(), 0); } #[test] diff --git a/tests/x509_chain_integration.rs b/tests/x509_chain_integration.rs index 8a582156..fa87f665 100644 --- a/tests/x509_chain_integration.rs +++ b/tests/x509_chain_integration.rs @@ -575,6 +575,58 @@ fn rejects_crl_with_unprocessed_critical_scope() { ); } +#[test] +fn ignores_unsupported_scope_on_unrelated_crl() { + // Scope extensions are meaningful only after a CRL has been authenticated + // against the issuer selected for the certificate path. + let mut selected_root_params = CertificateParams::new(Vec::new()).unwrap(); + selected_root_params + .distinguished_name + .push(rcgen::DnType::CommonName, "selected root"); + selected_root_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + selected_root_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + let selected_root = + rcgen::CertifiedIssuer::self_signed(selected_root_params, KeyPair::generate().unwrap()) + .unwrap(); + let leaf = CertificateParams::new(Vec::new()) + .unwrap() + .signed_by(&KeyPair::generate().unwrap(), &selected_root) + .unwrap(); + + let mut unrelated_root_params = CertificateParams::new(Vec::new()).unwrap(); + unrelated_root_params + .distinguished_name + .push(rcgen::DnType::CommonName, "unrelated root"); + unrelated_root_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + unrelated_root_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + let unrelated_root = + rcgen::CertifiedIssuer::self_signed(unrelated_root_params, KeyPair::generate().unwrap()) + .unwrap(); + let unrelated_crl = CertificateRevocationListParams { + this_update: date_time_ymd(2026, 3, 15), + next_update: date_time_ymd(2026, 4, 15), + crl_number: SerialNumber::from(1_u64), + issuing_distribution_point: Some(CrlIssuingDistributionPoint { + distribution_point: CrlDistributionPoint { + uris: vec!["https://example.test/unrelated.crl".into()], + }, + scope: Some(CrlScope::UserCertsOnly), + }), + revoked_certs: Vec::new(), + key_identifier_method: KeyIdMethod::Sha256, + } + .signed_by(&unrelated_root) + .unwrap(); + let mut info = generated_info(vec![leaf.der().to_vec(), selected_root.der().to_vec()]); + info.crls.push(unrelated_crl.der().to_vec()); + let anchors = [selected_root.der().to_vec()]; + + assert_eq!( + verify_x509_certificate_chain(&info, &options(&anchors, true)), + Ok(()) + ); +} + #[test] fn rejects_crl_signed_by_certificate_without_crl_sign_usage() { let mut root_params = CertificateParams::new(Vec::new()).unwrap(); From 5d37a10dd04ce33328270e3d3182ecdfb725c614 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Tue, 11 Aug 2026 02:22:43 +0300 Subject: [PATCH 50/63] fix(validation): close trust boundary gaps - validate URI authorities and AES-KW KEK sizes before dispatch - exhaust shared C14N work after bounded rendering failures - reject duplicate and unsupported delta CRL extensions - add regression coverage and synchronize public documentation --- README.md | 2 +- docs/xmldsig.md | 5 + docs/xmlenc.md | 3 + src/xmldsig/transforms.rs | 56 ++++++++- src/xmldsig/verify.rs | 62 +++++++++ src/xmldsig/x509.rs | 257 +++++++++++++++++++++++++++++++++----- src/xmlenc/encrypt.rs | 23 +++- 7 files changed, 367 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index b3131eab..7efc6492 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Currently implemented (core paths): - ECDSA SHA-256/SHA-384 verification for P-256, P-384, and P-521 keys - Legacy DSA-SHA1 and HMAC-SHA1 verification, including truncated HMAC output - RSA PKCS#1 v1.5 and ECDSA SHA-256/SHA-384 signing with P-256/P-384 PKCS#8 keys -- Opt-in X.509 certificate-chain validation with explicit trust anchors, validity and path-length checks, NameConstraints, authenticated CRLs, and typed RSA-PSS/Ed25519 certificate-signature support. Duplicate extension OIDs, malformed SAN identities, and invalid name constraints are rejected; critical KeyUsage, SubjectAlternativeName, BasicConstraints, and NameConstraints are processed, while every other critical extension fails closed. +- Opt-in X.509 certificate-chain validation with explicit trust anchors, validity and path-length checks, NameConstraints, authenticated CRLs, and typed RSA-PSS/Ed25519 certificate-signature support. Duplicate certificate, CRL, and CRL-entry extension OIDs, malformed SAN identities, unsupported delta CRLs, and invalid name constraints are rejected; critical KeyUsage, SubjectAlternativeName, BasicConstraints, and NameConstraints are processed, while every other critical extension fails closed. - Caller-supplied external references and X.509 `RetrievalMethod` resolution with bounded RFC 3986 `xml:base` processing and no 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 diff --git a/docs/xmldsig.md b/docs/xmldsig.md index bef97cc9..f2b522e0 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -133,6 +133,11 @@ Merlin X509Data retrieval selector; omitting XPath rejects that key-retrieval pa CRL checking is meaningful only inside authenticated X.509 path validation. A policy that enables CRLs without enabling certificate-chain validation is rejected during context construction rather than silently accepting a control the resolver cannot enforce. +CRL structure is validated before authority-key applicability is selected: duplicate CRL or +CRL-entry extension OIDs fail closed, and `deltaCRLIndicator` is rejected regardless of criticality +because delta and `removeFromCRL` semantics are not implemented. URI subject alternative names +likewise require one RFC 3986 authority, including syntactically valid userinfo, before their host +can participate in NameConstraints matching. Internal DTD declarations are disabled by default. Verification requires the operation's `VerificationPolicy::xml.allow_internal_dtd` decision; the diff --git a/docs/xmlenc.md b/docs/xmlenc.md index 903f7dab..0b06b3f7 100644 --- a/docs/xmlenc.md +++ b/docs/xmlenc.md @@ -41,6 +41,9 @@ SHA-256/MGF1-SHA-256. XMLEnc 1.1 itself defaults omitted parameters to SHA-1/MGF those implicit legacy defaults. SHA-1 OAEP remains available only through explicit parameters. The legacy `rsa-oaep-mgf1p` URI fixes MGF1 to SHA-1; configuration validation rejects any other MGF digest before provider dispatch because that URI has no wire field capable of representing it. +AES-KW configuration similarly validates the KEK size fixed by its algorithm URI before provider +dispatch, so custom providers cannot reinterpret `kw-aes128` with a 256-bit KEK or `kw-aes256` +with a 128-bit KEK. The same `EncryptionMethod` structural validation applies to parsed XML and caller-constructed typed values: an explicit `xenc11:MGF` is valid only with the XML Encryption 1.1 RSA-OAEP URI and is rejected before key resolution on the legacy URI. Every supplied `KeySize` must be positive; diff --git a/src/xmldsig/transforms.rs b/src/xmldsig/transforms.rs index 86234df0..159b303c 100644 --- a/src/xmldsig/transforms.rs +++ b/src/xmldsig/transforms.rs @@ -191,6 +191,47 @@ impl C14nOutputBudget { } Ok(()) } + + fn exhaust(&self) { + self.remaining.set(0); + } +} + +#[cfg(test)] +mod c14n_budget_regression_tests { + use super::*; + use crate::c14n::C14nMode; + use crate::xmldsig::types::NodeSet; + use roxmltree::Document; + + #[test] + fn bounded_c14n_failure_exhausts_the_shared_budget() { + let document = Document::parse("more than eight bytes") + .expect("test XML must parse"); + let budget = TransformExecutionBudget::with_c14n_limit(8); + + let error = execute_transforms_with_options_and_budget( + document.root_element(), + TransformData::NodeSet( + NodeSet::entire_document_without_comments(&document) + .expect("test document must fit the node-set ceiling"), + ), + &[Transform::C14n(C14nAlgorithm::new( + C14nMode::Inclusive1_0, + false, + ))], + TransformOptions::default(), + &budget, + ) + .expect_err("canonicalized output must exceed the shared budget"); + + assert!(matches!(error, TransformError::C14nOutputTooLarge { .. })); + assert_eq!( + budget.remaining_c14n_output(), + 0, + "a failed bounded render must not leave the same allowance reusable" + ); + } } impl Default for Base64WorkBudget { @@ -660,7 +701,7 @@ fn apply_transform_with_options_and_state<'s, 'd>( budget.xml_base_resolution(), &mut output, ) - .map_err(|error| map_c14n_limit_error(error, budget.c14n.max_bytes))?; + .map_err(|error| map_c14n_limit_error(error, &budget.c14n))?; budget.c14n.charge(output.len())?; Ok(TransformData::Binary(output)) } @@ -933,7 +974,7 @@ fn execute_transform_chain<'s, 'e, 'd>( context.budget.xml_base_resolution(), &mut output, ) - .map_err(|error| map_c14n_limit_error(error, context.budget.c14n.max_bytes))?; + .map_err(|error| map_c14n_limit_error(error, &context.budget.c14n))?; context.budget.c14n.charge(output.len())?; return execute_transform_chain( source_signature, @@ -1049,17 +1090,22 @@ fn finalize_transform_data( budget.xml_base_resolution(), &mut output, ) - .map_err(|error| map_c14n_limit_error(error, budget.c14n.max_bytes))?; + .map_err(|error| map_c14n_limit_error(error, &budget.c14n))?; budget.c14n.charge(output.len())?; Ok(output) } } } -fn map_c14n_limit_error(error: c14n::C14nError, max_bytes: usize) -> TransformError { +fn map_c14n_limit_error(error: c14n::C14nError, budget: &C14nOutputBudget) -> TransformError { match error { error if c14n::is_output_limit_error(&error) => { - TransformError::C14nOutputTooLarge { max_bytes } + // Rendering already spent work up to the remaining allowance. Mark + // it consumed so another Reference cannot spend the same budget. + budget.exhaust(); + TransformError::C14nOutputTooLarge { + max_bytes: budget.max_bytes, + } } c14n::C14nError::XmlBaseComponentsTooLarge { max, actual } => { TransformError::XmlBaseComponentsTooLarge { max, actual } diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index 32526b79..896fe562 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -1478,6 +1478,14 @@ fn process_manifest_references( } results.reserve(manifest_references.len()); for (index, reference, reference_node_id) in &manifest_references { + if execution.transform_budget.remaining_c14n_output() == 0 { + results.push(manifest_reference_invalid_result( + reference, + *index, + FailureReason::ReferenceProcessingFailure { ref_index: *index }, + )); + continue; + } if reference.transforms.len() > ctx.policy.resources.max_transforms_per_reference { results.push(manifest_reference_invalid_result( reference, @@ -2254,6 +2262,60 @@ mod tests { )); } + #[test] + fn manifest_processing_stops_after_c14n_budget_exhaustion() { + // A failed bounded render consumes the remaining operation allowance. + // Later Manifest references, including cheap binary ones, must not run. + let digest = base64::engine::general_purpose::STANDARD.encode([0_u8; 32]); + let xml = format!( + r##"too large{digest}{digest}"## + ); + let document = Document::parse(&xml).expect("test signature must parse"); + let signature = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "Signature"))) + .expect("test signature must contain Signature"); + let object = signature + .children() + .find(|node| node.has_tag_name((XMLDSIG_NS, "Object"))) + .expect("test signature must contain Object"); + let resources = HashMap::from([("urn:small".to_owned(), b"small".to_vec())]); + let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources); + let transform_budget = TransformExecutionBudget::with_c14n_limit(8); + let canonicalized_data_budget = CanonicalizedDataBudget::default(); + let execution = ReferenceExecutionContext { + store_pre_digest: false, + transform_options: TransformOptions::default(), + transform_budget: &transform_budget, + canonicalized_data_budget: &canonicalized_data_budget, + provider: crate::provider::default_provider(), + }; + let ctx = VerifyContext::new() + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources); + let authenticated = HashSet::from([object.id()]); + let mut xpath_budget = XPathSignatureParseBudget::default(); + + let results = process_manifest_references( + signature, + &resolver, + &ctx, + &authenticated, + 2, + &execution, + &mut xpath_budget, + ) + .expect("resource exhaustion is reported per Manifest reference"); + + assert_eq!(results.len(), 2); + assert!(results.iter().all(|result| { + matches!( + result.status, + DsigStatus::Invalid(FailureReason::ReferenceProcessingFailure { .. }) + ) + })); + } + #[test] fn verification_policy_bounds_detached_xml_nodes() { // Caller-owned detached octets become a second XML document during a diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index 5ddc066e..7f8a10a5 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -545,30 +545,92 @@ fn uri_scheme_specific_part_has_valid_syntax(value: &str) -> bool { } fn uri_authority_has_rfc5280_host(authority: &str) -> bool { - let host_port = authority - .rsplit_once('@') - .map_or(authority, |(_, host)| host); + parse_uri_authority_host(authority).is_some() +} + +#[derive(Clone, Copy)] +enum UriAuthorityHost<'a> { + Dns(&'a str), + Ip, +} + +fn parse_uri_authority_host(authority: &str) -> Option> { + let host_port = match authority.split_once('@') { + Some((userinfo, host_port)) + if !host_port.contains('@') && uri_userinfo_has_valid_syntax(userinfo) => + { + host_port + } + Some(_) => return None, + None => authority, + }; if let Some(bracketed) = host_port.strip_prefix('[') { - let Some((host, port)) = bracketed.split_once(']') else { + let (host, port) = bracketed.split_once(']')?; + return (host.parse::().is_ok() && uri_port_has_valid_syntax(port)) + .then_some(UriAuthorityHost::Ip); + } + let (host, port) = host_port + .split_once(':') + .map_or((host_port, None), |(host, port)| (host, Some(port))); + if port.is_some_and(|port| port.is_empty() || !port.bytes().all(|byte| byte.is_ascii_digit())) { + return None; + } + if host.parse::().is_ok() { + Some(UriAuthorityHost::Ip) + } else if dns_name_has_valid_syntax(host, false) { + Some(UriAuthorityHost::Dns(host)) + } else { + None + } +} + +fn uri_port_has_valid_syntax(suffix: &str) -> bool { + suffix.is_empty() + || suffix + .strip_prefix(':') + .is_some_and(|port| !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit())) +} + +fn uri_userinfo_has_valid_syntax(userinfo: &str) -> bool { + let bytes = userinfo.as_bytes(); + let mut index = 0; + while index < bytes.len() { + let byte = bytes[index]; + if byte == b'%' { + if index + 2 >= bytes.len() + || !bytes[index + 1].is_ascii_hexdigit() + || !bytes[index + 2].is_ascii_hexdigit() + { + return false; + } + index += 3; + continue; + } + if !(byte.is_ascii_alphanumeric() + || matches!( + byte, + b'-' | b'.' + | b'_' + | b'~' + | b'!' + | b'$' + | b'&' + | b'\'' + | b'(' + | b')' + | b'*' + | b'+' + | b',' + | b';' + | b'=' + | b':' + )) + { return false; - }; - return host.parse::().is_ok() - && (port.is_empty() - || port - .strip_prefix(':') - .is_some_and(|value| value.bytes().all(|byte| byte.is_ascii_digit()))); - } - let (host, port_is_valid) = - host_port - .rsplit_once(':') - .map_or((host_port, true), |(host, port)| { - ( - host, - !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit()), - ) - }); - port_is_valid - && (host.parse::().is_ok() || dns_name_has_valid_syntax(host, false)) + } + index += 1; + } + true } fn invalid_subject_identity(message: &str) -> X509ChainError { @@ -1286,17 +1348,10 @@ fn email_within_subtree(name: &str, subtree: &str) -> bool { fn uri_host(uri: &str) -> Option<&str> { let authority = uri.split_once("://")?.1; let authority = authority.split(['/', '?', '#']).next()?; - let host_port = authority - .rsplit_once('@') - .map_or(authority, |(_, host)| host); - if host_port.starts_with('[') { - return None; + match parse_uri_authority_host(authority)? { + UriAuthorityHost::Dns(host) => Some(host), + UriAuthorityHost::Ip => None, } - let host = host_port - .rsplit_once(':') - .filter(|(_, port)| !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit())) - .map_or(host_port, |(host, _)| host); - (!host.is_empty() && host.parse::().is_err()).then_some(host) } fn ip_address_within_subtree(address: &[u8], subtree: &[u8]) -> Result { @@ -1387,12 +1442,38 @@ fn crl_authority_key_matches( fn validate_crl_extensions( crl: &CertificateRevocationList<'_>, crl_index: usize, +) -> Result<(), X509ChainError> { + validate_crl_extension_uniqueness(crl, crl_index)?; + validate_crl_extension_semantics(crl, crl_index) +} + +fn validate_crl_extension_uniqueness( + crl: &CertificateRevocationList<'_>, + crl_index: usize, +) -> Result<(), X509ChainError> { + crl.tbs_cert_list + .extensions_map() + .map_err(|_| X509ChainError::InvalidCrl(crl_index))?; + for revoked in crl.iter_revoked_certificates() { + revoked + .extensions_map() + .map_err(|_| X509ChainError::InvalidCrl(crl_index))?; + } + Ok(()) +} + +fn validate_crl_extension_semantics( + crl: &CertificateRevocationList<'_>, + crl_index: usize, ) -> Result<(), X509ChainError> { for extension in crl.extensions() { let oid = extension.oid.to_id_string(); // IssuingDistributionPoint changes which certificates and issuers a CRL - // covers. It cannot be ignored while revocation entries are matched by serial. - if oid == "2.5.29.28" || (extension.critical && oid != "2.5.29.35") { + // covers. Delta CRLs also cannot be treated as complete CRLs: in particular, + // removeFromCRL has the opposite meaning from a complete-list revocation. + if matches!(oid.as_str(), "2.5.29.27" | "2.5.29.28") + || (extension.critical && oid != "2.5.29.35") + { return Err(X509ChainError::InvalidCrl(crl_index)); } if oid == "2.5.29.35" @@ -1449,6 +1530,9 @@ fn verify_crls( .iter() .filter(|(_, crl)| certificate_names_equal(crl.issuer(), cert.issuer())) { + // Duplicate OIDs make first-match AKI filtering ambiguous, so this + // structural invariant must hold before key applicability is tested. + validate_crl_extension_uniqueness(crl, *crl_index)?; let authority_key_match = crl_authority_key_matches(crl, issuer)?; if authority_key_match == Some(false) { continue; @@ -1957,6 +2041,11 @@ mod tests { uri_host("https://user@api.example.com:8443/path"), Some("api.example.com") ); + assert_eq!( + uri_host("https://user@other@example.com/path"), + None, + "a second userinfo delimiter must not expose a constraint-matchable host" + ); assert!(dns_name_within_subtree( uri_host("https://api.example.com/path").expect("URI must expose a DNS host"), ".example.com", @@ -2312,6 +2401,7 @@ mod tests { (0x81, b"first..last@example.com".as_slice()), (0x86, b"relative/path".as_slice()), (0x86, b"https://example.com/%zz".as_slice()), + (0x86, b"https://user@other@example.com/path".as_slice()), (0x86, b"file:///path".as_slice()), (0x87, &[192, 0, 2][..]), ] { @@ -2394,6 +2484,105 @@ mod tests { } } + fn parsed_merlin_crl(der: &[u8]) -> CertificateRevocationList<'_> { + CertificateRevocationList::from_der(der) + .expect("modified Merlin CRL must remain parseable") + .1 + } + + fn merlin_crl_der() -> Vec { + let xml = include_str!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt-crl.xml" + ); + let document = Document::parse(xml).expect("tracked Merlin document must parse"); + let key_info_node = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) + .expect("tracked Merlin document contains KeyInfo"); + let key_info = parse_key_info(key_info_node).expect("tracked Merlin KeyInfo must parse"); + let KeyInfoSource::X509Data(info) = &key_info.sources[0] else { + panic!("expected X509Data") + }; + info.crls[0].clone() + } + + #[test] + fn duplicate_crl_and_entry_extension_oids_fail_closed() { + use der::{Decode as _, Encode as _}; + use x509_cert::crl::CertificateList; + + let original = merlin_crl_der(); + let mut duplicate_crl: CertificateList = + CertificateList::from_der(&original).expect("tracked Merlin CRL must decode"); + let extensions = duplicate_crl + .tbs_cert_list + .crl_extensions + .as_mut() + .expect("tracked Merlin CRL must contain extensions"); + extensions.push(extensions[0].clone()); + let duplicate_crl = duplicate_crl + .to_der() + .expect("duplicate CRL extension test vector must encode"); + assert_eq!( + validate_crl_extensions(&parsed_merlin_crl(&duplicate_crl), 0), + Err(X509ChainError::InvalidCrl(0)) + ); + + let mut duplicate_entry: CertificateList = + CertificateList::from_der(&original).expect("tracked Merlin CRL must decode"); + let duplicate = duplicate_entry + .tbs_cert_list + .crl_extensions + .as_ref() + .and_then(|extensions| extensions.first()) + .expect("tracked Merlin CRL must contain an extension") + .clone(); + let revoked = duplicate_entry + .tbs_cert_list + .revoked_certificates + .as_mut() + .and_then(|entries| entries.first_mut()) + .expect("tracked Merlin CRL must contain a revoked entry"); + revoked.crl_entry_extensions = Some(vec![duplicate.clone(), duplicate]); + let duplicate_entry = duplicate_entry + .to_der() + .expect("duplicate entry extension test vector must encode"); + assert_eq!( + validate_crl_extensions(&parsed_merlin_crl(&duplicate_entry), 0), + Err(X509ChainError::InvalidCrl(0)) + ); + } + + #[test] + fn delta_crl_indicator_is_rejected_regardless_of_criticality() { + use der::{Decode as _, Encode as _, asn1::OctetString}; + use x509_cert::{crl::CertificateList, ext::Extension}; + + let original = merlin_crl_der(); + for critical in [false, true] { + let mut encoded: CertificateList = + CertificateList::from_der(&original).expect("tracked Merlin CRL must decode"); + encoded + .tbs_cert_list + .crl_extensions + .get_or_insert_default() + .push(Extension { + extn_id: der::asn1::ObjectIdentifier::new_unwrap("2.5.29.27"), + critical, + extn_value: OctetString::new([0x02, 0x01, 0x01]) + .expect("DER INTEGER extension payload must be valid"), + }); + let encoded = encoded + .to_der() + .expect("delta CRL indicator test vector must encode"); + assert_eq!( + validate_crl_extensions(&parsed_merlin_crl(&encoded), 0), + Err(X509ChainError::InvalidCrl(0)), + "delta CRL indicator criticality must not change unsupported semantics" + ); + } + } + #[test] fn unevaluable_uri_names_fail_closed_for_both_constraint_forms() { use x509_parser::extensions::GeneralSubtree; diff --git a/src/xmlenc/encrypt.rs b/src/xmlenc/encrypt.rs index 078fe15f..de8c80ed 100644 --- a/src/xmlenc/encrypt.rs +++ b/src/xmlenc/encrypt.rs @@ -320,10 +320,10 @@ impl EncryptedDataBuilder { self.validate_metadata_len("OAEPparams", parameters.label.len())?; } EncryptionRecipient::AesKeyWrap { + kek, algorithm, recipient, key_name, - .. } => { if self .policy @@ -337,6 +337,14 @@ impl EncryptedDataBuilder { } .into()); } + if kek.len() != algorithm.key_len() { + return Err(XmlEncError::InvalidEncryptionConfig(format!( + "{} requires a {}-byte key-encryption key, got {} bytes", + algorithm.uri(), + algorithm.key_len(), + kek.len() + ))); + } self.validate_metadata("EncryptedKey Recipient", recipient.as_deref())?; self.validate_key_name("EncryptedKey KeyName", key_name.as_deref())?; } @@ -936,6 +944,19 @@ mod tests { ); } + #[test] + fn aes_key_wrap_rejects_mismatched_kek_before_provider_dispatch() { + // Algorithm URIs define the KEK size. Provider implementations are + // capabilities, not authorities allowed to reinterpret wire semantics. + let builder = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .recipient_aes_kw([0x44; 32], KeyWrapAlgorithm::AesKw128); + + assert!(matches!( + builder.validate_configuration(), + Err(XmlEncError::InvalidEncryptionConfig(_)) + )); + } + #[test] fn rsa_oaep_round_trips_configurable_parameters() { let private = RsaPrivateKey::new(&mut UnwrapErr(SysRng), 2048) From 18eb17c8a14b305a595a20c7a159dd4545741852 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Tue, 11 Aug 2026 15:30:24 +0300 Subject: [PATCH 51/63] fix(xmlenc): enforce facade wire contracts - Validate EncryptedData IDs with the shared XML NCName grammar - Reject malformed custom-provider AES framing before serialization - Document provider-controlled content-key randomness --- docs/xmlenc.md | 6 +- src/xml.rs | 24 +++- src/xmldsig/builder.rs | 17 +-- src/xmlenc/decrypt.rs | 20 +-- src/xmlenc/encrypt.rs | 201 +++++++++++++++++++++++++++- src/xmlenc/types.rs | 34 +++++ tests/xmlenc_encrypt_integration.rs | 9 +- 7 files changed, 273 insertions(+), 38 deletions(-) diff --git a/docs/xmlenc.md b/docs/xmlenc.md index 0b06b3f7..8afbc585 100644 --- a/docs/xmlenc.md +++ b/docs/xmlenc.md @@ -34,8 +34,10 @@ fn example() -> Result<(), Box> { ``` For recipient transport, add one or more `EncryptionRecipient::rsa_oaep` entries with recipient -public keys, or use `recipient_aes_kw` with a shared KEK. A fresh content key is generated from -the operating-system RNG and wrapped once per recipient. The crate's secure RSA-OAEP default is +public keys, or use `recipient_aes_kw` with a shared KEK. `EncryptedDataBuilder` obtains each fresh +content key through `CryptoProvider::fill_random` and wraps it once per recipient. The default +`RustCryptoProvider` uses the operating-system RNG; `.provider(...)` can replace that behavior +together with the cryptographic primitives. The crate's secure RSA-OAEP default is SHA-256/MGF1-SHA-256. XMLEnc 1.1 itself defaults omitted parameters to SHA-1/MGF1-SHA-1, so `xml-sec` always emits explicit `ds:DigestMethod` and `xenc11:MGF` values rather than relying on those implicit legacy defaults. SHA-1 OAEP remains available only through explicit parameters. diff --git a/src/xml.rs b/src/xml.rs index 32581bb1..062835af 100644 --- a/src/xml.rs +++ b/src/xml.rs @@ -15,9 +15,21 @@ pub(crate) fn is_xml_1_0_character(character: char) -> bool { ) } +/// Return whether a string is an XML 1.0 NCName. +pub(crate) fn is_xml_ncname(value: &str) -> bool { + if value.is_empty() || value.contains(':') { + return false; + } + + // Delegate the complete Unicode Name grammar to the parser used by the + // rest of the crate instead of maintaining a partial ASCII approximation. + roxmltree::Document::parse(&format!("<{value}/>")) + .is_ok_and(|document| document.root_element().tag_name().name() == value) +} + #[cfg(test)] mod tests { - use super::is_xml_1_0_character; + use super::{is_xml_1_0_character, is_xml_ncname}; #[test] fn xml_1_0_character_boundaries_match_production_two() { @@ -41,4 +53,14 @@ mod tests { assert!(!is_xml_1_0_character(character), "{character:?}"); } } + + #[test] + fn ncname_validation_uses_the_xml_unicode_grammar() { + for valid in ["id", "_private", "Δοκιμή"] { + assert!(is_xml_ncname(valid), "{valid:?}"); + } + for invalid in ["", "1leading", "bad id", "qualified:name"] { + assert!(!is_xml_ncname(invalid), "{invalid:?}"); + } + } } diff --git a/src/xmldsig/builder.rs b/src/xmldsig/builder.rs index aaa855a3..ce6c2263 100644 --- a/src/xmldsig/builder.rs +++ b/src/xmldsig/builder.rs @@ -6,7 +6,7 @@ use quick_xml::Writer; use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event}; use crate::c14n::{C14nAlgorithm, C14nMode}; -use crate::xml::is_xml_1_0_character; +use crate::xml::{is_xml_1_0_character, is_xml_ncname}; use super::parse::MAX_REFERENCES_PER_SIGNATURE; use super::transforms::{ @@ -241,7 +241,7 @@ impl SignatureBuilder { )); } if let Some(id) = &self.signature_id - && !is_ncname(id) + && !is_xml_ncname(id) { return Err(SignatureBuilderError::InvalidId { element: "Signature", @@ -352,7 +352,7 @@ impl SignatureBuilder { } for reference in &self.references { if let Some(id) = &reference.id - && !is_ncname(id) + && !is_xml_ncname(id) { return Err(SignatureBuilderError::InvalidId { element: "Reference", @@ -585,20 +585,11 @@ fn qualified_name(prefix: Option<&str>, local_name: &str) -> String { ) } -fn is_ncname(value: &str) -> bool { - if value.is_empty() || value.contains(':') { - return false; - } - - roxmltree::Document::parse(&format!("<{value}/>")) - .is_ok_and(|document| document.root_element().tag_name().name() == value) -} - fn is_namespace_prefix(value: &str) -> bool { // Namespaces in XML reserves these names regardless of the URI being bound. // Keep the invariant explicit instead of depending on parser rejection of a // synthetic declaration assembled below. - if matches!(value, "xml" | "xmlns") || !is_ncname(value) { + if matches!(value, "xml" | "xmlns") || !is_xml_ncname(value) { return false; } diff --git a/src/xmlenc/decrypt.rs b/src/xmlenc/decrypt.rs index ce67a428..dd95e8ac 100644 --- a/src/xmlenc/decrypt.rs +++ b/src/xmlenc/decrypt.rs @@ -649,7 +649,7 @@ fn validate_typed_cipher_values( if projected > maximum_ciphertext { return Err(XmlEncError::PlaintextTooLarge { maximum: maximum_plaintext, - actual: projected.saturating_sub(ciphertext_framing_len(algorithm)), + actual: projected.saturating_sub(algorithm.minimum_ciphertext_len()), }); } @@ -707,13 +707,6 @@ fn projected_decoded_len_for_encoded_len(encoded_len: usize) -> usize { .unwrap_or(usize::MAX) } -const fn ciphertext_framing_len(algorithm: DataEncryptionAlgorithm) -> usize { - match algorithm { - DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc => 32, - DataEncryptionAlgorithm::Aes128Gcm | DataEncryptionAlgorithm::Aes256Gcm => 28, - } -} - fn validate_key_len(algorithm: DataEncryptionAlgorithm, key: &[u8]) -> Result<(), XmlEncError> { if key.len() == algorithm.key_len() { Ok(()) @@ -731,14 +724,9 @@ fn validate_possible_plaintext_len( ciphertext_len: usize, maximum: usize, ) -> Result<(), XmlEncError> { - let framing = match algorithm { - // CBC contains a 16-byte IV and 1 to 16 padding bytes. Assuming the - // largest padding yields the smallest plaintext possible on success, - // which is the safe pre-decryption lower bound checked here. The exact - // plaintext length is checked after decryption. - DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc => 32, - DataEncryptionAlgorithm::Aes128Gcm | DataEncryptionAlgorithm::Aes256Gcm => 28, - }; + // CBC's minimum includes a 16-byte IV and one padded block; using that + // maximum-padding case yields the safe pre-decryption plaintext lower bound. + let framing = algorithm.minimum_ciphertext_len(); validate_plaintext_len(ciphertext_len.saturating_sub(framing), maximum) } diff --git a/src/xmlenc/encrypt.rs b/src/xmlenc/encrypt.rs index de8c80ed..5bf7d4b3 100644 --- a/src/xmlenc/encrypt.rs +++ b/src/xmlenc/encrypt.rs @@ -10,7 +10,7 @@ use quick_xml::{ use roxmltree::{Document, Node, ParsingOptions}; use rsa::RsaPublicKey; -use crate::xml::is_xml_1_0_character; +use crate::xml::{is_xml_1_0_character, is_xml_ncname}; use super::types::{XMLDSIG_NS, XMLENC_NS, XMLENC11_NS}; use super::{ @@ -283,6 +283,11 @@ impl EncryptedDataBuilder { }); } self.validate_metadata("EncryptedData Id", self.id.as_deref())?; + if self.id.as_deref().is_some_and(|id| !is_xml_ncname(id)) { + return Err(XmlEncError::InvalidEncryptionConfig( + "EncryptedData Id must be an XML NCName".into(), + )); + } self.validate_key_name("direct KeyName", self.direct_key_name.as_deref())?; for recipient in &self.recipients { match recipient { @@ -558,7 +563,9 @@ fn encrypt_content( key: &[u8], plaintext: &[u8], ) -> Result, XmlEncError> { - Ok(provider.encrypt_data(algorithm, key, plaintext)?) + let ciphertext = provider.encrypt_data(algorithm, key, plaintext)?; + super::types::validate_ciphertext_framing(algorithm, ciphertext.len())?; + Ok(ciphertext) } fn wrap_content_key( @@ -870,6 +877,8 @@ fn replace_range(xml: &str, range: std::ops::Range, replacement: &str) -> #[cfg(test)] mod tests { + use std::sync::Arc; + use getrandom::SysRng; use getrandom::rand_core::UnwrapErr; use rsa::pkcs8::DecodePublicKey as _; @@ -887,6 +896,152 @@ mod tests { decrypt_document, parse_encrypted_data, }; + #[derive(Debug)] + struct MalformedCiphertextProvider { + ciphertext: Vec, + } + + impl crate::provider::CryptoProvider for MalformedCiphertextProvider { + fn name(&self) -> &'static str { + "malformed-ciphertext-test" + } + + fn supports(&self, query: crate::provider::CapabilityQuery<'_>) -> bool { + crate::provider::CryptoProvider::supports(&crate::provider::RustCryptoProvider, query) + } + + fn fill_random(&self, output: &mut [u8]) -> Result<(), crate::provider::ProviderError> { + crate::provider::CryptoProvider::fill_random( + &crate::provider::RustCryptoProvider, + output, + ) + } + + #[cfg(feature = "xmldsig")] + fn digest( + &self, + algorithm: crate::xmldsig::DigestAlgorithm, + data: &[u8], + ) -> Result, crate::provider::ProviderError> { + crate::provider::CryptoProvider::digest( + &crate::provider::RustCryptoProvider, + algorithm, + data, + ) + } + + #[cfg(feature = "xmldsig")] + fn sign( + &self, + key: &dyn crate::xmldsig::SigningKey, + algorithm: crate::xmldsig::SignatureAlgorithm, + data: &[u8], + ) -> Result, crate::xmldsig::SigningKeyError> { + crate::provider::CryptoProvider::sign( + &crate::provider::RustCryptoProvider, + key, + algorithm, + data, + ) + } + + #[cfg(feature = "xmldsig")] + fn verify( + &self, + key: &dyn crate::xmldsig::VerifyingKey, + algorithm: crate::xmldsig::SignatureAlgorithm, + data: &[u8], + signature: &[u8], + ) -> Result { + crate::provider::CryptoProvider::verify( + &crate::provider::RustCryptoProvider, + key, + algorithm, + data, + signature, + ) + } + + fn encrypt_data( + &self, + _algorithm: DataEncryptionAlgorithm, + _key: &[u8], + _plaintext: &[u8], + ) -> Result, crate::provider::ProviderError> { + Ok(self.ciphertext.clone()) + } + + fn decrypt_data( + &self, + algorithm: DataEncryptionAlgorithm, + key: &[u8], + ciphertext: &[u8], + ) -> Result, crate::provider::ProviderError> { + crate::provider::CryptoProvider::decrypt_data( + &crate::provider::RustCryptoProvider, + algorithm, + key, + ciphertext, + ) + } + + fn wrap_key( + &self, + algorithm: KeyWrapAlgorithm, + kek: &[u8], + key: &[u8], + ) -> Result, crate::provider::ProviderError> { + crate::provider::CryptoProvider::wrap_key( + &crate::provider::RustCryptoProvider, + algorithm, + kek, + key, + ) + } + + fn unwrap_key( + &self, + algorithm: KeyWrapAlgorithm, + kek: &[u8], + wrapped: &[u8], + ) -> Result, crate::provider::ProviderError> { + crate::provider::CryptoProvider::unwrap_key( + &crate::provider::RustCryptoProvider, + algorithm, + kek, + wrapped, + ) + } + + fn transport_key( + &self, + key: &RsaPublicKey, + parameters: &RsaOaepParameters, + plaintext: &[u8], + ) -> Result, crate::provider::ProviderError> { + crate::provider::CryptoProvider::transport_key( + &crate::provider::RustCryptoProvider, + key, + parameters, + plaintext, + ) + } + + fn recover_key( + &self, + key: &RsaPrivateKey, + parameters: &RsaOaepParameters, + ciphertext: &[u8], + ) -> Result, crate::provider::ProviderError> { + crate::provider::CryptoProvider::recover_key( + &crate::provider::RustCryptoProvider, + key, + parameters, + ciphertext, + ) + } + } + #[test] fn direct_key_round_trips_every_content_algorithm() { // All emitted wire layouts must be accepted by the existing independent @@ -1425,6 +1580,48 @@ mod tests { )); } + #[test] + fn encrypted_data_id_must_be_an_xml_ncname() { + // xsd:ID derives from NCName; escaping arbitrary attribute text cannot + // make whitespace, a leading digit, or a colon schema-valid. + for invalid in ["bad id", "1leading", "qualified:name", ""] { + assert!(matches!( + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key([0_u8; 16]) + .id(invalid) + .encrypt_binary(b"data"), + Err(XmlEncError::InvalidEncryptionConfig(_)) + )); + } + + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key([0_u8; 16]) + .id("Δοκιμή") + .encrypt_binary(b"data") + .expect("Unicode XML NCNames must remain valid identifiers"); + } + + #[test] + fn rejects_malformed_custom_provider_ciphertext_before_serialization() { + // Providers supply primitives, but the facade owns the standard wire + // contract and must not serialize output its own decryptor rejects. + for (algorithm, ciphertext) in [ + (DataEncryptionAlgorithm::Aes128Gcm, vec![0_u8; 27]), + (DataEncryptionAlgorithm::Aes128Cbc, vec![0_u8; 31]), + (DataEncryptionAlgorithm::Aes256Cbc, vec![0_u8; 33]), + ] { + let error = EncryptedDataBuilder::new(algorithm) + .provider(Arc::new(MalformedCiphertextProvider { ciphertext })) + .direct_key(vec![0_u8; algorithm.key_len()]) + .encrypt_binary(b"data") + .expect_err("malformed provider output must fail before XML serialization"); + assert!(matches!( + error, + XmlEncError::DataTooShort { .. } | XmlEncError::InvalidCbcCiphertextLength(_) + )); + } + } + #[test] fn debug_output_redacts_symmetric_key_material() { let direct_key = b"direct-key-secret".to_vec(); diff --git a/src/xmlenc/types.rs b/src/xmlenc/types.rs index 5996d4e7..fe5f3b9b 100644 --- a/src/xmlenc/types.rs +++ b/src/xmlenc/types.rs @@ -67,6 +67,40 @@ impl DataEncryptionAlgorithm { Self::Aes256Gcm => "http://www.w3.org/2009/xmlenc11#aes256-gcm", } } + + /// Minimum standard wire length for ciphertext produced by this algorithm. + pub(crate) const fn minimum_ciphertext_len(self) -> usize { + match self { + Self::Aes128Cbc | Self::Aes256Cbc => 32, + Self::Aes128Gcm | Self::Aes256Gcm => 28, + } + } +} + +pub(crate) fn validate_ciphertext_framing( + algorithm: DataEncryptionAlgorithm, + ciphertext_len: usize, +) -> Result<(), XmlEncError> { + let minimum = algorithm.minimum_ciphertext_len(); + if ciphertext_len < minimum { + let algorithm_name = match algorithm { + DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc => "AES-CBC", + DataEncryptionAlgorithm::Aes128Gcm | DataEncryptionAlgorithm::Aes256Gcm => "AES-GCM", + }; + return Err(XmlEncError::DataTooShort { + algorithm: algorithm_name, + minimum, + actual: ciphertext_len, + }); + } + if matches!( + algorithm, + DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc + ) && !(ciphertext_len - 16).is_multiple_of(16) + { + return Err(XmlEncError::InvalidCbcCiphertextLength(ciphertext_len - 16)); + } + Ok(()) } impl KeyTransportAlgorithm { diff --git a/tests/xmlenc_encrypt_integration.rs b/tests/xmlenc_encrypt_integration.rs index 52a63d2e..4ab481a6 100644 --- a/tests/xmlenc_encrypt_integration.rs +++ b/tests/xmlenc_encrypt_integration.rs @@ -542,10 +542,11 @@ fn tampered_generated_gcm_ciphertext_fails_authentication() { #[test] fn generated_metadata_is_xml_escaped_and_legacy_mgf_is_restricted() { - // Caller metadata must remain data after serialization, and the legacy - // OAEP URI cannot falsely advertise a configurable non-SHA1 MGF. + // Free-form caller metadata must remain data after serialization while the + // schema-constrained XML ID remains an NCName. The legacy OAEP URI cannot + // falsely advertise a configurable non-SHA1 MGF. let encrypted = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) - .id("encrypted<&\"") + .id("encrypted-id") .add_recipient( EncryptionRecipient::aes_key_wrap([0xe5; 16], KeyWrapAlgorithm::AesKw128) .recipient("recipient<&\"") @@ -555,7 +556,7 @@ fn generated_metadata_is_xml_escaped_and_legacy_mgf_is_restricted() { .expect("metadata must serialize safely"); let parsed = parse_encrypted_data(&encrypted.encrypted_data_xml).expect("escaped metadata must parse"); - assert_eq!(parsed.id.as_deref(), Some("encrypted<&\"")); + assert_eq!(parsed.id.as_deref(), Some("encrypted-id")); assert_eq!( parsed.encrypted_keys[0].recipient.as_deref(), Some("recipient<&\"") From 15e0a819378774e61cb7116270a393ec4a9ef580 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Tue, 11 Aug 2026 17:08:24 +0300 Subject: [PATCH 52/63] fix(validation): enforce structural bounds - Reject empty NameConstraints DER structures before path matching - Bound complete standalone EncryptedData fragments after serialization - Document both validation contracts and their regression coverage --- docs/xmldsig.md | 3 +++ docs/xmlenc.md | 9 ++++---- src/xmldsig/x509.rs | 47 +++++++++++++++++++++++++++++++++++-- src/xmlenc/encrypt.rs | 54 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 6 deletions(-) diff --git a/docs/xmldsig.md b/docs/xmldsig.md index f2b522e0..4ed57815 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -138,6 +138,9 @@ CRL-entry extension OIDs fail closed, and `deltaCRLIndicator` is rejected regard because delta and `removeFromCRL` semantics are not implemented. URI subject alternative names likewise require one RFC 3986 authority, including syntactically valid userinfo, before their host can participate in NameConstraints matching. +Critical `NameConstraints` also retain their DER structure during validation: the extension must +contain at least one permitted or excluded subtree, every present subtree collection must be +non-empty, and unsupported minimum/maximum distance fields fail closed. Internal DTD declarations are disabled by default. Verification requires the operation's `VerificationPolicy::xml.allow_internal_dtd` decision; the diff --git a/docs/xmlenc.md b/docs/xmlenc.md index 8afbc585..75d783b5 100644 --- a/docs/xmlenc.md +++ b/docs/xmlenc.md @@ -80,10 +80,11 @@ fn example(encrypted_xml: &str) -> Result<(), Box> { `PrivateKeyDecryptor` unwraps embedded RSA-OAEP `EncryptedKey` values and `KekDecryptor` unwraps AES-KW values. RSA PKCS#1 v1.5 transport, `CipherReference`, and unauthenticated external -resource loading are rejected; only inline `CipherValue` is accepted. Encryption inputs and -recipient counts are bounded before allocation. Decryption applies the same aggregate recipient -ceiling while parsing, bounds each retained identifier, algorithm URI, key name, OAEP label, and -reference URI, and rechecks caller-constructed `EncryptedData` before decoding or key resolution. +resource loading are rejected; only inline `CipherValue` is accepted. Encryption plaintext, +recipient counts, and the complete serialized `EncryptedData` fragment are bounded. Decryption +applies the same aggregate recipient ceiling while parsing, bounds each retained identifier, +algorithm URI, key name, OAEP label, and reference URI, and rechecks caller-constructed +`EncryptedData` before decoding or key resolution. That typed-input check validates the top-level content `EncryptionMethod` and every embedded key method before resolver dispatch, and bounds both encoded and projected decoded `CipherValue` sizes. Callers therefore cannot bypass parser structural or allocation limits by constructing the diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index 7f8a10a5..0abd1979 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -1042,7 +1042,7 @@ fn validate_name_constraints(path: &[X509Certificate<'_>]) -> Result<(), X509Cha let ParsedExtension::NameConstraints(constraints) = extension.parsed_extension() else { continue; }; - validate_name_constraint_distances(extension.value, constraining_position)?; + validate_name_constraints_der(extension.value, constraining_position)?; ensure_supported_name_constraints(constraints, constraining_position)?; for (position, subordinate) in path[..constraining_position].iter().enumerate() { // The target certificate is always checked. Self-issued CA rollover @@ -1057,7 +1057,7 @@ fn validate_name_constraints(path: &[X509Certificate<'_>]) -> Result<(), X509Cha Ok(()) } -fn validate_name_constraint_distances( +fn validate_name_constraints_der( extension_der: &[u8], position: usize, ) -> Result<(), X509ChainError> { @@ -1073,6 +1073,18 @@ fn validate_name_constraint_distances( message: error.to_string(), } })?; + if constraints.permitted_subtrees.is_none() && constraints.excluded_subtrees.is_none() + || constraints + .permitted_subtrees + .as_ref() + .is_some_and(Vec::is_empty) + || constraints + .excluded_subtrees + .as_ref() + .is_some_and(Vec::is_empty) + { + return Err(X509ChainError::InvalidNameConstraints { position }); + } let unsupported = constraints .permitted_subtrees .iter() @@ -2138,6 +2150,37 @@ mod tests { } } + #[test] + fn empty_name_constraint_collections_are_rejected() { + use der::Encode as _; + use x509_cert::ext::pkix::NameConstraints as EncodedNameConstraints; + + // RFC 5280 requires at least one subtree overall and at least one entry + // in every explicitly present GeneralSubtrees collection. + for constraints in [ + EncodedNameConstraints { + permitted_subtrees: None, + excluded_subtrees: None, + }, + EncodedNameConstraints { + permitted_subtrees: Some(Vec::new()), + excluded_subtrees: None, + }, + EncodedNameConstraints { + permitted_subtrees: None, + excluded_subtrees: Some(Vec::new()), + }, + ] { + let der = constraints + .to_der() + .expect("malformed NameConstraints test input must encode"); + assert!(matches!( + validate_name_constraints_der(&der, 1), + Err(X509ChainError::InvalidNameConstraints { position: 1 }) + )); + } + } + #[test] fn unsupported_name_constraint_distances_fail_path_validation() { use der::{Encode as _, asn1::Ia5String}; diff --git a/src/xmlenc/encrypt.rs b/src/xmlenc/encrypt.rs index 5bf7d4b3..7b3d2bce 100644 --- a/src/xmlenc/encrypt.rs +++ b/src/xmlenc/encrypt.rs @@ -245,6 +245,7 @@ impl EncryptedDataBuilder { &encrypted_keys, &ciphertext, )?; + self.validate_document_len(encrypted_data_xml.len())?; let replacement = match encrypted_type { Some(EncryptedDataType::Content) => ReplacementMode::ReplaceContent, Some(EncryptedDataType::Element | EncryptedDataType::Other(_)) | None => { @@ -1463,6 +1464,59 @@ mod tests { )); } + #[test] + fn standalone_encrypted_output_obeys_document_byte_ceiling() { + // The returned fragment must remain admissible to the reciprocal parser; + // plaintext bounds alone do not account for framing, base64, or markup. + let policy = |maximum| crate::policy::EncryptionPolicy { + resources: crate::policy::ResourcePolicy { + max_encryption_document_bytes: maximum, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::EncryptionPolicy::default() + }; + + let binary_len = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key([0_u8; 16]) + .encrypt_binary(b"bounded binary") + .expect("baseline binary encryption must succeed") + .encrypted_data_xml + .len(); + assert!(matches!( + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key([0_u8; 16]) + .policy(policy(binary_len - 1)) + .encrypt_binary(b"bounded binary"), + Err(XmlEncError::DocumentTooLarge { maximum, actual }) + if maximum == binary_len - 1 && actual == binary_len + )); + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key([0_u8; 16]) + .policy(policy(binary_len)) + .encrypt_binary(b"bounded binary") + .expect("the exact standalone binary output bound must be accepted"); + + let xml_len = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key([0_u8; 16]) + .encrypt_xml("bounded XML") + .expect("baseline XML encryption must succeed") + .encrypted_data_xml + .len(); + assert!(matches!( + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key([0_u8; 16]) + .policy(policy(xml_len - 1)) + .encrypt_xml("bounded XML"), + Err(XmlEncError::DocumentTooLarge { maximum, actual }) + if maximum == xml_len - 1 && actual == xml_len + )); + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key([0_u8; 16]) + .policy(policy(xml_len)) + .encrypt_xml("bounded XML") + .expect("the exact standalone XML output bound must be accepted"); + } + #[test] fn zero_resource_ceilings_allow_operations_that_consume_none() { // Zero is deny-all, not an invalid policy. Direct-key encryption has no From e4d67126d7b630985a039c6313c98b0df892def6 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Tue, 11 Aug 2026 18:26:11 +0300 Subject: [PATCH 53/63] fix(xmlenc): validate provider key wrapping Enforce RFC 3394 output framing at the XMLEnc facade boundary so malformed custom-provider output cannot be serialized as an interoperable EncryptedKey. --- docs/xmlenc.md | 4 ++- src/provider.rs | 4 +++ src/xmlenc/encrypt.rs | 69 +++++++++++++++++++++++++++++++++++++------ src/xmlenc/types.rs | 8 +++++ 4 files changed, 75 insertions(+), 10 deletions(-) diff --git a/docs/xmlenc.md b/docs/xmlenc.md index 75d783b5..1cfe6a5b 100644 --- a/docs/xmlenc.md +++ b/docs/xmlenc.md @@ -45,7 +45,9 @@ The legacy `rsa-oaep-mgf1p` URI fixes MGF1 to SHA-1; configuration validation re MGF digest before provider dispatch because that URI has no wire field capable of representing it. AES-KW configuration similarly validates the KEK size fixed by its algorithm URI before provider dispatch, so custom providers cannot reinterpret `kw-aes128` with a 256-bit KEK or `kw-aes256` -with a 128-bit KEK. +with a 128-bit KEK. A custom provider's wrapped-key output must contain the complete RFC 3394 value, +which is exactly eight bytes longer than the content key; the facade validates that framing before +serializing `EncryptedKey`. The same `EncryptionMethod` structural validation applies to parsed XML and caller-constructed typed values: an explicit `xenc11:MGF` is valid only with the XML Encryption 1.1 RSA-OAEP URI and is rejected before key resolution on the legacy URI. Every supplied `KeySize` must be positive; diff --git a/src/provider.rs b/src/provider.rs index d9c1ba36..0b6dfd43 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -241,6 +241,10 @@ pub trait CryptoProvider: Send + Sync { ) -> Result, ProviderError>; /// Wrap a content key with RFC 3394 AES Key Wrap. + /// + /// Successful output contains the complete RFC 3394 value and is exactly + /// eight bytes longer than `key`. The XMLEnc facade validates that framing + /// before serializing provider output. #[cfg(feature = "xmlenc")] fn wrap_key( &self, diff --git a/src/xmlenc/encrypt.rs b/src/xmlenc/encrypt.rs index 7b3d2bce..d1fbee8b 100644 --- a/src/xmlenc/encrypt.rs +++ b/src/xmlenc/encrypt.rs @@ -594,6 +594,13 @@ fn wrap_content_key( key_name, } => { let wrapped = provider.wrap_key(*algorithm, kek, content_key)?; + let expected = content_key.len() + 8; + if wrapped.len() != expected { + return Err(XmlEncError::InvalidWrappedKeyLength { + expected, + actual: wrapped.len(), + }); + } Ok(WrappedKey { algorithm_uri: algorithm.uri(), oaep: None, @@ -898,13 +905,14 @@ mod tests { }; #[derive(Debug)] - struct MalformedCiphertextProvider { - ciphertext: Vec, + struct OverridingOutputProvider { + ciphertext: Option>, + wrapped_key: Option>, } - impl crate::provider::CryptoProvider for MalformedCiphertextProvider { + impl crate::provider::CryptoProvider for OverridingOutputProvider { fn name(&self) -> &'static str { - "malformed-ciphertext-test" + "overriding-output-test" } fn supports(&self, query: crate::provider::CapabilityQuery<'_>) -> bool { @@ -965,11 +973,19 @@ mod tests { fn encrypt_data( &self, - _algorithm: DataEncryptionAlgorithm, - _key: &[u8], - _plaintext: &[u8], + algorithm: DataEncryptionAlgorithm, + key: &[u8], + plaintext: &[u8], ) -> Result, crate::provider::ProviderError> { - Ok(self.ciphertext.clone()) + if let Some(ciphertext) = &self.ciphertext { + return Ok(ciphertext.clone()); + } + crate::provider::CryptoProvider::encrypt_data( + &crate::provider::RustCryptoProvider, + algorithm, + key, + plaintext, + ) } fn decrypt_data( @@ -992,6 +1008,9 @@ mod tests { kek: &[u8], key: &[u8], ) -> Result, crate::provider::ProviderError> { + if let Some(wrapped_key) = &self.wrapped_key { + return Ok(wrapped_key.clone()); + } crate::provider::CryptoProvider::wrap_key( &crate::provider::RustCryptoProvider, algorithm, @@ -1665,7 +1684,10 @@ mod tests { (DataEncryptionAlgorithm::Aes256Cbc, vec![0_u8; 33]), ] { let error = EncryptedDataBuilder::new(algorithm) - .provider(Arc::new(MalformedCiphertextProvider { ciphertext })) + .provider(Arc::new(OverridingOutputProvider { + ciphertext: Some(ciphertext), + wrapped_key: None, + })) .direct_key(vec![0_u8; algorithm.key_len()]) .encrypt_binary(b"data") .expect_err("malformed provider output must fail before XML serialization"); @@ -1676,6 +1698,35 @@ mod tests { } } + #[test] + fn rejects_malformed_custom_provider_wrapped_keys_before_serialization() { + // RFC 3394 adds exactly one 64-bit integrity block. Accepting any other + // provider output would emit EncryptedKey data no recipient can unwrap. + for wrapped_key in [vec![], vec![0_u8; 23], vec![0_u8; 25]] { + let result = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .provider(Arc::new(OverridingOutputProvider { + ciphertext: None, + wrapped_key: Some(wrapped_key), + })) + .recipient_aes_kw([0_u8; 16], KeyWrapAlgorithm::AesKw128) + .encrypt_binary(b"data"); + assert!(matches!( + result, + Err(XmlEncError::InvalidWrappedKeyLength { expected: 24, actual }) + if actual != 24 + )); + } + + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .provider(Arc::new(OverridingOutputProvider { + ciphertext: None, + wrapped_key: Some(vec![0_u8; 24]), + })) + .recipient_aes_kw([0_u8; 16], KeyWrapAlgorithm::AesKw128) + .encrypt_binary(b"data") + .expect("exact RFC 3394 wrapped-key length must remain accepted"); + } + #[test] fn debug_output_redacts_symmetric_key_material() { let direct_key = b"direct-key-secret".to_vec(); diff --git a/src/xmlenc/types.rs b/src/xmlenc/types.rs index fe5f3b9b..b19d90cd 100644 --- a/src/xmlenc/types.rs +++ b/src/xmlenc/types.rs @@ -589,6 +589,14 @@ pub enum XmlEncError { /// Actual KEK size. actual: usize, }, + /// A provider returned an AES-KW value with invalid RFC 3394 framing. + #[error("AES-KW output must be {expected} bytes, got {actual}")] + InvalidWrappedKeyLength { + /// Exact wrapped length required for the supplied content key. + expected: usize, + /// Actual provider output length. + actual: usize, + }, /// Plaintext exceeds the bounded encryption input size. #[error("encryption plaintext exceeds {maximum}-byte limit: got {actual} bytes")] PlaintextTooLarge { From e42d41800a4508f725b6d6dff4dc7bf6a77676a2 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Tue, 11 Aug 2026 20:01:43 +0300 Subject: [PATCH 54/63] fix(xmldsig): enforce validation invariants - enforce provider-independent KEK and metadata bounds - add typed leaf EKU policy with end-to-end coverage - clarify resolved retrieval identities and CA criticality semantics --- README.md | 2 +- docs/xmldsig.md | 14 ++- src/policy.rs | 60 +++++++++ src/xmldsig/keys.rs | 66 ++++++++++ src/xmldsig/parse.rs | 2 +- src/xmldsig/verify.rs | 34 +++--- src/xmldsig/x509.rs | 210 ++++++++++++++++++++++++++++++-- src/xmlenc/decrypt.rs | 194 +++++++++++++++++++++++++++++ src/xmlenc/parse.rs | 35 +++++- tests/donor_negative_vectors.rs | 1 + tests/x509_chain_integration.rs | 1 + 11 files changed, 583 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 7efc6492..d27f8bbf 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Currently implemented (core paths): - ECDSA SHA-256/SHA-384 verification for P-256, P-384, and P-521 keys - Legacy DSA-SHA1 and HMAC-SHA1 verification, including truncated HMAC output - RSA PKCS#1 v1.5 and ECDSA SHA-256/SHA-384 signing with P-256/P-384 PKCS#8 keys -- Opt-in X.509 certificate-chain validation with explicit trust anchors, validity and path-length checks, NameConstraints, authenticated CRLs, and typed RSA-PSS/Ed25519 certificate-signature support. Duplicate certificate, CRL, and CRL-entry extension OIDs, malformed SAN identities, unsupported delta CRLs, and invalid name constraints are rejected; critical KeyUsage, SubjectAlternativeName, BasicConstraints, and NameConstraints are processed, while every other critical extension fails closed. +- Opt-in X.509 certificate-chain validation with explicit trust anchors, validity and path-length checks, NameConstraints, authenticated CRLs, typed leaf ExtendedKeyUsage policy, and RSA-PSS/Ed25519 certificate-signature support. Duplicate certificate, CRL, and CRL-entry extension OIDs, malformed SAN identities, unsupported delta CRLs, and invalid name constraints are rejected; implemented critical extensions are processed and every other critical extension fails closed. - Caller-supplied external references and X.509 `RetrievalMethod` resolution with bounded RFC 3986 `xml:base` processing and no 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 diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 4ed57815..650d27e2 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -70,16 +70,22 @@ validates; neither condition can bypass the configured work bounds or trust anch Path validation excludes self-issued rollover CAs from `pathLenConstraint`, applies supported RFC 5280 NameConstraints to every subordinate certificate, and rejects critical extensions whose semantics are not implemented. Repeated extension OIDs are rejected certificate-wide before any -extension-specific interpretation. Empty certificate subjects require exactly one critical, non-empty +extension-specific interpretation. Issuing certificates must assert BasicConstraints `cA=true` +and, when KeyUsage is present, `keyCertSign`. RFC 5280 requires conforming issuers to encode CA +BasicConstraints as critical, but path validation retains OpenSSL/xmlsec1 compatibility with +historical non-critical encodings. Empty certificate subjects require exactly one critical, non-empty SubjectAlternativeName, and malformed GeneralName entries fail path validation regardless of whether the subject is empty. Invalid DNS-based constraints, including email-domain and URI-host forms, malformed IPv4/IPv6 encodings, non-contiguous CIDR masks, nonzero `minimum`, and any `maximum` distance fail the path rather than behaving as ordinary name mismatches. Processed critical certificate extensions are KeyUsage (`2.5.29.15`), SubjectAlternativeName (`2.5.29.17`), BasicConstraints (`2.5.29.19`), and -NameConstraints (`2.5.29.30`). A chain using critical ExtendedKeyUsage (`2.5.29.37`) or -CertificatePolicies (`2.5.29.32`) therefore fails closed until those semantics are implemented; -encode them as non-critical only when that matches the issuing PKI's security contract. +NameConstraints (`2.5.29.30`). ExtendedKeyUsage (`2.5.29.37`) is processed whether critical or +non-critical: a leaf without EKU, or with `anyExtendedKeyUsage`, remains unrestricted, while every +other EKU must intersect `KeyTrustPolicy::allowed_leaf_extended_key_usages`. The default empty set +therefore rejects TLS-, code-signing-, and other purpose-restricted leaves unless the deployment +explicitly approves that typed purpose. Critical CertificatePolicies (`2.5.29.32`) still fails closed +because policy-tree processing is not implemented. When `X509Data` supplies multiple selector categories, every category must match certificates on the same selected, policy-valid path rather than unrelated certificates from the lookup pool. diff --git a/src/policy.rs b/src/policy.rs index 6d36ae42..dc67dfbe 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -210,6 +210,27 @@ pub struct XmlInputPolicy { pub allow_internal_dtd: bool, } +/// An RFC 5280 extended-key-purpose identifier accepted for XML signing. +#[cfg(feature = "xmldsig")] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum ExtendedKeyPurpose { + /// TLS server authentication (`id-kp-serverAuth`). + ServerAuth, + /// TLS client authentication (`id-kp-clientAuth`). + ClientAuth, + /// Executable code signing (`id-kp-codeSigning`). + CodeSigning, + /// Email protection (`id-kp-emailProtection`). + EmailProtection, + /// Trusted timestamping (`id-kp-timeStamping`). + TimeStamping, + /// OCSP response signing (`id-kp-OCSPSigning`). + OcspSigning, + /// Application-defined purpose represented as OID arcs. + Other(Vec), +} + /// X.509 and key-resolution decisions for verification. #[cfg(feature = "xmldsig")] #[derive(Debug, Clone, PartialEq, Eq)] @@ -222,6 +243,12 @@ pub struct KeyTrustPolicy { pub max_x509_candidate_paths: usize, /// Permit legacy RSA-SHA1 verification after key resolution. pub allow_legacy_rsa_sha1: bool, + /// Purposes accepted when a leaf certificate restricts itself with ExtendedKeyUsage. + /// + /// An empty set accepts only leaves without ExtendedKeyUsage or with + /// `anyExtendedKeyUsage`; it does not treat TLS/code-signing purposes as a + /// generic authorization for XML signatures. + pub allowed_leaf_extended_key_usages: HashSet, /// Authenticate and enforce embedded CRLs during path validation. /// Requires [`Self::verify_x509_chains`]. pub check_crls: bool, @@ -237,6 +264,7 @@ impl Default for KeyTrustPolicy { max_x509_chain_depth: 9, max_x509_candidate_paths: 64, allow_legacy_rsa_sha1: false, + allowed_leaf_extended_key_usages: HashSet::new(), check_crls: false, verification_time: None, } @@ -251,6 +279,20 @@ impl KeyTrustPolicy { reason: "CRL checking requires X.509 chain validation", }); } + if self + .allowed_leaf_extended_key_usages + .iter() + .any(|purpose| match purpose { + ExtendedKeyPurpose::Other(arcs) => { + arcs.len() < 2 || arcs[0] > 2 || (arcs[0] < 2 && arcs[1] > 39) + } + _ => false, + }) + { + return Err(PolicyViolation::KeyTrust { + reason: "custom extended key purposes must contain valid OID arcs", + }); + } ResourcePolicy::nonzero_within("X.509 chain depth", self.max_x509_chain_depth, 9)?; ResourcePolicy::nonzero_within("X.509 candidate paths", self.max_x509_candidate_paths, 64) } @@ -450,6 +492,24 @@ mod tests { ); } + #[cfg(feature = "xmldsig")] + #[test] + fn custom_extended_key_purposes_require_valid_oid_arcs() { + // The typed policy rejects impossible OIDs when the immutable snapshot + // is validated instead of silently making the purpose unmatchable. + let mut policy = KeyTrustPolicy::default(); + policy + .allowed_leaf_extended_key_usages + .insert(ExtendedKeyPurpose::Other(vec![1, 40, 7])); + + assert!(matches!( + policy.validate(), + Err(PolicyViolation::KeyTrust { + reason: "custom extended key purposes must contain valid OID arcs", + }) + )); + } + #[cfg(feature = "xmldsig")] #[test] fn crl_checking_requires_x509_chain_validation() { diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index a22f0d34..f0fed6bd 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -277,6 +277,7 @@ impl DefaultKeyResolver { verification_time: trust.verification_time.unwrap_or_else(SystemTime::now), max_chain_depth: trust.max_x509_chain_depth, check_crls: trust.check_crls, + allowed_leaf_extended_key_usages: Some(&trust.allowed_leaf_extended_key_usages), }; verify_x509_certificate_chain_with_provider(info, &options, provider)?; Ok(()) @@ -748,6 +749,10 @@ impl KeyResolver for DefaultKeyResolver { .max_x509_candidate_paths .min(self.config.trust.max_x509_candidate_paths), allow_legacy_rsa_sha1: policy.key_trust.allow_legacy_rsa_sha1, + allowed_leaf_extended_key_usages: policy + .key_trust + .allowed_leaf_extended_key_usages + .clone(), check_crls: policy.key_trust.check_crls || self.config.trust.check_crls, verification_time: policy .key_trust @@ -1216,6 +1221,67 @@ mod tests { assert_eq!(config.trust.max_x509_chain_depth, 9); } + #[test] + fn verification_policy_controls_leaf_extended_key_usage() { + // The immutable operation snapshot must reach certificate-path + // validation; resolver-local trust defaults cannot bypass EKU policy. + let root = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("EKU policy root", true), + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("root should be self-signable"); + let mut leaf_params = generated_certificate_params("TLS-only XML signer", false); + leaf_params.key_usages = vec![rcgen::KeyUsagePurpose::DigitalSignature]; + leaf_params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth]; + let leaf = leaf_params + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &root, + ) + .expect("root should sign leaf certificate"); + let key_info = KeyInfo { + sources: vec![KeyInfoSource::X509Data(x509_info( + vec![leaf.der().to_vec(), root.der().to_vec()], + 0, + ))], + }; + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + trusted_certs: vec![root.der().to_vec()], + ..KeyResolverConfig::default() + }); + let mut policy = crate::policy::VerificationPolicy::default(); + policy.key_trust.verify_x509_chains = true; + + let error = match resolver.resolve_with_policy( + Some(&key_info), + SignatureAlgorithm::EcdsaSha256, + &policy, + ) { + Ok(_) => panic!("unapproved restricted EKU must be rejected"), + Err(error) => error, + }; + assert!(matches!( + error, + DsigError::KeyResolution(KeyResolutionError::Chain( + super::super::X509ChainError::InvalidKeyUsage { + position: 0, + required: "an approved extended key usage", + } + )) + )); + + policy + .key_trust + .allowed_leaf_extended_key_usages + .insert(crate::policy::ExtendedKeyPurpose::ServerAuth); + assert!( + resolver + .resolve_with_policy(Some(&key_info), SignatureAlgorithm::EcdsaSha256, &policy,) + .expect("approved restricted EKU must pass path validation") + .is_some() + ); + } + #[test] fn resolver_rejects_zero_composed_x509_resource_limits() { // Resolver-local defaults tighten the operation snapshot after the diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index 00428835..f32ec130 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -187,7 +187,7 @@ pub enum KeyInfoSource { DerEncodedKeyValue(Vec), /// `` URI and optional type URI. RetrievalMethod { - /// Resource URI. + /// RFC 3986-resolved resource identity. Same-document fragments remain unchanged. uri: String, /// Declared resource type. resource_type: Option, diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index 896fe562..658d59ca 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -1264,7 +1264,7 @@ fn materialize_retrieval_methods( let mut outcome = RetrievalMaterialization::default(); for source in std::mem::take(&mut key_info.sources) { let super::parse::KeyInfoSource::RetrievalMethod { - uri, + uri: resolved_uri, resource_type, transforms, } = source @@ -1273,7 +1273,7 @@ fn materialize_retrieval_methods( continue; }; - let identity = (uri.clone(), resource_type.clone(), transforms); + let identity = (resolved_uri.clone(), resource_type.clone(), transforms); if !seen.insert(identity) { continue; } @@ -1281,16 +1281,18 @@ fn materialize_retrieval_methods( if resource_type.as_deref() == Some("http://www.w3.org/2000/09/xmldsig#rawX509Certificate") { if transforms != RetrievalMethodTransforms::None - || classify_uri(&uri) != UriClass::External + || classify_uri(&resolved_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 }); + if !allowed_uri_types.allows(&resolved_uri) { + return Err(SignatureVerificationPipelineError::DisallowedUri { + uri: resolved_uri, + }); } - let certificate = resolver.external_resource(&uri).map_err(|error| { + let certificate = resolver.external_resource(&resolved_uri).map_err(|error| { SignatureVerificationPipelineError::Reference(ReferenceProcessingError::Transform( error, )) @@ -1299,12 +1301,12 @@ fn materialize_retrieval_methods( outcome.deferred_error.get_or_insert_with(|| { SignatureVerificationPipelineError::Reference( ReferenceProcessingError::Transform(super::TransformError::UnsupportedUri( - uri.clone(), + resolved_uri.clone(), )), ) }); materialized.push(super::parse::KeyInfoSource::RetrievalMethod { - uri, + uri: resolved_uri, resource_type, transforms, }); @@ -1323,7 +1325,7 @@ fn materialize_retrieval_methods( .deferred_error .get_or_insert(SignatureVerificationPipelineError::ParseKeyInfo(error)); materialized.push(super::parse::KeyInfoSource::RetrievalMethod { - uri, + uri: resolved_uri, resource_type, transforms, }); @@ -1339,10 +1341,12 @@ fn materialize_retrieval_methods( }, )); } 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 !allowed_uri_types.allows(&resolved_uri) { + return Err(SignatureVerificationPipelineError::DisallowedUri { + uri: resolved_uri, + }); } - let id = same_document_reference_id(&uri).ok_or( + let id = same_document_reference_id(&resolved_uri).ok_or( SignatureVerificationPipelineError::InvalidStructure { reason: "X509Data RetrievalMethod requires a same-document URI", }, @@ -1382,7 +1386,7 @@ fn materialize_retrieval_methods( materialized.push(super::parse::KeyInfoSource::X509Data(data)); } else { materialized.push(super::parse::KeyInfoSource::RetrievalMethod { - uri, + uri: resolved_uri, resource_type, transforms, }); @@ -3742,8 +3746,8 @@ mod tests { // 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#" - + r#" + "# ); let document = Document::parse(&xml).unwrap(); diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index 0abd1979..ef470612 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -1,6 +1,9 @@ //! X.509 certificate path and revocation validation. -use std::time::{SystemTime, UNIX_EPOCH}; +use std::{ + collections::HashSet, + time::{SystemTime, UNIX_EPOCH}, +}; use x509_parser::{ certificate::X509Certificate, @@ -15,7 +18,7 @@ use super::{ X509DataInfo, parse::{distinguished_name_within_subtree, distinguished_names_equal, x509_name_to_rfc4514}, }; -use crate::provider::X509SignatureAlgorithm; +use crate::{policy::ExtendedKeyPurpose, provider::X509SignatureAlgorithm}; /// Inputs controlling X.509 certificate-chain validation. #[derive(Debug, Clone)] @@ -28,6 +31,8 @@ pub struct X509ChainOptions<'a> { pub max_chain_depth: usize, /// Whether parsed `` entries are enforced. pub check_crls: bool, + /// Purposes accepted when the leaf carries a restrictive ExtendedKeyUsage. + pub allowed_leaf_extended_key_usages: Option<&'a HashSet>, } /// Certificate-chain validation failure. @@ -238,7 +243,7 @@ fn validate_path( return Err(X509ChainError::CertificateNotValid(position)); } if position == 0 { - validate_leaf_key_usage(cert)?; + validate_leaf_key_usage(cert, options.allowed_leaf_extended_key_usages)?; } else { validate_ca_constraints(cert, position)?; } @@ -886,7 +891,10 @@ fn x509_digest_algorithm(oid: &str) -> Result) -> Result<(), X509ChainError> { +fn validate_leaf_key_usage( + cert: &X509Certificate<'_>, + allowed_extended_key_usages: Option<&HashSet>, +) -> Result<(), X509ChainError> { // RFC 5280 section 4.2.1.3 restricts key purpose only when KeyUsage is present. if cert .key_usage() @@ -901,7 +909,50 @@ fn validate_leaf_key_usage(cert: &X509Certificate<'_>) -> Result<(), X509ChainEr required: "digitalSignature or nonRepudiation", }); } - Ok(()) + let Some(usage) = cert + .extended_key_usage() + .map_err(|error| X509ChainError::InvalidDer { + kind: "certificate ExtendedKeyUsage", + message: error.to_string(), + })? + else { + return Ok(()); + }; + if usage.value.any + || allowed_extended_key_usages + .is_some_and(|allowed| extended_key_usage_is_allowed(usage.value, allowed)) + { + return Ok(()); + } + Err(X509ChainError::InvalidKeyUsage { + position: 0, + required: "an approved extended key usage", + }) +} + +fn extended_key_usage_is_allowed( + usage: &x509_parser::extensions::ExtendedKeyUsage<'_>, + allowed: &HashSet, +) -> bool { + (usage.server_auth && allowed.contains(&ExtendedKeyPurpose::ServerAuth)) + || (usage.client_auth && allowed.contains(&ExtendedKeyPurpose::ClientAuth)) + || (usage.code_signing && allowed.contains(&ExtendedKeyPurpose::CodeSigning)) + || (usage.email_protection && allowed.contains(&ExtendedKeyPurpose::EmailProtection)) + || (usage.time_stamping && allowed.contains(&ExtendedKeyPurpose::TimeStamping)) + || (usage.ocsp_signing && allowed.contains(&ExtendedKeyPurpose::OcspSigning)) + || usage.other.iter().any(|oid| { + let oid = oid.to_id_string(); + allowed.iter().any(|purpose| match purpose { + ExtendedKeyPurpose::Other(arcs) => { + arcs.iter() + .map(u64::to_string) + .collect::>() + .join(".") + == oid + } + _ => false, + }) + }) } fn parse_certificate(der: &[u8]) -> Result, X509ChainError> { @@ -935,14 +986,26 @@ fn validate_ca_constraints( cert: &X509Certificate<'_>, position: usize, ) -> Result<(), X509ChainError> { - cert.extensions() + let extension = cert + .extensions() .iter() - .find_map(|extension| match extension.parsed_extension() { - ParsedExtension::BasicConstraints(value) => Some(value), - _ => None, + .find(|extension| { + matches!( + extension.parsed_extension(), + ParsedExtension::BasicConstraints(_) + ) }) - .filter(|constraints| constraints.ca) .ok_or(X509ChainError::IssuerNotCa(position))?; + let ParsedExtension::BasicConstraints(constraints) = extension.parsed_extension() else { + unreachable!("extension was selected by parsed type") + }; + if !constraints.ca { + return Err(X509ChainError::IssuerNotCa(position)); + } + // RFC 5280 section 4.2.1.9 requires conforming issuers to mark CA + // BasicConstraints critical, but the path-validation algorithm requires + // the cA assertion and does not turn issuer non-conformance into a path + // failure. OpenSSL/xmlsec1 accepts historical non-critical CA extensions. if cert .key_usage() @@ -1004,7 +1067,7 @@ fn validate_critical_extensions( let oid = extension.oid.to_id_string(); if !matches!( oid.as_str(), - "2.5.29.15" | "2.5.29.17" | "2.5.29.19" | "2.5.29.30" + "2.5.29.15" | "2.5.29.17" | "2.5.29.19" | "2.5.29.30" | "2.5.29.37" ) { return Err(X509ChainError::UnsupportedCriticalExtension { position, oid }); } @@ -1618,6 +1681,14 @@ mod tests { fn verify_generated_path( certificates: Vec>, trusted_anchor: Vec, + ) -> Result<(), X509ChainError> { + verify_generated_path_with_eku(certificates, trusted_anchor, None) + } + + fn verify_generated_path_with_eku( + certificates: Vec>, + trusted_anchor: Vec, + allowed_leaf_extended_key_usages: Option<&HashSet>, ) -> Result<(), X509ChainError> { let info = X509DataInfo { certificate_chain: (0..certificates.len()).collect(), @@ -1632,10 +1703,126 @@ mod tests { verification_time: SystemTime::now(), max_chain_depth: info.certificate_chain.len(), check_crls: false, + allowed_leaf_extended_key_usages, }, ) } + #[test] + fn noncritical_ca_basic_constraints_remain_path_compatible() { + // Criticality is an issuer conformance requirement, not an additional + // relying-party path gate; historical xmlsec1 chains depend on this. + let mut params = generated_certificate_params("non-critical authority", false); + params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign]; + params + .custom_extensions + .push(rcgen::CustomExtension::from_oid_content( + &[2, 5, 29, 19], + vec![0x30, 0x03, 0x01, 0x01, 0xff], + )); + let certificate = params + .self_signed(&rcgen::KeyPair::generate().expect("CA key generation should succeed")) + .expect("test CA should be self-signable"); + let parsed = parse_certificate(certificate.der()).expect("test CA DER should parse"); + + assert_eq!(validate_ca_constraints(&parsed, 1), Ok(())); + } + + #[test] + fn restricted_leaf_eku_requires_an_approved_purpose() { + // A server-authentication certificate is not implicitly authorized for + // XML signatures merely because its key permits digital signatures. + let root = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("EKU authority", true), + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("root should be self-signable"); + let mut leaf_params = generated_certificate_params("TLS-only signer", false); + leaf_params.key_usages = vec![rcgen::KeyUsagePurpose::DigitalSignature]; + leaf_params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth]; + let leaf = leaf_params + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &root, + ) + .expect("root should sign leaf certificate"); + let leaf_der = leaf.der().to_vec(); + let root_der = root.der().to_vec(); + + assert!(matches!( + verify_generated_path(vec![leaf_der.clone(), root_der.clone()], root_der.clone(),), + Err(X509ChainError::InvalidKeyUsage { + position: 0, + required: "an approved extended key usage", + }) + )); + + let allowed = HashSet::from([ExtendedKeyPurpose::ServerAuth]); + verify_generated_path_with_eku(vec![leaf_der, root_der.clone()], root_der, Some(&allowed)) + .expect("an explicitly approved leaf purpose must be accepted"); + } + + #[test] + fn critical_leaf_eku_uses_the_same_purpose_policy() { + // Criticality changes whether an unknown extension may be ignored, not + // the authorization semantics of an EKU that this validator implements. + let root = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("critical EKU authority", true), + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("root should be self-signable"); + let mut leaf_params = generated_certificate_params("critical TLS signer", false); + leaf_params.key_usages = vec![rcgen::KeyUsagePurpose::DigitalSignature]; + let mut extension = rcgen::CustomExtension::from_oid_content( + &[2, 5, 29, 37], + vec![ + 0x30, 0x0a, 0x06, 0x08, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x01, + ], + ); + extension.set_criticality(true); + leaf_params.custom_extensions.push(extension); + let leaf = leaf_params + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &root, + ) + .expect("root should sign leaf certificate"); + let allowed = HashSet::from([ExtendedKeyPurpose::ServerAuth]); + + verify_generated_path_with_eku( + vec![leaf.der().to_vec(), root.der().to_vec()], + root.der().to_vec(), + Some(&allowed), + ) + .expect("approved critical EKU must be processed rather than rejected as unknown"); + } + + #[test] + fn any_extended_key_usage_does_not_restrict_xml_signing() { + // RFC 5280 anyExtendedKeyUsage explicitly leaves the key unrestricted, + // so it does not require a deployment-specific purpose allowlist entry. + let root = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("any EKU authority", true), + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("root should be self-signable"); + let mut leaf_params = generated_certificate_params("unrestricted signer", false); + leaf_params.key_usages = vec![rcgen::KeyUsagePurpose::DigitalSignature]; + leaf_params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::Any]; + let leaf = leaf_params + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &root, + ) + .expect("root should sign leaf certificate"); + + verify_generated_path( + vec![leaf.der().to_vec(), root.der().to_vec()], + root.der().to_vec(), + ) + .expect("anyExtendedKeyUsage must remain unrestricted"); + } + #[test] fn x509_ecdsa_hash_oid_does_not_select_the_issuer_curve() { // RFC 5758 signature OIDs select the digest while SubjectPublicKeyInfo @@ -1909,6 +2096,7 @@ mod tests { verification_time: UNIX_EPOCH + Duration::from_secs(1_104_580_800), max_chain_depth: 2, check_crls: false, + allowed_leaf_extended_key_usages: None, }; verify_x509_certificate_chain(&info, &options) diff --git a/src/xmlenc/decrypt.rs b/src/xmlenc/decrypt.rs index dd95e8ac..9f548c6b 100644 --- a/src/xmlenc/decrypt.rs +++ b/src/xmlenc/decrypt.rs @@ -224,6 +224,14 @@ impl DecryptionKeyResolver for KekDecryptor { .map_err(|error| XmlEncError::Base64(error.to_string()))?; let wrap_algorithm = KeyWrapAlgorithm::from_uri(&encrypted_key.encryption_method.algorithm)?; + let expected_kek_len = wrap_algorithm.key_len(); + if self.kek.len() != expected_kek_len { + return Err(XmlEncError::InvalidKekSize { + algorithm: wrap_algorithm, + expected: expected_kek_len, + actual: self.kek.len(), + }); + } let key = provider .unwrap_key(wrap_algorithm, &self.kek, &wrapped) .map_err(|error| match error { @@ -781,6 +789,7 @@ fn map_data_decryption_error( #[cfg(test)] mod tests { use std::cell::Cell; + use std::sync::atomic::{AtomicUsize, Ordering}; use aes_gcm::{ Aes128Gcm, @@ -810,6 +819,153 @@ mod tests { key: Vec, } + #[derive(Debug, Default)] + struct PermissiveUnwrapProvider { + unwrap_calls: AtomicUsize, + } + + impl crate::provider::CryptoProvider for PermissiveUnwrapProvider { + fn name(&self) -> &'static str { + "permissive-unwrap-test" + } + + fn supports(&self, query: crate::provider::CapabilityQuery<'_>) -> bool { + crate::provider::CryptoProvider::supports(&crate::provider::RustCryptoProvider, query) + } + + fn fill_random(&self, output: &mut [u8]) -> Result<(), crate::provider::ProviderError> { + crate::provider::CryptoProvider::fill_random( + &crate::provider::RustCryptoProvider, + output, + ) + } + + #[cfg(feature = "xmldsig")] + fn digest( + &self, + algorithm: crate::xmldsig::DigestAlgorithm, + data: &[u8], + ) -> Result, crate::provider::ProviderError> { + crate::provider::CryptoProvider::digest( + &crate::provider::RustCryptoProvider, + algorithm, + data, + ) + } + + #[cfg(feature = "xmldsig")] + fn sign( + &self, + key: &dyn crate::xmldsig::SigningKey, + algorithm: crate::xmldsig::SignatureAlgorithm, + data: &[u8], + ) -> Result, crate::xmldsig::SigningKeyError> { + crate::provider::CryptoProvider::sign( + &crate::provider::RustCryptoProvider, + key, + algorithm, + data, + ) + } + + #[cfg(feature = "xmldsig")] + fn verify( + &self, + key: &dyn crate::xmldsig::VerifyingKey, + algorithm: crate::xmldsig::SignatureAlgorithm, + data: &[u8], + signature: &[u8], + ) -> Result { + crate::provider::CryptoProvider::verify( + &crate::provider::RustCryptoProvider, + key, + algorithm, + data, + signature, + ) + } + + fn encrypt_data( + &self, + algorithm: DataEncryptionAlgorithm, + key: &[u8], + plaintext: &[u8], + ) -> Result, crate::provider::ProviderError> { + crate::provider::CryptoProvider::encrypt_data( + &crate::provider::RustCryptoProvider, + algorithm, + key, + plaintext, + ) + } + + fn decrypt_data( + &self, + algorithm: DataEncryptionAlgorithm, + key: &[u8], + ciphertext: &[u8], + ) -> Result, crate::provider::ProviderError> { + crate::provider::CryptoProvider::decrypt_data( + &crate::provider::RustCryptoProvider, + algorithm, + key, + ciphertext, + ) + } + + fn wrap_key( + &self, + algorithm: KeyWrapAlgorithm, + kek: &[u8], + key: &[u8], + ) -> Result, crate::provider::ProviderError> { + crate::provider::CryptoProvider::wrap_key( + &crate::provider::RustCryptoProvider, + algorithm, + kek, + key, + ) + } + + fn unwrap_key( + &self, + _algorithm: KeyWrapAlgorithm, + _kek: &[u8], + _wrapped: &[u8], + ) -> Result, crate::provider::ProviderError> { + self.unwrap_calls.fetch_add(1, Ordering::Relaxed); + Ok(vec![0_u8; 16]) + } + + fn transport_key( + &self, + key: &RsaPublicKey, + parameters: &RsaOaepParameters, + plaintext: &[u8], + ) -> Result, crate::provider::ProviderError> { + crate::provider::CryptoProvider::transport_key( + &crate::provider::RustCryptoProvider, + key, + parameters, + plaintext, + ) + } + + fn recover_key( + &self, + key: &rsa::RsaPrivateKey, + parameters: &RsaOaepParameters, + ciphertext: &[u8], + ) -> Result, crate::provider::ProviderError> { + crate::provider::CryptoProvider::recover_key( + &crate::provider::RustCryptoProvider, + key, + parameters, + ciphertext, + ) + } + } + impl DecryptionKeyResolver for CountingResolver { fn resolve_key( &self, @@ -1047,6 +1203,44 @@ mod tests { assert_eq!(resolved, session_key); } + #[test] + fn rejects_invalid_kek_before_custom_provider_dispatch() { + // KEK length is part of the XMLEnc algorithm contract, not a provider + // preference. A permissive provider must not bypass facade validation. + let encrypted_key = EncryptedKey { + id: None, + recipient: None, + key_name: None, + encryption_method: super::super::EncryptionMethod { + algorithm: KeyWrapAlgorithm::AesKw128.uri().into(), + key_size_bits: None, + oaep_digest: None, + mgf_algorithm: None, + oaep_params: None, + }, + cipher_data: super::super::CipherData { + value: STANDARD.encode([0_u8; 24]), + }, + reference_list: None, + carried_key_name: None, + }; + let provider = PermissiveUnwrapProvider::default(); + + assert!(matches!( + KekDecryptor::new([0_u8; 32]).resolve_key( + &provider, + DataEncryptionAlgorithm::Aes128Gcm, + Some(&encrypted_key), + ), + Err(XmlEncError::InvalidKekSize { + algorithm: KeyWrapAlgorithm::AesKw128, + expected: 16, + actual: 32, + }) + )); + assert_eq!(provider.unwrap_calls.load(Ordering::Relaxed), 0); + } + #[test] fn rejects_truncated_gcm_and_invalid_wrapped_key() { // Framing and key-wrap integrity failures must occur before content is exposed. diff --git a/src/xmlenc/parse.rs b/src/xmlenc/parse.rs index 98b60987..3fc69a17 100644 --- a/src/xmlenc/parse.rs +++ b/src/xmlenc/parse.rs @@ -327,7 +327,7 @@ fn parse_encryption_method_with_limit( && oaep_digest.is_none() && mgf_algorithm.is_none() => { - key_size_bits = Some(parse_key_size(child)?); + key_size_bits = Some(parse_key_size(child, metadata_limit)?); } (Some(XMLENC_NS), "OAEPparams") if oaep_params.is_none() => { oaep_params = Some(decode_bounded_base64_text(child, metadata_limit)?); @@ -368,8 +368,8 @@ fn parse_encryption_method_with_limit( Ok(method) } -fn parse_key_size(node: Node<'_, '_>) -> Result { - let value = simple_text(node, "KeySize")?; +fn parse_key_size(node: Node<'_, '_>, metadata_limit: usize) -> Result { + let value = bounded_simple_text_with_limit(node, "KeySize", metadata_limit)?; let value = value.trim(); let bits = value .parse::() @@ -414,13 +414,20 @@ fn bounded_simple_text( node: Node<'_, '_>, field: &'static str, policy: &crate::policy::DecryptionPolicy, +) -> Result { + bounded_simple_text_with_limit(node, field, policy.resources.max_encryption_metadata_bytes) +} + +fn bounded_simple_text_with_limit( + node: Node<'_, '_>, + field: &'static str, + maximum: usize, ) -> Result { if node.children().any(|child| child.is_element()) { return Err(XmlEncError::InvalidStructure(format!( "{field} must not contain element children" ))); } - let maximum = policy.resources.max_encryption_metadata_bytes; let mut value = String::new(); for text in node .children() @@ -859,6 +866,26 @@ mod tests { } } + #[test] + fn key_size_text_is_bounded_before_integer_parsing() { + // Leading zeroes keep the numeric value valid while making the lexical + // form arbitrarily large; enforce the metadata budget before parsing. + let key_size = format!("{}128", "0".repeat(65)); + let xml = format!( + "{key_size}" + ); + let document = Document::parse(&xml).expect("test method must be XML"); + + assert!(matches!( + parse_encryption_method_with_limit(document.root_element(), 64), + Err(XmlEncError::EncryptionMetadataTooLarge { + field: "KeySize", + maximum: 64, + actual: 68, + }) + )); + } + #[test] fn retains_key_names_and_encrypted_key_reference_list() { // Key selection and reference metadata must survive parsing even though diff --git a/tests/donor_negative_vectors.rs b/tests/donor_negative_vectors.rs index a8cb17bb..28df1cd8 100644 --- a/tests/donor_negative_vectors.rs +++ b/tests/donor_negative_vectors.rs @@ -138,6 +138,7 @@ fn phaos_certificate_chain_is_expired_at_a_modern_verification_time() { verification_time: UNIX_EPOCH + Duration::from_secs(1_767_225_600), max_chain_depth: 2, check_crls: false, + allowed_leaf_extended_key_usages: None, }; assert_eq!( diff --git a/tests/x509_chain_integration.rs b/tests/x509_chain_integration.rs index fa87f665..3e101d2f 100644 --- a/tests/x509_chain_integration.rs +++ b/tests/x509_chain_integration.rs @@ -82,6 +82,7 @@ fn options<'a>(trusted_certs: &'a [Vec], check_crls: bool) -> X509ChainOptio verification_time: UNIX_EPOCH + Duration::from_secs(VERIFICATION_TIME), max_chain_depth: 3, check_crls, + allowed_leaf_extended_key_usages: None, } } From 7fab10a481a958306805777d4f53b5118f3587d3 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Tue, 11 Aug 2026 20:45:46 +0300 Subject: [PATCH 55/63] fix(validation): enforce trust invariants - compose resolver and operation EKU policy across complete X.509 paths - reject invalid digest, CRL reason, and RSA transport provider output - add regression coverage and synchronize XMLDSig documentation --- docs/xmldsig.md | 13 ++-- src/policy.rs | 16 ++-- src/provider.rs | 13 ++++ src/xmldsig/digest.rs | 57 +++++++++++++- src/xmldsig/keys.rs | 32 ++++++-- src/xmldsig/x509.rs | 134 ++++++++++++++++++++++++++++---- src/xmlenc/encrypt.rs | 57 +++++++++++++- src/xmlenc/types.rs | 4 +- tests/donor_negative_vectors.rs | 2 +- tests/x509_chain_integration.rs | 2 +- 10 files changed, 289 insertions(+), 41 deletions(-) diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 650d27e2..4a113c77 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -81,11 +81,14 @@ distance fail the path rather than behaving as ordinary name mismatches. Process extensions are KeyUsage (`2.5.29.15`), SubjectAlternativeName (`2.5.29.17`), BasicConstraints (`2.5.29.19`), and NameConstraints (`2.5.29.30`). ExtendedKeyUsage (`2.5.29.37`) is processed whether critical or -non-critical: a leaf without EKU, or with `anyExtendedKeyUsage`, remains unrestricted, while every -other EKU must intersect `KeyTrustPolicy::allowed_leaf_extended_key_usages`. The default empty set -therefore rejects TLS-, code-signing-, and other purpose-restricted leaves unless the deployment -explicitly approves that typed purpose. Critical CertificatePolicies (`2.5.29.32`) still fails closed -because policy-tree processing is not implemented. +non-critical on every certificate in the path: absent EKU and `anyExtendedKeyUsage` remain +unrestricted, while every other EKU must intersect +`KeyTrustPolicy::allowed_extended_key_usages`. The resolver-local and operation policy sets are +independent approvals, so a purpose must appear in both when `VerifyContext` composes them. Their +default empty sets therefore reject TLS-, code-signing-, and other purpose-restricted paths unless +the deployment explicitly approves that typed purpose at both boundaries. Critical +CertificatePolicies (`2.5.29.32`) still fails closed because policy-tree processing is not +implemented. When `X509Data` supplies multiple selector categories, every category must match certificates on the same selected, policy-valid path rather than unrelated certificates from the lookup pool. diff --git a/src/policy.rs b/src/policy.rs index dc67dfbe..cbf1fc5a 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -243,12 +243,12 @@ pub struct KeyTrustPolicy { pub max_x509_candidate_paths: usize, /// Permit legacy RSA-SHA1 verification after key resolution. pub allow_legacy_rsa_sha1: bool, - /// Purposes accepted when a leaf certificate restricts itself with ExtendedKeyUsage. + /// Purposes accepted when any certificate in a path carries ExtendedKeyUsage. /// - /// An empty set accepts only leaves without ExtendedKeyUsage or with - /// `anyExtendedKeyUsage`; it does not treat TLS/code-signing purposes as a - /// generic authorization for XML signatures. - pub allowed_leaf_extended_key_usages: HashSet, + /// An empty set accepts only paths whose certificates omit ExtendedKeyUsage + /// or use `anyExtendedKeyUsage`; it does not treat TLS/code-signing purposes + /// as a generic authorization for XML signatures. + pub allowed_extended_key_usages: HashSet, /// Authenticate and enforce embedded CRLs during path validation. /// Requires [`Self::verify_x509_chains`]. pub check_crls: bool, @@ -264,7 +264,7 @@ impl Default for KeyTrustPolicy { max_x509_chain_depth: 9, max_x509_candidate_paths: 64, allow_legacy_rsa_sha1: false, - allowed_leaf_extended_key_usages: HashSet::new(), + allowed_extended_key_usages: HashSet::new(), check_crls: false, verification_time: None, } @@ -280,7 +280,7 @@ impl KeyTrustPolicy { }); } if self - .allowed_leaf_extended_key_usages + .allowed_extended_key_usages .iter() .any(|purpose| match purpose { ExtendedKeyPurpose::Other(arcs) => { @@ -499,7 +499,7 @@ mod tests { // is validated instead of silently making the purpose unmatchable. let mut policy = KeyTrustPolicy::default(); policy - .allowed_leaf_extended_key_usages + .allowed_extended_key_usages .insert(ExtendedKeyPurpose::Other(vec![1, 40, 7])); assert!(matches!( diff --git a/src/provider.rs b/src/provider.rs index 0b6dfd43..9b3af7a0 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -157,6 +157,19 @@ pub enum ProviderError { /// Supplied key length. actual: usize, }, + /// A provider reported success but returned bytes that violate the selected + /// operation's fixed-size output contract. + #[error( + "invalid provider output size for {operation:?}: expected {expected} bytes, got {actual}" + )] + InvalidOutputSize { + /// Operation whose output contract was violated. + operation: ProviderOperation, + /// Exact output length required by the algorithm. + expected: usize, + /// Actual provider output length. + actual: usize, + }, /// Input framing, padding, or primitive initialization is invalid. #[error("invalid cryptographic input: {0}")] InvalidInput(ProviderInputError), diff --git a/src/xmldsig/digest.rs b/src/xmldsig/digest.rs index 8d1a4560..f19c5b4e 100644 --- a/src/xmldsig/digest.rs +++ b/src/xmldsig/digest.rs @@ -89,7 +89,16 @@ pub fn compute_digest_with_provider( algorithm: DigestAlgorithm, data: &[u8], ) -> Result, crate::provider::ProviderError> { - provider.digest(algorithm, data) + let digest = provider.digest(algorithm, data)?; + let expected = algorithm.output_len(); + if digest.len() != expected { + return Err(crate::provider::ProviderError::InvalidOutputSize { + operation: crate::provider::ProviderOperation::Digest, + expected, + actual: digest.len(), + }); + } + Ok(digest) } /// Constant-time comparison of two byte slices. @@ -108,7 +117,9 @@ pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { mod tests { use super::*; - struct RejectingDigestProvider; + struct RejectingDigestProvider { + output: Option>, + } impl crate::provider::CryptoProvider for RejectingDigestProvider { fn name(&self) -> &'static str { @@ -128,6 +139,9 @@ mod tests { algorithm: DigestAlgorithm, _data: &[u8], ) -> Result, crate::provider::ProviderError> { + if let Some(output) = &self.output { + return Ok(output.clone()); + } Err(crate::provider::ProviderError::Unsupported { operation: crate::provider::ProviderOperation::Digest, algorithm: Some(algorithm.uri().to_owned()), @@ -219,11 +233,48 @@ mod tests { // 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"), + compute_digest_with_provider( + &RejectingDigestProvider { output: None }, + DigestAlgorithm::Sha256, + b"x" + ), Err(crate::provider::ProviderError::Unsupported { .. }) )); } + #[test] + fn explicit_provider_digest_output_must_match_the_algorithm() { + // The facade, rather than an interchangeable provider, owns the XMLDSig + // algorithm contract and must reject bytes its own parser cannot accept. + for actual in [0, 31, 33] { + assert!(matches!( + compute_digest_with_provider( + &RejectingDigestProvider { + output: Some(vec![0_u8; actual]), + }, + DigestAlgorithm::Sha256, + b"x", + ), + Err(crate::provider::ProviderError::InvalidOutputSize { + operation: crate::provider::ProviderOperation::Digest, + expected: 32, + actual: output_len, + }) if output_len == actual + )); + } + + assert_eq!( + compute_digest_with_provider( + &RejectingDigestProvider { + output: Some(vec![7_u8; 32]), + }, + DigestAlgorithm::Sha256, + b"x", + ), + Ok(vec![7_u8; 32]), + ); + } + // ── from_uri / uri round-trip ──────────────────────────────────── #[test] diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index f0fed6bd..df796309 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -277,7 +277,7 @@ impl DefaultKeyResolver { verification_time: trust.verification_time.unwrap_or_else(SystemTime::now), max_chain_depth: trust.max_x509_chain_depth, check_crls: trust.check_crls, - allowed_leaf_extended_key_usages: Some(&trust.allowed_leaf_extended_key_usages), + allowed_extended_key_usages: Some(&trust.allowed_extended_key_usages), }; verify_x509_certificate_chain_with_provider(info, &options, provider)?; Ok(()) @@ -749,10 +749,12 @@ impl KeyResolver for DefaultKeyResolver { .max_x509_candidate_paths .min(self.config.trust.max_x509_candidate_paths), allow_legacy_rsa_sha1: policy.key_trust.allow_legacy_rsa_sha1, - allowed_leaf_extended_key_usages: policy + allowed_extended_key_usages: policy .key_trust - .allowed_leaf_extended_key_usages - .clone(), + .allowed_extended_key_usages + .intersection(&self.config.trust.allowed_extended_key_usages) + .cloned() + .collect(), check_crls: policy.key_trust.check_crls || self.config.trust.check_crls, verification_time: policy .key_trust @@ -1272,8 +1274,28 @@ mod tests { policy .key_trust - .allowed_leaf_extended_key_usages + .allowed_extended_key_usages + .insert(crate::policy::ExtendedKeyPurpose::ServerAuth); + assert!(matches!( + resolver + .resolve_with_policy(Some(&key_info), SignatureAlgorithm::EcdsaSha256, &policy,), + Err(DsigError::KeyResolution(KeyResolutionError::Chain( + super::super::X509ChainError::InvalidKeyUsage { + position: 0, + required: "an approved extended key usage", + } + ))) + )); + + let mut resolver_trust = crate::policy::KeyTrustPolicy::default(); + resolver_trust + .allowed_extended_key_usages .insert(crate::policy::ExtendedKeyPurpose::ServerAuth); + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + trusted_certs: vec![root.der().to_vec()], + trust: resolver_trust, + ..KeyResolverConfig::default() + }); assert!( resolver .resolve_with_policy(Some(&key_info), SignatureAlgorithm::EcdsaSha256, &policy,) diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index ef470612..40d87f0f 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -31,8 +31,8 @@ pub struct X509ChainOptions<'a> { pub max_chain_depth: usize, /// Whether parsed `` entries are enforced. pub check_crls: bool, - /// Purposes accepted when the leaf carries a restrictive ExtendedKeyUsage. - pub allowed_leaf_extended_key_usages: Option<&'a HashSet>, + /// Purposes accepted when any path certificate carries ExtendedKeyUsage. + pub allowed_extended_key_usages: Option<&'a HashSet>, } /// Certificate-chain validation failure. @@ -243,10 +243,11 @@ fn validate_path( return Err(X509ChainError::CertificateNotValid(position)); } if position == 0 { - validate_leaf_key_usage(cert, options.allowed_leaf_extended_key_usages)?; + validate_leaf_key_usage(cert)?; } else { validate_ca_constraints(cert, position)?; } + validate_extended_key_usage(cert, position, options.allowed_extended_key_usages)?; validate_subject_identity(cert)?; validate_critical_extensions(cert, position)?; } @@ -891,10 +892,7 @@ fn x509_digest_algorithm(oid: &str) -> Result, - allowed_extended_key_usages: Option<&HashSet>, -) -> Result<(), X509ChainError> { +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 .key_usage() @@ -909,6 +907,14 @@ fn validate_leaf_key_usage( required: "digitalSignature or nonRepudiation", }); } + Ok(()) +} + +fn validate_extended_key_usage( + cert: &X509Certificate<'_>, + position: usize, + allowed_extended_key_usages: Option<&HashSet>, +) -> Result<(), X509ChainError> { let Some(usage) = cert .extended_key_usage() .map_err(|error| X509ChainError::InvalidDer { @@ -925,7 +931,7 @@ fn validate_leaf_key_usage( return Ok(()); } Err(X509ChainError::InvalidKeyUsage { - position: 0, + position, required: "an approved extended key usage", }) } @@ -1564,8 +1570,15 @@ fn validate_crl_extension_semantics( for extension in revoked.extensions() { let oid = extension.oid.to_id_string(); // certificateIssuer carries the issuer identity for indirect CRLs. - // No critical entry extension is safe to ignore during serial matching. - if oid == "2.5.29.29" || extension.critical { + // removeFromCRL is meaningful only in a delta CRL, which this + // complete-CRL validator rejects above. + let invalid_reason = oid == "2.5.29.21" + && !matches!( + extension.parsed_extension(), + ParsedExtension::ReasonCode(code) + if *code != x509_parser::x509::ReasonCode::RemoveFromCRL + ); + if oid == "2.5.29.29" || extension.critical || invalid_reason { return Err(X509ChainError::InvalidCrl(crl_index)); } } @@ -1688,7 +1701,7 @@ mod tests { fn verify_generated_path_with_eku( certificates: Vec>, trusted_anchor: Vec, - allowed_leaf_extended_key_usages: Option<&HashSet>, + allowed_extended_key_usages: Option<&HashSet>, ) -> Result<(), X509ChainError> { let info = X509DataInfo { certificate_chain: (0..certificates.len()).collect(), @@ -1703,7 +1716,7 @@ mod tests { verification_time: SystemTime::now(), max_chain_depth: info.certificate_chain.len(), check_crls: false, - allowed_leaf_extended_key_usages, + allowed_extended_key_usages, }, ) } @@ -1797,6 +1810,65 @@ mod tests { .expect("approved critical EKU must be processed rather than rejected as unknown"); } + #[test] + fn issuer_eku_restricts_the_entire_certificate_path() { + // RFC 5280 applies an issuer EKU as a path-wide purpose constraint. A + // leaf approval cannot override an incompatible critical CA authorization. + for (issuer_purpose_der, accepted) in [ + ( + vec![ + 0x30, 0x0a, 0x06, 0x08, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x02, + ], + false, + ), + ( + vec![ + 0x30, 0x0a, 0x06, 0x08, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x01, + ], + true, + ), + ] { + let mut root_params = + generated_certificate_params("purpose-constrained authority", true); + let mut extension = + rcgen::CustomExtension::from_oid_content(&[2, 5, 29, 37], issuer_purpose_der); + extension.set_criticality(true); + root_params.custom_extensions.push(extension); + let root = rcgen::CertifiedIssuer::self_signed( + root_params, + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("root should be self-signable"); + let mut leaf_params = generated_certificate_params("TLS server signer", false); + leaf_params.key_usages = vec![rcgen::KeyUsagePurpose::DigitalSignature]; + leaf_params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth]; + let leaf = leaf_params + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &root, + ) + .expect("root should sign leaf certificate"); + let allowed = HashSet::from([ExtendedKeyPurpose::ServerAuth]); + let result = verify_generated_path_with_eku( + vec![leaf.der().to_vec(), root.der().to_vec()], + root.der().to_vec(), + Some(&allowed), + ); + + if accepted { + result.expect("a shared allowed purpose must satisfy the complete path"); + } else { + assert!(matches!( + result, + Err(X509ChainError::InvalidKeyUsage { + position: 1, + required: "an approved extended key usage", + }) + )); + } + } + } + #[test] fn any_extended_key_usage_does_not_restrict_xml_signing() { // RFC 5280 anyExtendedKeyUsage explicitly leaves the key unrestricted, @@ -2096,7 +2168,7 @@ mod tests { verification_time: UNIX_EPOCH + Duration::from_secs(1_104_580_800), max_chain_depth: 2, check_crls: false, - allowed_leaf_extended_key_usages: None, + allowed_extended_key_usages: None, }; verify_x509_certificate_chain(&info, &options) @@ -2814,6 +2886,42 @@ mod tests { } } + #[test] + fn remove_from_crl_is_rejected_in_a_complete_crl() { + use der::{Decode as _, Encode as _, asn1::OctetString}; + use x509_cert::{crl::CertificateList, ext::Extension}; + + let original = merlin_crl_der(); + for (reason, accepted) in [(1_u8, true), (8_u8, false)] { + let mut encoded: CertificateList = + CertificateList::from_der(&original).expect("tracked Merlin CRL must decode"); + let revoked = encoded + .tbs_cert_list + .revoked_certificates + .as_mut() + .and_then(|entries| entries.first_mut()) + .expect("tracked Merlin CRL must contain a revoked entry"); + revoked + .crl_entry_extensions + .get_or_insert_default() + .push(Extension { + extn_id: der::asn1::ObjectIdentifier::new_unwrap("2.5.29.21"), + critical: false, + extn_value: OctetString::new([0x0a, 0x01, reason]) + .expect("DER ENUMERATED extension payload must be valid"), + }); + let encoded = encoded + .to_der() + .expect("reason-code CRL test vector must encode"); + let result = validate_crl_extensions(&parsed_merlin_crl(&encoded), 0); + if accepted { + assert_eq!(result, Ok(()), "ordinary revocation reasons remain valid"); + } else { + assert_eq!(result, Err(X509ChainError::InvalidCrl(0))); + } + } + } + #[test] fn unevaluable_uri_names_fail_closed_for_both_constraint_forms() { use x509_parser::extensions::GeneralSubtree; diff --git a/src/xmlenc/encrypt.rs b/src/xmlenc/encrypt.rs index d1fbee8b..c1bed596 100644 --- a/src/xmlenc/encrypt.rs +++ b/src/xmlenc/encrypt.rs @@ -8,7 +8,7 @@ use quick_xml::{ events::{BytesEnd, BytesStart, BytesText, Event}, }; use roxmltree::{Document, Node, ParsingOptions}; -use rsa::RsaPublicKey; +use rsa::{RsaPublicKey, traits::PublicKeyParts as _}; use crate::xml::{is_xml_1_0_character, is_xml_ncname}; @@ -618,7 +618,7 @@ fn wrap_rsa_oaep( parameters: &RsaOaepParameters, content_key: &[u8], ) -> Result, XmlEncError> { - provider + let ciphertext = provider .transport_key(public_key, parameters, content_key) .map_err(|error| match error { crate::provider::ProviderError::Random(message) => XmlEncError::Rng(message), @@ -626,7 +626,15 @@ fn wrap_rsa_oaep( XmlEncError::InvalidEncryptionConfig(reason.to_string()) } error => XmlEncError::RsaEncrypt(error.to_string()), - }) + })?; + let expected = public_key.size(); + if ciphertext.len() != expected { + return Err(XmlEncError::InvalidWrappedKeyLength { + expected, + actual: ciphertext.len(), + }); + } + Ok(ciphertext) } fn render_encrypted_data( @@ -908,6 +916,7 @@ mod tests { struct OverridingOutputProvider { ciphertext: Option>, wrapped_key: Option>, + transported_key: Option>, } impl crate::provider::CryptoProvider for OverridingOutputProvider { @@ -1039,6 +1048,9 @@ mod tests { parameters: &RsaOaepParameters, plaintext: &[u8], ) -> Result, crate::provider::ProviderError> { + if let Some(transported_key) = &self.transported_key { + return Ok(transported_key.clone()); + } crate::provider::CryptoProvider::transport_key( &crate::provider::RustCryptoProvider, key, @@ -1687,6 +1699,7 @@ mod tests { .provider(Arc::new(OverridingOutputProvider { ciphertext: Some(ciphertext), wrapped_key: None, + transported_key: None, })) .direct_key(vec![0_u8; algorithm.key_len()]) .encrypt_binary(b"data") @@ -1707,6 +1720,7 @@ mod tests { .provider(Arc::new(OverridingOutputProvider { ciphertext: None, wrapped_key: Some(wrapped_key), + transported_key: None, })) .recipient_aes_kw([0_u8; 16], KeyWrapAlgorithm::AesKw128) .encrypt_binary(b"data"); @@ -1721,12 +1735,49 @@ mod tests { .provider(Arc::new(OverridingOutputProvider { ciphertext: None, wrapped_key: Some(vec![0_u8; 24]), + transported_key: None, })) .recipient_aes_kw([0_u8; 16], KeyWrapAlgorithm::AesKw128) .encrypt_binary(b"data") .expect("exact RFC 3394 wrapped-key length must remain accepted"); } + #[test] + fn rejects_malformed_custom_provider_rsa_transport_before_serialization() { + // RSA ciphertext is exactly one modulus wide. Enforcing that invariant + // here prevents custom providers from emitting undecryptable XML. + let private_key = RsaPrivateKey::new(&mut UnwrapErr(SysRng), 2048) + .expect("RSA key generation should succeed"); + let public_key = RsaPublicKey::from(&private_key); + for transported_key in [vec![], vec![0_u8; 255], vec![0_u8; 257]] { + let result = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .provider(Arc::new(OverridingOutputProvider { + ciphertext: None, + wrapped_key: None, + transported_key: Some(transported_key), + })) + .recipient_rsa_oaep(public_key.clone()) + .encrypt_binary(b"data"); + assert!(matches!( + result, + Err(XmlEncError::InvalidWrappedKeyLength { + expected: 256, + actual, + }) if actual != 256 + )); + } + + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .provider(Arc::new(OverridingOutputProvider { + ciphertext: None, + wrapped_key: None, + transported_key: Some(vec![0_u8; 256]), + })) + .recipient_rsa_oaep(public_key) + .encrypt_binary(b"data") + .expect("modulus-sized RSA transport output must remain accepted"); + } + #[test] fn debug_output_redacts_symmetric_key_material() { let direct_key = b"direct-key-secret".to_vec(); diff --git a/src/xmlenc/types.rs b/src/xmlenc/types.rs index b19d90cd..9469404f 100644 --- a/src/xmlenc/types.rs +++ b/src/xmlenc/types.rs @@ -589,8 +589,8 @@ pub enum XmlEncError { /// Actual KEK size. actual: usize, }, - /// A provider returned an AES-KW value with invalid RFC 3394 framing. - #[error("AES-KW output must be {expected} bytes, got {actual}")] + /// A provider returned a transported key with invalid algorithm framing. + #[error("wrapped-key output must be {expected} bytes, got {actual}")] InvalidWrappedKeyLength { /// Exact wrapped length required for the supplied content key. expected: usize, diff --git a/tests/donor_negative_vectors.rs b/tests/donor_negative_vectors.rs index 28df1cd8..3b877987 100644 --- a/tests/donor_negative_vectors.rs +++ b/tests/donor_negative_vectors.rs @@ -138,7 +138,7 @@ fn phaos_certificate_chain_is_expired_at_a_modern_verification_time() { verification_time: UNIX_EPOCH + Duration::from_secs(1_767_225_600), max_chain_depth: 2, check_crls: false, - allowed_leaf_extended_key_usages: None, + allowed_extended_key_usages: None, }; assert_eq!( diff --git a/tests/x509_chain_integration.rs b/tests/x509_chain_integration.rs index 3e101d2f..365d70e5 100644 --- a/tests/x509_chain_integration.rs +++ b/tests/x509_chain_integration.rs @@ -82,7 +82,7 @@ fn options<'a>(trusted_certs: &'a [Vec], check_crls: bool) -> X509ChainOptio verification_time: UNIX_EPOCH + Duration::from_secs(VERIFICATION_TIME), max_chain_depth: 3, check_crls, - allowed_leaf_extended_key_usages: None, + allowed_extended_key_usages: None, } } From 0448fb03a2cdbd415517ca5d2b0de50446b7bbf3 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Tue, 11 Aug 2026 22:51:05 +0300 Subject: [PATCH 56/63] fix(crypto): validate provider framing --- README.md | 2 +- docs/xmldsig.md | 5 +- src/xmldsig/sign.rs | 66 ++++++++++++++ src/xmlenc/decrypt.rs | 188 +++++++++++++++++++++++++++++++++++----- src/xmlenc/types.rs | 8 +- tests/signing_digest.rs | 68 +++++++++++++++ 6 files changed, 310 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index d27f8bbf..3a2b2649 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Currently implemented (core paths): - ECDSA SHA-256/SHA-384 verification for P-256, P-384, and P-521 keys - Legacy DSA-SHA1 and HMAC-SHA1 verification, including truncated HMAC output - RSA PKCS#1 v1.5 and ECDSA SHA-256/SHA-384 signing with P-256/P-384 PKCS#8 keys -- Opt-in X.509 certificate-chain validation with explicit trust anchors, validity and path-length checks, NameConstraints, authenticated CRLs, typed leaf ExtendedKeyUsage policy, and RSA-PSS/Ed25519 certificate-signature support. Duplicate certificate, CRL, and CRL-entry extension OIDs, malformed SAN identities, unsupported delta CRLs, and invalid name constraints are rejected; implemented critical extensions are processed and every other critical extension fails closed. +- Opt-in X.509 certificate-chain validation with explicit trust anchors, validity and path-length checks, NameConstraints, authenticated CRLs, typed path-wide ExtendedKeyUsage policy, and RSA-PSS/Ed25519 certificate-signature support. Duplicate certificate, CRL, and CRL-entry extension OIDs, malformed SAN identities, unsupported delta CRLs, `removeFromCRL` entries in complete CRLs, and invalid name constraints are rejected; implemented critical extensions are processed and every other critical extension fails closed. - Caller-supplied external references and X.509 `RetrievalMethod` resolution with bounded RFC 3986 `xml:base` processing and no 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 diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 4a113c77..45c8be12 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -143,8 +143,9 @@ CRL checking is meaningful only inside authenticated X.509 path validation. A po CRLs without enabling certificate-chain validation is rejected during context construction rather than silently accepting a control the resolver cannot enforce. CRL structure is validated before authority-key applicability is selected: duplicate CRL or -CRL-entry extension OIDs fail closed, and `deltaCRLIndicator` is rejected regardless of criticality -because delta and `removeFromCRL` semantics are not implemented. URI subject alternative names +CRL-entry extension OIDs fail closed, and `deltaCRLIndicator` is rejected regardless of criticality. +The `removeFromCRL` reason is rejected in complete CRLs because it is meaningful only in a delta +CRL. URI subject alternative names likewise require one RFC 3986 authority, including syntactically valid userinfo, before their host can participate in NameConstraints matching. Critical `NameConstraints` also retain their DER structure during validation: the extension must diff --git a/src/xmldsig/sign.rs b/src/xmldsig/sign.rs index 2bf44005..1440ff6a 100644 --- a/src/xmldsig/sign.rs +++ b/src/xmldsig/sign.rs @@ -126,6 +126,15 @@ pub enum SigningError { #[error("signing key error: {0}")] Key(#[from] SigningKeyError), + /// A signing provider returned bytes that cannot encode this key's signature. + #[error("signature output must be {expected} bytes, got {actual}")] + InvalidSignatureOutputLength { + /// Exact XMLDSig wire length implied by the signing public key. + expected: usize, + /// Actual provider output length. + actual: usize, + }, + /// Writing `` failed. #[error("XML mutation error: {0}")] XmlMutation(#[from] XmlMutationError), @@ -176,6 +185,10 @@ pub enum SigningKeyError { /// Public-key encoding failed for a supported signing key. #[error("failed to encode signing public key as SPKI DER")] PublicKeyEncodingFailed, + + /// Public-key metadata cannot determine the XMLDSig signature framing. + #[error("invalid signing public-key metadata")] + InvalidPublicKeyInfo, } /// Public key material corresponding to a private XMLDSig signing key. @@ -212,6 +225,58 @@ impl SigningPublicKeyInfo { } } +fn validate_signature_output( + key: &dyn SigningKey, + algorithm: SignatureAlgorithm, + signature: &[u8], +) -> Result<(), SigningError> { + let public_key = key.public_key_info()?; + let expected = match (algorithm, public_key) { + ( + SignatureAlgorithm::RsaSha1 + | SignatureAlgorithm::RsaSha256 + | SignatureAlgorithm::RsaSha384 + | SignatureAlgorithm::RsaSha512, + SigningPublicKeyInfo::Rsa { modulus, .. }, + ) if !modulus.is_empty() => modulus.len(), + ( + SignatureAlgorithm::EcdsaSha256 | SignatureAlgorithm::EcdsaSha384, + SigningPublicKeyInfo::Ec { public_key, .. }, + ) if public_key.first() == Some(&0x04) + && public_key.len() > 1 + && (public_key.len() - 1).is_multiple_of(2) => + { + // XMLDSig serializes ECDSA as fixed-width r || s. An uncompressed + // SEC1 public point is 0x04 || x || y with the same field width. + public_key.len() - 1 + } + ( + SignatureAlgorithm::RsaSha1 + | SignatureAlgorithm::RsaSha256 + | SignatureAlgorithm::RsaSha384 + | SignatureAlgorithm::RsaSha512, + SigningPublicKeyInfo::Ec { .. }, + ) + | ( + SignatureAlgorithm::EcdsaSha256 | SignatureAlgorithm::EcdsaSha384, + SigningPublicKeyInfo::Rsa { .. }, + ) => { + return Err(SigningKeyError::UnsupportedAlgorithm { + uri: algorithm.uri().to_owned(), + } + .into()); + } + _ => return Err(SigningKeyError::InvalidPublicKeyInfo.into()), + }; + if signature.len() != expected { + return Err(SigningError::InvalidSignatureOutputLength { + expected, + actual: signature.len(), + }); + } + Ok(()) +} + /// Private key abstraction used by [`SignContext`]. pub trait SigningKey { /// Sign canonicalized `` bytes for the declared XMLDSig method. @@ -644,6 +709,7 @@ impl<'a> SignContext<'a> { let signature_value = self.provider .sign(self.signing_key, algorithm, &canonical_signed_info)?; + validate_signature_output(self.signing_key, algorithm, &signature_value)?; let signature_b64 = base64::engine::general_purpose::STANDARD.encode(signature_value); let signed = fill_signature_value_with_options(&with_digests, &signature_b64, Some(&self.policy))?; diff --git a/src/xmlenc/decrypt.rs b/src/xmlenc/decrypt.rs index 9f548c6b..c69be7bf 100644 --- a/src/xmlenc/decrypt.rs +++ b/src/xmlenc/decrypt.rs @@ -4,13 +4,13 @@ use std::fmt; use base64::{Engine as _, engine::general_purpose::STANDARD}; use roxmltree::{Document, ParsingOptions}; -use rsa::RsaPrivateKey; +use rsa::{RsaPrivateKey, traits::PublicKeyParts as _}; use super::parse::{ parse_encrypted_data_node_with_policy, parse_encrypted_data_with_policy, validate_encrypted_data_metadata, }; -use super::types::{MAX_CIPHER_VALUE_BASE64_LEN, XMLENC_NS}; +use super::types::{MAX_CIPHER_VALUE_BASE64_LEN, XMLENC_NS, validate_ciphertext_framing}; use super::{ DataEncryptionAlgorithm, DecryptedContent, EncryptedData, EncryptedDataType, EncryptedKey, KeyTransportAlgorithm, KeyWrapAlgorithm, OaepDigestAlgorithm, RsaOaepParameters, XmlEncError, @@ -109,6 +109,12 @@ impl<'a> DecryptContext<'a> { let ciphertext = STANDARD .decode(&encrypted.cipher_data.value) .map_err(|error| XmlEncError::Base64(error.to_string()))?; + validate_content_framing_before_resolution( + algorithm, + ciphertext.len(), + &encrypted.encrypted_keys, + &self.policy, + )?; validate_possible_plaintext_len( algorithm, ciphertext.len(), @@ -232,6 +238,13 @@ impl DecryptionKeyResolver for KekDecryptor { actual: self.kek.len(), }); } + let expected_wrapped_len = algorithm.key_len() + 8; + if wrapped.len() != expected_wrapped_len { + return Err(XmlEncError::InvalidWrappedKeyLength { + expected: expected_wrapped_len, + actual: wrapped.len(), + }); + } let key = provider .unwrap_key(wrap_algorithm, &self.kek, &wrapped) .map_err(|error| match error { @@ -361,6 +374,13 @@ fn recover_rsa_oaep( parameters: &RsaOaepParameters, wrapped: &[u8], ) -> Result, XmlEncError> { + let expected = key.size(); + if wrapped.len() != expected { + return Err(XmlEncError::InvalidWrappedKeyLength { + expected, + actual: wrapped.len(), + }); + } provider .recover_key(key, parameters, wrapped) .map_err(|error| match error { @@ -636,6 +656,35 @@ fn validate_encrypted_key_policy( Ok(()) } +fn validate_content_framing_before_resolution( + algorithm: DataEncryptionAlgorithm, + ciphertext_len: usize, + encrypted_keys: &[EncryptedKey], + policy: &crate::policy::DecryptionPolicy, +) -> Result<(), XmlEncError> { + let Err(framing_error) = validate_ciphertext_framing(algorithm, ciphertext_len) else { + return Ok(()); + }; + + // If no embedded key uses a supported transport, that envelope error is + // more specific than content framing: the ciphertext cannot be interpreted + // under any supported key path. This inspection performs no key resolution + // and never dispatches malformed content to a cryptographic provider. + if !encrypted_keys.is_empty() { + let mut last_key_error = None; + for encrypted_key in encrypted_keys { + match validate_encrypted_key_policy(encrypted_key, policy) { + Ok(()) => return Err(framing_error), + Err(error) => last_key_error = Some(error), + } + } + if let Some(error) = last_key_error { + return Err(error); + } + } + Err(framing_error) +} + fn validate_typed_cipher_values( encrypted: &EncryptedData, algorithm: DataEncryptionAlgorithm, @@ -821,7 +870,9 @@ mod tests { #[derive(Debug, Default)] struct PermissiveUnwrapProvider { + decrypt_calls: AtomicUsize, unwrap_calls: AtomicUsize, + recover_calls: AtomicUsize, } impl crate::provider::CryptoProvider for PermissiveUnwrapProvider { @@ -901,16 +952,12 @@ mod tests { fn decrypt_data( &self, - algorithm: DataEncryptionAlgorithm, - key: &[u8], - ciphertext: &[u8], + _algorithm: DataEncryptionAlgorithm, + _key: &[u8], + _ciphertext: &[u8], ) -> Result, crate::provider::ProviderError> { - crate::provider::CryptoProvider::decrypt_data( - &crate::provider::RustCryptoProvider, - algorithm, - key, - ciphertext, - ) + self.decrypt_calls.fetch_add(1, Ordering::Relaxed); + Ok(b"provider plaintext".to_vec()) } fn wrap_key( @@ -953,16 +1000,12 @@ mod tests { fn recover_key( &self, - key: &rsa::RsaPrivateKey, - parameters: &RsaOaepParameters, - ciphertext: &[u8], + _key: &rsa::RsaPrivateKey, + _parameters: &RsaOaepParameters, + _ciphertext: &[u8], ) -> Result, crate::provider::ProviderError> { - crate::provider::CryptoProvider::recover_key( - &crate::provider::RustCryptoProvider, - key, - parameters, - ciphertext, - ) + self.recover_calls.fetch_add(1, Ordering::Relaxed); + Ok(vec![0_u8; 16]) } } @@ -1241,6 +1284,111 @@ mod tests { assert_eq!(provider.unwrap_calls.load(Ordering::Relaxed), 0); } + #[test] + fn rejects_content_ciphertext_framing_before_resolution_or_provider_dispatch() { + // Algorithm framing belongs to the XMLEnc facade. A permissive provider + // and resolver must never observe malformed standard CipherValue bytes. + for (algorithm, ciphertext_len) in [ + (DataEncryptionAlgorithm::Aes128Gcm, 27), + (DataEncryptionAlgorithm::Aes128Cbc, 33), + ] { + let resolver = AllCallsResolver { + calls: Cell::new(0), + key: vec![0_u8; algorithm.key_len()], + }; + let provider = PermissiveUnwrapProvider::default(); + let encrypted = EncryptedData { + id: None, + encrypted_type: None, + key_name: None, + encryption_method: super::super::EncryptionMethod { + algorithm: algorithm.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(vec![0_u8; ciphertext_len]), + }, + }; + + assert!( + DecryptContext::new(&resolver) + .provider(&provider) + .decrypt_data(&encrypted) + .is_err() + ); + assert_eq!(resolver.calls.get(), 0); + assert_eq!(provider.decrypt_calls.load(Ordering::Relaxed), 0); + } + } + + #[test] + fn rejects_malformed_aes_kw_before_custom_provider_dispatch() { + // RFC 3394 adds exactly eight bytes to the transported content key; + // permissive custom providers must not redefine that wire contract. + let provider = PermissiveUnwrapProvider::default(); + for actual in [0, 23, 25] { + let encrypted_key = EncryptedKey { + id: None, + recipient: None, + key_name: None, + encryption_method: super::super::EncryptionMethod { + algorithm: KeyWrapAlgorithm::AesKw128.uri().into(), + key_size_bits: None, + oaep_digest: None, + mgf_algorithm: None, + oaep_params: None, + }, + cipher_data: super::super::CipherData { + value: STANDARD.encode(vec![0_u8; actual]), + }, + reference_list: None, + carried_key_name: None, + }; + assert!(matches!( + KekDecryptor::new([0_u8; 16]).resolve_key( + &provider, + DataEncryptionAlgorithm::Aes128Gcm, + Some(&encrypted_key), + ), + Err(XmlEncError::InvalidWrappedKeyLength { + expected: 24, + actual: output_len, + }) if output_len == actual + )); + } + assert_eq!(provider.unwrap_calls.load(Ordering::Relaxed), 0); + } + + #[test] + fn rejects_malformed_rsa_oaep_before_custom_provider_dispatch() { + // RSA ciphertext width is the private modulus width, so malformed + // transport bytes must be rejected before provider-owned recovery. + let private_key = RsaPrivateKey::from_pkcs8_pem(include_str!( + "../../tests/fixtures/keys/rsa/rsa-2048-key.pem" + )) + .expect("RSA donor private key must parse"); + let provider = PermissiveUnwrapProvider::default(); + for actual in [0, 255, 257] { + assert!(matches!( + recover_rsa_oaep( + &provider, + &private_key, + &RsaOaepParameters::default(), + &vec![0_u8; actual], + ), + Err(XmlEncError::InvalidWrappedKeyLength { + expected: 256, + actual: output_len, + }) if output_len == actual + )); + } + assert_eq!(provider.recover_calls.load(Ordering::Relaxed), 0); + } + #[test] fn rejects_truncated_gcm_and_invalid_wrapped_key() { // Framing and key-wrap integrity failures must occur before content is exposed. diff --git a/src/xmlenc/types.rs b/src/xmlenc/types.rs index 9469404f..e573f6dc 100644 --- a/src/xmlenc/types.rs +++ b/src/xmlenc/types.rs @@ -589,12 +589,12 @@ pub enum XmlEncError { /// Actual KEK size. actual: usize, }, - /// A provider returned a transported key with invalid algorithm framing. - #[error("wrapped-key output must be {expected} bytes, got {actual}")] + /// A wrapped-key input or provider output has invalid algorithm framing. + #[error("wrapped-key value must be {expected} bytes, got {actual}")] InvalidWrappedKeyLength { - /// Exact wrapped length required for the supplied content key. + /// Exact wrapped length required by the algorithm and key context. expected: usize, - /// Actual provider output length. + /// Actual input or provider output length. actual: usize, }, /// Plaintext exceeds the bounded encryption input size. diff --git a/tests/signing_digest.rs b/tests/signing_digest.rs index 7193f520..9f2aff38 100644 --- a/tests/signing_digest.rs +++ b/tests/signing_digest.rs @@ -158,6 +158,74 @@ fn signing_keys_reject_unsupported_signature_algorithms() { } } +#[test] +fn signing_facade_rejects_malformed_key_specific_signature_output() { + // Custom signing keys cannot bypass fixed-width RSA and XMLDSig ECDSA + // output framing before SignatureValue serialization. + struct FixedOutputSigningKey { + output: Vec, + public_key: SigningPublicKeyInfo, + } + + impl SigningKey for FixedOutputSigningKey { + fn sign( + &self, + _algorithm: SignatureAlgorithm, + _canonical_signed_info: &[u8], + ) -> Result, SigningKeyError> { + Ok(self.output.clone()) + } + + fn public_key_info(&self) -> Result { + Ok(self.public_key.clone()) + } + } + + let xml = "hello"; + let cases = [ + ( + SignatureAlgorithm::RsaSha256, + SigningPublicKeyInfo::Rsa { + spki_der: Vec::new(), + modulus: vec![1_u8; 256], + exponent: vec![1, 0, 1], + }, + 255, + 256, + ), + ( + SignatureAlgorithm::EcdsaSha256, + SigningPublicKeyInfo::Ec { + spki_der: Vec::new(), + curve_oid: "1.2.840.10045.3.1.7", + public_key: [vec![4], vec![1_u8; 64]].concat(), + }, + 63, + 64, + ), + ]; + + for (algorithm, public_key, actual, expected) in cases { + let key = FixedOutputSigningKey { + output: vec![1_u8; actual], + public_key, + }; + let builder = SignatureBuilder::new(exclusive_c14n(), algorithm).add_reference( + ReferenceBuilder::new(DigestAlgorithm::Sha256) + .uri("#payload") + .transform(Transform::C14n(exclusive_c14n())), + ); + + assert!(matches!( + SignContext::new(&key).sign_with_builder(xml, &builder), + Err(SigningError::InvalidSignatureOutputLength { + expected: expected_len, + actual: actual_len, + }) if expected_len == expected && actual_len == actual + )); + } +} + #[test] fn x509_key_info_writer_uses_structured_public_key_info() { struct PublicInfoFailingKey; From 6f8ee3e987682b77f4242e671afb172fe2827d25 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 12 Aug 2026 10:32:45 +0300 Subject: [PATCH 57/63] fix(policy): enforce outbound RSA strength --- README.md | 4 +- docs/xmldsig.md | 3 + docs/xmlenc.md | 3 + src/hard_limits.rs | 4 + src/policy.rs | 182 +++++++++++++++++++++++++++++++++++++++ src/xmldsig/sign.rs | 26 ++++-- src/xmldsig/signature.rs | 34 ++------ src/xmlenc/encrypt.rs | 56 ++++++++++-- tests/signing_digest.rs | 84 ++++++++++++++++++ 9 files changed, 352 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 3a2b2649..7345231e 100644 --- a/README.md +++ b/README.md @@ -39,7 +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 XML parsing, explicit transforms, implicit reference canonicalization, and `SignedInfo` canonicalization under shared work limits +- Typed signing and verification policy covers XML parsing, explicit transforms, implicit reference canonicalization, `SignedInfo` canonicalization, and outbound RSA key strength 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 @@ -51,7 +51,7 @@ Currently implemented (core paths): - 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; document, node, and aggregate recipient - limits cover caller-constructed ciphertext and generated replacement output + limits plus outbound RSA key-strength policy cover caller-constructed ciphertext and generated replacement output before expensive work. CBC failures expose no decrypted padding details, but CBC remains unauthenticated and can be excluded by policy diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 45c8be12..88157536 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -32,6 +32,9 @@ The same immutable policy controls every signing parse and mutation reparse, inc validation in `sign_with_builder`, digest filling, `SignedInfo` parsing, signature filling, and optional `KeyInfo` filling. An internal-DTD opt-in and XML node ceiling therefore cannot be lost between stages. +`SigningPolicy::rsa_keys` validates normalized modulus width and public exponent before provider +dispatch. The default accepts 2048-8192-bit RSA keys for new signatures; compatibility callers can +raise or lower the minimum explicitly, while the 8192-bit implementation ceiling cannot be relaxed. `SignContext::provider` selects both digest primitives and operation randomness. Built-in ECDSA signing obtains its prehash from that provider, while built-in RSA signing routes its blinding diff --git a/docs/xmlenc.md b/docs/xmlenc.md index 1cfe6a5b..9b7be8cd 100644 --- a/docs/xmlenc.md +++ b/docs/xmlenc.md @@ -41,6 +41,9 @@ together with the cryptographic primitives. The crate's secure RSA-OAEP default SHA-256/MGF1-SHA-256. XMLEnc 1.1 itself defaults omitted parameters to SHA-1/MGF1-SHA-1, so `xml-sec` always emits explicit `ds:DigestMethod` and `xenc11:MGF` values rather than relying on those implicit legacy defaults. SHA-1 OAEP remains available only through explicit parameters. +`EncryptionPolicy::rsa_keys` validates every recipient modulus and exponent before provider +dispatch. New output defaults to 2048-8192-bit RSA keys; callers can explicitly tighten or relax +the minimum for a deployment profile, but cannot exceed the implementation ceiling. The legacy `rsa-oaep-mgf1p` URI fixes MGF1 to SHA-1; configuration validation rejects any other MGF digest before provider dispatch because that URI has no wire field capable of representing it. AES-KW configuration similarly validates the KEK size fixed by its algorithm URI before provider diff --git a/src/hard_limits.rs b/src/hard_limits.rs index 3e4fd5d1..0378c833 100644 --- a/src/hard_limits.rs +++ b/src/hard_limits.rs @@ -33,3 +33,7 @@ pub(crate) const ENCRYPTION_DOCUMENT_BYTE_CEILING: usize = pub(crate) const ENCRYPTION_RECIPIENT_CEILING: usize = 64; #[cfg(any(feature = "xmldsig", feature = "xmlenc"))] pub(crate) const ENCRYPTION_METADATA_BYTE_CEILING: usize = 4 * 1024; + +/// Largest RSA modulus accepted by built-in operation paths. +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] +pub(crate) const RSA_MODULUS_BIT_CEILING: usize = 8192; diff --git a/src/policy.rs b/src/policy.rs index cbf1fc5a..52c22e18 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -60,6 +60,124 @@ pub enum PolicyViolation { /// Non-secret reason suitable for diagnostics. reason: &'static str, }, + /// An RSA key falls outside the operation's configured strength range. + #[error( + "{operation} policy requires {key_type} keys between {minimum_bits} and {maximum_bits} bits: got {actual_bits}" + )] + KeySize { + /// Operation evaluating the key. + operation: &'static str, + /// Stable key-family diagnostic. + key_type: &'static str, + /// Configured minimum modulus width. + minimum_bits: usize, + /// Non-configurable implementation ceiling. + maximum_bits: usize, + /// Observed normalized modulus width. + actual_bits: usize, + }, + /// RSA key material is structurally invalid. + #[error("{operation} policy rejects invalid {key_type} key material: {reason}")] + InvalidKeyMaterial { + /// Operation evaluating the key. + operation: &'static str, + /// Stable key-family diagnostic. + key_type: &'static str, + /// Non-secret structural rejection reason. + reason: &'static str, + }, +} + +/// RSA strength and structural requirements for outbound cryptographic operations. +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RsaKeyPolicy { + /// Minimum normalized RSA modulus width accepted for new output. + pub minimum_modulus_bits: usize, +} + +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] +impl Default for RsaKeyPolicy { + fn default() -> Self { + Self { + minimum_modulus_bits: 2048, + } + } +} + +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] +impl RsaKeyPolicy { + /// Validate the configured minimum against the implementation ceiling. + pub fn validate(&self) -> Result<(), PolicyViolation> { + if self.minimum_modulus_bits == 0 || !self.minimum_modulus_bits.is_multiple_of(8) { + return Err(PolicyViolation::InvalidResourceLimit { + resource: "minimum RSA modulus bits", + requirement: "minimum must be a nonzero whole-byte width", + actual: self.minimum_modulus_bits, + }); + } + ResourcePolicy::within( + "minimum RSA modulus bits", + self.minimum_modulus_bits, + crate::hard_limits::RSA_MODULUS_BIT_CEILING, + ) + } + + pub(crate) fn validate_components( + &self, + operation: &'static str, + modulus: &[u8], + exponent: &[u8], + ) -> Result { + self.validate()?; + let modulus = modulus + .iter() + .position(|byte| *byte != 0) + .map(|start| &modulus[start..]) + .ok_or(PolicyViolation::InvalidKeyMaterial { + operation, + key_type: "RSA", + reason: "modulus is zero", + })?; + let modulus_bits = + modulus + .len() + .checked_mul(8) + .ok_or(PolicyViolation::InvalidKeyMaterial { + operation, + key_type: "RSA", + reason: "modulus width overflows", + })?; + if !(self.minimum_modulus_bits..=crate::hard_limits::RSA_MODULUS_BIT_CEILING) + .contains(&modulus_bits) + { + return Err(PolicyViolation::KeySize { + operation, + key_type: "RSA", + minimum_bits: self.minimum_modulus_bits, + maximum_bits: crate::hard_limits::RSA_MODULUS_BIT_CEILING, + actual_bits: modulus_bits, + }); + } + if exponent.is_empty() || exponent[0] & 0x80 != 0 || exponent.len() > 8 { + return Err(PolicyViolation::InvalidKeyMaterial { + operation, + key_type: "RSA", + reason: "public exponent has invalid encoding", + }); + } + let mut exponent_bytes = [0_u8; 8]; + exponent_bytes[8 - exponent.len()..].copy_from_slice(exponent); + let exponent = u64::from_be_bytes(exponent_bytes); + if !(3..=((1_u64 << 33) - 1)).contains(&exponent) || exponent % 2 == 0 { + return Err(PolicyViolation::InvalidKeyMaterial { + operation, + key_type: "RSA", + reason: "public exponent is outside the supported odd range", + }); + } + Ok(modulus.len()) + } } /// Resource ceilings shared by parsing, transforms, and cryptographic output. @@ -187,6 +305,7 @@ impl ResourcePolicy { Ok(()) } + #[cfg(feature = "xmldsig")] fn nonzero_within( resource: &'static str, selected: usize, @@ -366,6 +485,8 @@ pub struct SigningPolicy { pub signature_algorithms: Option>, /// Allowed reference digest methods; `None` uses the implemented secure defaults. pub digest_algorithms: Option>, + /// RSA requirements enforced before producing a signature. + pub rsa_keys: RsaKeyPolicy, /// Allowed transform URIs; `None` accepts every implemented transform. pub transforms: Option>, /// XML parser rules. @@ -376,6 +497,15 @@ pub struct SigningPolicy { pub resources: ResourcePolicy, } +#[cfg(feature = "xmldsig")] +impl SigningPolicy { + /// Validate the complete snapshot before signing work begins. + pub fn validate(&self) -> Result<(), PolicyViolation> { + self.resources.validate()?; + self.rsa_keys.validate() + } +} + /// Immutable policy snapshot for XMLEnc encryption. #[cfg(feature = "xmlenc")] #[derive(Debug, Clone, Default)] @@ -388,12 +518,23 @@ pub struct EncryptionPolicy { pub key_wrap_algorithms: Option>, /// Allowed OAEP digest algorithms. pub oaep_digests: Option>, + /// RSA requirements enforced when producing OAEP key transport. + pub rsa_keys: RsaKeyPolicy, /// XML parser rules. pub xml: XmlInputPolicy, /// Resource ceilings. pub resources: ResourcePolicy, } +#[cfg(feature = "xmlenc")] +impl EncryptionPolicy { + /// Validate the complete snapshot before outbound encryption work begins. + pub fn validate(&self) -> Result<(), PolicyViolation> { + self.resources.validate()?; + self.rsa_keys.validate() + } +} + /// Immutable policy snapshot for XMLEnc decryption. #[cfg(feature = "xmlenc")] pub type DecryptionPolicy = EncryptionPolicy; @@ -475,6 +616,47 @@ mod tests { assert_eq!(policy.validate(), Ok(())); } + #[cfg(any(feature = "xmldsig", feature = "xmlenc"))] + #[test] + fn rsa_key_policy_enforces_structure_range_and_explicit_relaxation() { + let secure = RsaKeyPolicy::default(); + assert!(matches!( + secure.validate_components("test", &[1; 128], &[1, 0, 1]), + Err(PolicyViolation::KeySize { + minimum_bits: 2048, + maximum_bits: 8192, + actual_bits: 1024, + .. + }) + )); + assert!(matches!( + secure.validate_components("test", &[1; 1025], &[1, 0, 1]), + Err(PolicyViolation::KeySize { + actual_bits: 8200, + .. + }) + )); + assert!(matches!( + secure.validate_components("test", &[1; 256], &[2]), + Err(PolicyViolation::InvalidKeyMaterial { .. }) + )); + + let compatibility = RsaKeyPolicy { + minimum_modulus_bits: 1024, + }; + assert_eq!( + compatibility.validate_components("test", &[1; 128], &[1, 0, 1]), + Ok(128) + ); + assert!( + RsaKeyPolicy { + minimum_modulus_bits: 2047, + } + .validate() + .is_err() + ); + } + #[cfg(feature = "xmldsig")] #[test] fn mandatory_x509_limits_report_the_nonzero_requirement() { diff --git a/src/xmldsig/sign.rs b/src/xmldsig/sign.rs index 1440ff6a..5f66b7e7 100644 --- a/src/xmldsig/sign.rs +++ b/src/xmldsig/sign.rs @@ -225,11 +225,11 @@ impl SigningPublicKeyInfo { } } -fn validate_signature_output( +fn expected_signature_output_len( key: &dyn SigningKey, algorithm: SignatureAlgorithm, - signature: &[u8], -) -> Result<(), SigningError> { + policy: &crate::policy::SigningPolicy, +) -> Result { let public_key = key.public_key_info()?; let expected = match (algorithm, public_key) { ( @@ -237,8 +237,12 @@ fn validate_signature_output( | SignatureAlgorithm::RsaSha256 | SignatureAlgorithm::RsaSha384 | SignatureAlgorithm::RsaSha512, - SigningPublicKeyInfo::Rsa { modulus, .. }, - ) if !modulus.is_empty() => modulus.len(), + SigningPublicKeyInfo::Rsa { + modulus, exponent, .. + }, + ) => policy + .rsa_keys + .validate_components("signing", &modulus, &exponent)?, ( SignatureAlgorithm::EcdsaSha256 | SignatureAlgorithm::EcdsaSha384, SigningPublicKeyInfo::Ec { public_key, .. }, @@ -268,6 +272,10 @@ fn validate_signature_output( } _ => return Err(SigningKeyError::InvalidPublicKeyInfo.into()), }; + Ok(expected) +} + +fn validate_signature_output(expected: usize, signature: &[u8]) -> Result<(), SigningError> { if signature.len() != expected { return Err(SigningError::InvalidSignatureOutputLength { expected, @@ -676,7 +684,7 @@ impl<'a> SignContext<'a> { /// canonicalizes ``, signs those canonical bytes, and fills the /// base64 ``. pub fn sign_template(&self, xml: &str) -> Result { - self.policy.resources.validate()?; + self.policy.validate()?; let execution_budget = TransformExecutionBudget::from_resources(&self.policy.resources); let transform_options = TransformOptions::default() .allow_internal_dtd(self.policy.xml.allow_internal_dtd) @@ -706,10 +714,12 @@ impl<'a> SignContext<'a> { } .into()); } + let expected_signature_len = + expected_signature_output_len(self.signing_key, algorithm, &self.policy)?; let signature_value = self.provider .sign(self.signing_key, algorithm, &canonical_signed_info)?; - validate_signature_output(self.signing_key, algorithm, &signature_value)?; + validate_signature_output(expected_signature_len, &signature_value)?; let signature_b64 = base64::engine::general_purpose::STANDARD.encode(signature_value); let signed = fill_signature_value_with_options(&with_digests, &signature_b64, Some(&self.policy))?; @@ -731,7 +741,7 @@ impl<'a> SignContext<'a> { xml: &str, builder: &SignatureBuilder, ) -> Result { - self.policy.resources.validate()?; + self.policy.validate()?; let template = builder.build_template()?; let templated = append_signature_to_root_with_options(xml, &template, Some(&self.policy))?; self.sign_template(&templated) diff --git a/src/xmldsig/signature.rs b/src/xmldsig/signature.rs index 192f08fc..885c250d 100644 --- a/src/xmldsig/signature.rs +++ b/src/xmldsig/signature.rs @@ -316,36 +316,12 @@ pub(crate) fn validate_rsa_key_components( exponent: &[u8], minimum_modulus_bits: usize, ) -> Result<(), SignatureVerificationError> { - let modulus_start = modulus - .iter() - .position(|byte| *byte != 0) - .ok_or(SignatureVerificationError::InvalidKeyDer)?; - let modulus = &modulus[modulus_start..]; - if modulus.is_empty() { - return Err(SignatureVerificationError::InvalidKeyDer); - } - // Match ring's RSA parameter checks: modulus length is evaluated after - // rounding up to the nearest whole byte, not by exact significant-bit - // length of the highest non-zero byte. - let modulus_bits = modulus - .len() - .checked_mul(8) - .ok_or(SignatureVerificationError::InvalidKeyDer)?; - if !(minimum_modulus_bits..=8192).contains(&modulus_bits) { - return Err(SignatureVerificationError::InvalidKeyDer); - } - - if exponent.is_empty() || exponent[0] & 0x80 != 0 || exponent.len() > 8 { - return Err(SignatureVerificationError::InvalidKeyDer); - } - let mut exponent_bytes = [0_u8; 8]; - exponent_bytes[8 - exponent.len()..].copy_from_slice(exponent); - let exponent = u64::from_be_bytes(exponent_bytes); - if !(3..=((1_u64 << 33) - 1)).contains(&exponent) || exponent % 2 == 0 { - return Err(SignatureVerificationError::InvalidKeyDer); + crate::policy::RsaKeyPolicy { + minimum_modulus_bits, } - - Ok(()) + .validate_components("verification", modulus, exponent) + .map(|_| ()) + .map_err(|_| SignatureVerificationError::InvalidKeyDer) } fn ensure_rsa_signature_algorithm( diff --git a/src/xmlenc/encrypt.rs b/src/xmlenc/encrypt.rs index c1bed596..af2c3edb 100644 --- a/src/xmlenc/encrypt.rs +++ b/src/xmlenc/encrypt.rs @@ -122,7 +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.policy.validate()?; self.validate_plaintext_len(xml.len())?; validate_xml_plaintext(xml, &self.encrypted_type, &self.policy)?; self.encrypt_payload(xml.as_bytes(), Some(self.encrypted_type.clone())) @@ -130,7 +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.policy.validate()?; self.encrypt_payload(data, None) } @@ -140,7 +140,7 @@ impl EncryptedDataBuilder { xml: &str, options: DocumentEncryptionOptions<'_>, ) -> Result { - self.policy.resources.validate()?; + self.policy.validate()?; self.validate_document_len(xml.len())?; let parsing_options = encryption_parsing_options( &self.policy, @@ -259,7 +259,7 @@ impl EncryptedDataBuilder { } fn validate_configuration(&self) -> Result<(), XmlEncError> { - self.policy.resources.validate()?; + self.policy.validate()?; if self .policy .data_algorithms @@ -293,11 +293,16 @@ impl EncryptedDataBuilder { for recipient in &self.recipients { match recipient { EncryptionRecipient::RsaOaep { + public_key, parameters, recipient, key_name, - .. } => { + self.policy.rsa_keys.validate_components( + "encryption", + &public_key.n().to_be_bytes_trimmed_vartime(), + &public_key.e().to_be_bytes_trimmed_vartime(), + )?; if parameters.algorithm == super::KeyTransportAlgorithm::RsaOaepMgf1p && parameters.mgf_digest != super::OaepDigestAlgorithm::Sha1 { @@ -893,6 +898,8 @@ fn replace_range(xml: &str, range: std::ops::Range, replacement: &str) -> #[cfg(test)] mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; use getrandom::SysRng; @@ -917,6 +924,7 @@ mod tests { ciphertext: Option>, wrapped_key: Option>, transported_key: Option>, + transport_calls: AtomicUsize, } impl crate::provider::CryptoProvider for OverridingOutputProvider { @@ -1048,6 +1056,7 @@ mod tests { parameters: &RsaOaepParameters, plaintext: &[u8], ) -> Result, crate::provider::ProviderError> { + self.transport_calls.fetch_add(1, Ordering::Relaxed); if let Some(transported_key) = &self.transported_key { return Ok(transported_key.clone()); } @@ -1700,6 +1709,7 @@ mod tests { ciphertext: Some(ciphertext), wrapped_key: None, transported_key: None, + transport_calls: AtomicUsize::new(0), })) .direct_key(vec![0_u8; algorithm.key_len()]) .encrypt_binary(b"data") @@ -1721,6 +1731,7 @@ mod tests { ciphertext: None, wrapped_key: Some(wrapped_key), transported_key: None, + transport_calls: AtomicUsize::new(0), })) .recipient_aes_kw([0_u8; 16], KeyWrapAlgorithm::AesKw128) .encrypt_binary(b"data"); @@ -1736,6 +1747,7 @@ mod tests { ciphertext: None, wrapped_key: Some(vec![0_u8; 24]), transported_key: None, + transport_calls: AtomicUsize::new(0), })) .recipient_aes_kw([0_u8; 16], KeyWrapAlgorithm::AesKw128) .encrypt_binary(b"data") @@ -1755,6 +1767,7 @@ mod tests { ciphertext: None, wrapped_key: None, transported_key: Some(transported_key), + transport_calls: AtomicUsize::new(0), })) .recipient_rsa_oaep(public_key.clone()) .encrypt_binary(b"data"); @@ -1772,12 +1785,45 @@ mod tests { ciphertext: None, wrapped_key: None, transported_key: Some(vec![0_u8; 256]), + transport_calls: AtomicUsize::new(0), })) .recipient_rsa_oaep(public_key) .encrypt_binary(b"data") .expect("modulus-sized RSA transport output must remain accepted"); } + #[test] + fn encryption_policy_rejects_weak_rsa_recipient_before_provider_dispatch() { + // Provider capability cannot weaken the outbound recipient-key policy. + let private_key = RsaPrivateKey::new(&mut UnwrapErr(SysRng), 1024) + .expect("test RSA key generation should succeed"); + let provider = Arc::new(OverridingOutputProvider { + ciphertext: None, + wrapped_key: None, + transported_key: Some(vec![0_u8; 128]), + transport_calls: AtomicUsize::new(0), + }); + + let result = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .provider(provider.clone()) + .recipient_rsa_oaep(RsaPublicKey::from(&private_key)) + .encrypt_binary(b"data"); + + assert!(matches!( + result, + Err(XmlEncError::Policy( + crate::policy::PolicyViolation::KeySize { + operation: "encryption", + minimum_bits: 2048, + maximum_bits: 8192, + actual_bits: 1024, + .. + } + )) + )); + assert_eq!(provider.transport_calls.load(Ordering::Relaxed), 0); + } + #[test] fn debug_output_redacts_symmetric_key_material() { let direct_key = b"direct-key-secret".to_vec(); diff --git a/tests/signing_digest.rs b/tests/signing_digest.rs index 9f2aff38..3b77c4d0 100644 --- a/tests/signing_digest.rs +++ b/tests/signing_digest.rs @@ -1,4 +1,8 @@ use std::collections::HashSet; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; use xml_sec::c14n::{C14nAlgorithm, C14nMode}; use xml_sec::policy::SigningPolicy; @@ -226,6 +230,86 @@ fn signing_facade_rejects_malformed_key_specific_signature_output() { } } +#[test] +fn signing_policy_rejects_weak_rsa_key_before_provider_dispatch() { + struct CountingSigningKey { + calls: Arc, + modulus: Vec, + exponent: Vec, + } + + impl SigningKey for CountingSigningKey { + fn sign( + &self, + _algorithm: SignatureAlgorithm, + _canonical_signed_info: &[u8], + ) -> Result, SigningKeyError> { + self.calls.fetch_add(1, Ordering::Relaxed); + Ok(vec![1_u8; self.modulus.len()]) + } + + fn public_key_info(&self) -> Result { + Ok(SigningPublicKeyInfo::Rsa { + spki_der: Vec::new(), + modulus: self.modulus.clone(), + exponent: self.exponent.clone(), + }) + } + } + + let builder = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::RsaSha256) + .add_reference( + ReferenceBuilder::new(DigestAlgorithm::Sha256) + .uri("#payload") + .transform(Transform::C14n(exclusive_c14n())), + ); + + for (modulus_bytes, actual_bits) in [(128, 1024), (1025, 8200)] { + let calls = Arc::new(AtomicUsize::new(0)); + let key = CountingSigningKey { + calls: Arc::clone(&calls), + modulus: vec![1_u8; modulus_bytes], + exponent: vec![1, 0, 1], + }; + assert!(matches!( + SignContext::new(&key).sign_with_builder( + "hello", + &builder + ), + Err(SigningError::Policy( + xml_sec::policy::PolicyViolation::KeySize { + operation: "signing", + minimum_bits: 2048, + maximum_bits: 8192, + actual_bits: observed_bits, + .. + } + )) if observed_bits == actual_bits + )); + assert_eq!(calls.load(Ordering::Relaxed), 0); + } + + let calls = Arc::new(AtomicUsize::new(0)); + let key = CountingSigningKey { + calls: Arc::clone(&calls), + modulus: vec![1_u8; 256], + exponent: vec![2], + }; + assert!(matches!( + SignContext::new(&key).sign_with_builder( + "hello", + &builder + ), + Err(SigningError::Policy( + xml_sec::policy::PolicyViolation::InvalidKeyMaterial { + operation: "signing", + .. + } + )) + )); + assert_eq!(calls.load(Ordering::Relaxed), 0); +} + #[test] fn x509_key_info_writer_uses_structured_public_key_info() { struct PublicInfoFailingKey; From d8c8cf2cc21e09b7036d28fa3c8726af91c65092 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 12 Aug 2026 12:30:01 +0300 Subject: [PATCH 58/63] fix(policy): enforce exact output bounds - Measure RSA modulus strength by mathematical bit length - Bound generated EncryptedData nodes before returning output - Preserve projected-document and provider-framing coverage --- src/policy.rs | 38 ++++++----- src/xmlenc/encrypt.rs | 136 ++++++++++++++++++++++++++++++++-------- tests/signing_digest.rs | 6 +- 3 files changed, 138 insertions(+), 42 deletions(-) diff --git a/src/policy.rs b/src/policy.rs index 52c22e18..49613a59 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -92,7 +92,7 @@ pub enum PolicyViolation { #[cfg(any(feature = "xmldsig", feature = "xmlenc"))] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RsaKeyPolicy { - /// Minimum normalized RSA modulus width accepted for new output. + /// Minimum mathematical RSA modulus bit length accepted for new output. pub minimum_modulus_bits: usize, } @@ -139,15 +139,15 @@ impl RsaKeyPolicy { key_type: "RSA", reason: "modulus is zero", })?; - let modulus_bits = - modulus - .len() - .checked_mul(8) - .ok_or(PolicyViolation::InvalidKeyMaterial { - operation, - key_type: "RSA", - reason: "modulus width overflows", - })?; + let modulus_bits = modulus + .len() + .checked_mul(8) + .and_then(|width| width.checked_sub(modulus[0].leading_zeros() as usize)) + .ok_or(PolicyViolation::InvalidKeyMaterial { + operation, + key_type: "RSA", + reason: "modulus width overflows", + })?; if !(self.minimum_modulus_bits..=crate::hard_limits::RSA_MODULUS_BIT_CEILING) .contains(&modulus_bits) { @@ -621,7 +621,7 @@ mod tests { fn rsa_key_policy_enforces_structure_range_and_explicit_relaxation() { let secure = RsaKeyPolicy::default(); assert!(matches!( - secure.validate_components("test", &[1; 128], &[1, 0, 1]), + secure.validate_components("test", &[0x80; 128], &[1, 0, 1]), Err(PolicyViolation::KeySize { minimum_bits: 2048, maximum_bits: 8192, @@ -629,15 +629,25 @@ mod tests { .. }) )); + let mut short_2048_width = [0_u8; 256]; + short_2048_width[0] = 1; + assert!(matches!( + secure.validate_components("test", &short_2048_width, &[1, 0, 1]), + Err(PolicyViolation::KeySize { + minimum_bits: 2048, + actual_bits: 2041, + .. + }) + )); assert!(matches!( secure.validate_components("test", &[1; 1025], &[1, 0, 1]), Err(PolicyViolation::KeySize { - actual_bits: 8200, + actual_bits: 8193, .. }) )); assert!(matches!( - secure.validate_components("test", &[1; 256], &[2]), + secure.validate_components("test", &[0x80; 256], &[2]), Err(PolicyViolation::InvalidKeyMaterial { .. }) )); @@ -645,7 +655,7 @@ mod tests { minimum_modulus_bits: 1024, }; assert_eq!( - compatibility.validate_components("test", &[1; 128], &[1, 0, 1]), + compatibility.validate_components("test", &[0x80; 128], &[1, 0, 1]), Ok(128) ); assert!( diff --git a/src/xmlenc/encrypt.rs b/src/xmlenc/encrypt.rs index af2c3edb..96c4521f 100644 --- a/src/xmlenc/encrypt.rs +++ b/src/xmlenc/encrypt.rs @@ -34,6 +34,11 @@ pub struct EncryptedDataBuilder { provider: Arc, } +struct GeneratedEncryption { + result: EncryptionResult, + xml_nodes: usize, +} + impl fmt::Debug for EncryptedDataBuilder { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter @@ -126,12 +131,14 @@ impl EncryptedDataBuilder { self.validate_plaintext_len(xml.len())?; validate_xml_plaintext(xml, &self.encrypted_type, &self.policy)?; self.encrypt_payload(xml.as_bytes(), Some(self.encrypted_type.clone())) + .map(|generated| generated.result) } /// Encrypt opaque bytes without an XML `Type` attribute. pub fn encrypt_binary(&self, data: &[u8]) -> Result { self.policy.validate()?; self.encrypt_payload(data, None) + .map(|generated| generated.result) } /// Encrypt and replace the document root or one element selected by XML ID. @@ -153,8 +160,9 @@ impl EncryptedDataBuilder { match self.encrypted_type { EncryptedDataType::Element => { - let result = + let generated = self.encrypt_payload(source.as_bytes(), Some(EncryptedDataType::Element))?; + let result = generated.result; validate_replacement_document_len( xml.len(), range.len(), @@ -164,7 +172,7 @@ impl EncryptedDataBuilder { validate_replacement_document_nodes( &document, selected, - &result.encrypted_data_xml, + generated.xml_nodes, ReplacementMode::ReplaceElement, self.policy.resources.max_xml_nodes, )?; @@ -173,8 +181,9 @@ impl EncryptedDataBuilder { EncryptedDataType::Content => { let boundaries = element_content_boundaries(source)?; let plaintext = &source[boundaries.content.clone()]; - let result = + let generated = self.encrypt_payload(plaintext.as_bytes(), Some(EncryptedDataType::Content))?; + let result = generated.result; let (removed, inserted) = if boundaries.self_closing { let slash = source[..boundaries.start_tag_end] .rfind('/') @@ -200,7 +209,7 @@ impl EncryptedDataBuilder { validate_replacement_document_nodes( &document, selected, - &result.encrypted_data_xml, + generated.xml_nodes, ReplacementMode::ReplaceContent, self.policy.resources.max_xml_nodes, )?; @@ -216,7 +225,7 @@ impl EncryptedDataBuilder { &self, plaintext: &[u8], encrypted_type: Option, - ) -> Result { + ) -> Result { self.validate_plaintext_len(plaintext.len())?; self.validate_configuration()?; @@ -246,15 +255,22 @@ impl EncryptedDataBuilder { &ciphertext, )?; self.validate_document_len(encrypted_data_xml.len())?; + let xml_nodes = validate_generated_encrypted_data_nodes( + &encrypted_data_xml, + self.policy.resources.max_xml_nodes, + )?; let replacement = match encrypted_type { Some(EncryptedDataType::Content) => ReplacementMode::ReplaceContent, Some(EncryptedDataType::Element | EncryptedDataType::Other(_)) | None => { ReplacementMode::ReplaceElement } }; - Ok(EncryptionResult { - encrypted_data_xml, - replacement, + Ok(GeneratedEncryption { + result: EncryptionResult { + encrypted_data_xml, + replacement, + }, + xml_nodes, }) } @@ -507,19 +523,10 @@ fn validate_replacement_document_len( fn validate_replacement_document_nodes( document: &Document<'_>, selected: Node<'_, '_>, - inserted_xml: &str, + inserted_nodes: usize, replacement: ReplacementMode, maximum: usize, ) -> Result<(), XmlEncError> { - let inserted = Document::parse_with_options( - inserted_xml, - ParsingOptions { - allow_dtd: false, - nodes_limit: crate::hard_limits::XML_DOCUMENT_NODE_CEILING, - entity_resolver: None, - }, - )?; - let inserted_nodes = inserted.root_element().descendants().count(); let selected_nodes = selected.descendants().count(); let removed_nodes = match replacement { ReplacementMode::ReplaceElement => selected_nodes, @@ -542,6 +549,30 @@ fn validate_replacement_document_nodes( Ok(()) } +fn validate_generated_encrypted_data_nodes( + encrypted_data_xml: &str, + maximum: usize, +) -> Result { + let generated = Document::parse_with_options( + encrypted_data_xml, + ParsingOptions { + allow_dtd: false, + nodes_limit: crate::hard_limits::XML_DOCUMENT_NODE_CEILING, + entity_resolver: None, + }, + )?; + let actual = generated.root_element().descendants().count(); + if actual > maximum { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: "generated EncryptedData XML nodes", + maximum, + actual, + } + .into()); + } + Ok(actual) +} + fn validate_content_key(algorithm: DataEncryptionAlgorithm, key: &[u8]) -> Result<(), XmlEncError> { if key.len() == algorithm.key_len() { Ok(()) @@ -1394,18 +1425,31 @@ mod tests { } } + let generated_nodes = { + let encrypted = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key([0_u8; 16]) + .encrypt_binary(b"payload") + .expect("default policy must permit generated EncryptedData"); + Document::parse(&encrypted.encrypted_data_xml) + .expect("generated EncryptedData must parse") + .root_element() + .descendants() + .count() + }; + // The source document fits the low limit, but the generated - // EncryptedData tree does not. Capture its exact projected node count. + // EncryptedData replacement does not. The ceiling admits the standalone + // fragment so this specifically exercises whole-document projection. let element_actual = match EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) .direct_key([0_u8; 16]) - .policy(policy(2)) + .policy(policy(generated_nodes)) .encrypt_document("", DocumentEncryptionOptions::default()) { Err(XmlEncError::Policy(crate::policy::PolicyViolation::ResourceLimit { resource: "encrypted document XML nodes", - maximum: 2, + maximum, actual, - })) if actual > 2 => actual, + })) if maximum == generated_nodes && actual > maximum => actual, result => panic!("expected projected element node bound, got {result:?}"), }; EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) @@ -1420,14 +1464,14 @@ mod tests { let content_actual = match EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) .encryption_type(EncryptedDataType::Content) .direct_key([0_u8; 16]) - .policy(policy(2)) + .policy(policy(generated_nodes)) .encrypt_document("", DocumentEncryptionOptions::default()) { Err(XmlEncError::Policy(crate::policy::PolicyViolation::ResourceLimit { resource: "encrypted document XML nodes", - maximum: 2, + maximum, actual, - })) if actual > 2 => actual, + })) if maximum == generated_nodes && actual > maximum => actual, result => panic!("expected projected content node bound, got {result:?}"), }; EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) @@ -1438,6 +1482,48 @@ mod tests { .expect("the exact projected content node limit must be accepted"); } + #[test] + fn standalone_encryption_bounds_generated_xml_nodes() { + fn policy(max_xml_nodes: usize) -> crate::policy::EncryptionPolicy { + crate::policy::EncryptionPolicy { + resources: crate::policy::ResourcePolicy { + max_xml_nodes, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::EncryptionPolicy::default() + } + } + + // Binary encryption has no input XML tree, but its generated EncryptedData + // must still be consumable under the same operation-policy node ceiling. + assert!(matches!( + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key([0_u8; 16]) + .policy(policy(1)) + .encrypt_binary(b"payload"), + Err(XmlEncError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: "generated EncryptedData XML nodes", + maximum: 1, + actual, + })) if actual > 1 + )); + + let generated = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key([0_u8; 16]) + .encrypt_binary(b"payload") + .expect("default policy must permit generated EncryptedData"); + let actual = Document::parse(&generated.encrypted_data_xml) + .expect("generated EncryptedData must parse") + .root_element() + .descendants() + .count(); + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key([0_u8; 16]) + .policy(policy(actual)) + .encrypt_binary(b"payload") + .expect("the exact generated node limit must be accepted"); + } + #[test] fn document_dtd_requires_policy_and_per_call_opt_in() { // Internal DTD parsing is a two-party decision: operation policy sets diff --git a/tests/signing_digest.rs b/tests/signing_digest.rs index 3b77c4d0..ff5811c1 100644 --- a/tests/signing_digest.rs +++ b/tests/signing_digest.rs @@ -191,7 +191,7 @@ fn signing_facade_rejects_malformed_key_specific_signature_output() { SignatureAlgorithm::RsaSha256, SigningPublicKeyInfo::Rsa { spki_der: Vec::new(), - modulus: vec![1_u8; 256], + modulus: vec![0x80_u8; 256], exponent: vec![1, 0, 1], }, 255, @@ -268,7 +268,7 @@ fn signing_policy_rejects_weak_rsa_key_before_provider_dispatch() { let calls = Arc::new(AtomicUsize::new(0)); let key = CountingSigningKey { calls: Arc::clone(&calls), - modulus: vec![1_u8; modulus_bytes], + modulus: vec![0x80_u8; modulus_bytes], exponent: vec![1, 0, 1], }; assert!(matches!( @@ -292,7 +292,7 @@ fn signing_policy_rejects_weak_rsa_key_before_provider_dispatch() { let calls = Arc::new(AtomicUsize::new(0)); let key = CountingSigningKey { calls: Arc::clone(&calls), - modulus: vec![1_u8; 256], + modulus: vec![0x80_u8; 256], exponent: vec![2], }; assert!(matches!( From 3822b334db66b5ceafd5125f1c9226ef68dbe755 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 12 Aug 2026 12:33:17 +0300 Subject: [PATCH 59/63] fix(xmlenc): validate exact cipher output - Derive the exact CBC and GCM wire length from plaintext - Reject overlong custom-provider output before serialization - Cover block-aligned CBC and one-byte GCM excess --- src/xmlenc/encrypt.rs | 43 +++++++++++++++++++++++++++++++++++++++++++ src/xmlenc/types.rs | 11 +++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/xmlenc/encrypt.rs b/src/xmlenc/encrypt.rs index 96c4521f..acc86272 100644 --- a/src/xmlenc/encrypt.rs +++ b/src/xmlenc/encrypt.rs @@ -602,6 +602,20 @@ fn encrypt_content( ) -> Result, XmlEncError> { let ciphertext = provider.encrypt_data(algorithm, key, plaintext)?; super::types::validate_ciphertext_framing(algorithm, ciphertext.len())?; + let expected = algorithm + .ciphertext_len_for_plaintext(plaintext.len()) + .ok_or(XmlEncError::PlaintextTooLarge { + maximum: crate::hard_limits::ENCRYPTION_PLAINTEXT_BYTE_CEILING, + actual: plaintext.len(), + })?; + if ciphertext.len() != expected { + return Err(crate::provider::ProviderError::InvalidOutputSize { + operation: crate::provider::ProviderOperation::Encrypt, + expected, + actual: ciphertext.len(), + } + .into()); + } Ok(ciphertext) } @@ -1807,6 +1821,35 @@ mod tests { } } + #[test] + fn rejects_overlong_custom_provider_ciphertext_before_serialization() { + // Provider success cannot change the algorithm-defined relationship + // between plaintext and ciphertext length. + for (algorithm, expected, actual) in [ + (DataEncryptionAlgorithm::Aes128Gcm, 32, 33), + (DataEncryptionAlgorithm::Aes128Cbc, 32, 48), + ] { + let error = EncryptedDataBuilder::new(algorithm) + .provider(Arc::new(OverridingOutputProvider { + ciphertext: Some(vec![0_u8; actual]), + wrapped_key: None, + transported_key: None, + transport_calls: AtomicUsize::new(0), + })) + .direct_key(vec![0_u8; algorithm.key_len()]) + .encrypt_binary(b"data") + .expect_err("overlong provider output must fail before XML serialization"); + assert!(matches!( + error, + XmlEncError::Provider(crate::provider::ProviderError::InvalidOutputSize { + operation: crate::provider::ProviderOperation::Encrypt, + expected: observed_expected, + actual: observed_actual, + }) if observed_expected == expected && observed_actual == actual + )); + } + } + #[test] fn rejects_malformed_custom_provider_wrapped_keys_before_serialization() { // RFC 3394 adds exactly one 64-bit integrity block. Accepting any other diff --git a/src/xmlenc/types.rs b/src/xmlenc/types.rs index e573f6dc..807c0d4f 100644 --- a/src/xmlenc/types.rs +++ b/src/xmlenc/types.rs @@ -75,6 +75,17 @@ impl DataEncryptionAlgorithm { Self::Aes128Gcm | Self::Aes256Gcm => 28, } } + + /// Exact wire length produced when encrypting the given plaintext length. + pub(crate) fn ciphertext_len_for_plaintext(self, plaintext_len: usize) -> Option { + match self { + Self::Aes128Cbc | Self::Aes256Cbc => (plaintext_len / 16) + .checked_add(1)? + .checked_mul(16)? + .checked_add(16), + Self::Aes128Gcm | Self::Aes256Gcm => plaintext_len.checked_add(28), + } + } } pub(crate) fn validate_ciphertext_framing( From 765c6fa1d1ee94b1598d0a5f4d2235bf98dbb74a Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 12 Aug 2026 14:09:18 +0300 Subject: [PATCH 60/63] fix(policy): harden provider trust boundaries - validate custom decryption output framing - accept unsigned RSA exponents with a high bit - require bounded CRL validity windows - reject conflicting verification clocks --- docs/xmldsig.md | 4 ++ docs/xmlenc.md | 2 + src/policy.rs | 7 ++- src/provider.rs | 15 +++++ src/xmldsig/keys.rs | 52 ++++++++++++++-- src/xmldsig/x509.rs | 9 +-- src/xmlenc/decrypt.rs | 102 +++++++++++++++++++++++++++++++- tests/x509_chain_integration.rs | 48 +++++++++++++++ 8 files changed, 229 insertions(+), 10 deletions(-) diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 88157536..50f0996d 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -70,6 +70,8 @@ Configured chain depth and candidate-path limits are validated after resolver de the operation policy. Candidate-path accounting includes every generated partial path, and self-issued rollover certificates continue toward a distinct same-name issuer when its signature validates; neither condition can bypass the configured work bounds or trust anchor requirement. +An explicit verification time may come from either boundary; if both boundaries provide one, the +timestamps must be identical because silently preferring either clock would discard caller policy. Path validation excludes self-issued rollover CAs from `pathLenConstraint`, applies supported RFC 5280 NameConstraints to every subordinate certificate, and rejects critical extensions whose semantics are not implemented. Repeated extension OIDs are rejected certificate-wide before any @@ -147,6 +149,8 @@ CRLs without enabling certificate-chain validation is rejected during context co than silently accepting a control the resolver cannot enforce. CRL structure is validated before authority-key applicability is selected: duplicate CRL or CRL-entry extension OIDs fail closed, and `deltaCRLIndicator` is rejected regardless of criticality. +Applicable CRLs must include the RFC 5280 `nextUpdate` field and the verification time must remain +inside the bounded `thisUpdate` through `nextUpdate` validity window. The `removeFromCRL` reason is rejected in complete CRLs because it is meaningful only in a delta CRL. URI subject alternative names likewise require one RFC 3986 authority, including syntactically valid userinfo, before their host diff --git a/docs/xmlenc.md b/docs/xmlenc.md index 9b7be8cd..ff438c97 100644 --- a/docs/xmlenc.md +++ b/docs/xmlenc.md @@ -104,6 +104,8 @@ Unknown key transport and wrap URIs fail closed before an application resolver i 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. +Successful custom-provider output is also checked against the wire-derived contract: AES-GCM has +one exact plaintext length, while AES-CBC output must fit the range permitted by one padding block. That uniform diagnostic does not authenticate CBC or remove the success-versus-failure signal. Applications processing attacker-controlled ciphertext must authenticate the enclosing protocol before acting on plaintext, or exclude AES-CBC with `EncryptionPolicy::data_algorithms` and use diff --git a/src/policy.rs b/src/policy.rs index 49613a59..979b244a 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -159,7 +159,7 @@ impl RsaKeyPolicy { actual_bits: modulus_bits, }); } - if exponent.is_empty() || exponent[0] & 0x80 != 0 || exponent.len() > 8 { + if exponent.is_empty() || exponent.len() > 8 { return Err(PolicyViolation::InvalidKeyMaterial { operation, key_type: "RSA", @@ -650,6 +650,11 @@ mod tests { secure.validate_components("test", &[0x80; 256], &[2]), Err(PolicyViolation::InvalidKeyMaterial { .. }) )); + assert_eq!( + secure.validate_components("test", &[0x80; 256], &[0x80, 0, 0, 1]), + Ok(256), + "normalized RSA components encode the exponent as unsigned bytes" + ); let compatibility = RsaKeyPolicy { minimum_modulus_bits: 1024, diff --git a/src/provider.rs b/src/provider.rs index 9b3af7a0..bc6ac2e8 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -170,6 +170,21 @@ pub enum ProviderError { /// Actual provider output length. actual: usize, }, + /// A provider reported success but returned bytes outside the selected + /// operation's variable-size output contract. + #[error( + "invalid provider output size for {operation:?}: expected {minimum}..={maximum} bytes, got {actual}" + )] + InvalidOutputSizeRange { + /// Operation whose output contract was violated. + operation: ProviderOperation, + /// Smallest output length permitted by the algorithm. + minimum: usize, + /// Largest output length permitted by the algorithm. + maximum: usize, + /// Actual provider output length. + actual: usize, + }, /// Input framing, padding, or primitive initialization is invalid. #[error("invalid cryptographic input: {0}")] InvalidInput(ProviderInputError), diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index df796309..ffaa0d7f 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -737,6 +737,19 @@ impl KeyResolver for DefaultKeyResolver { // validation requirements can only become stricter, while the legacy // algorithm opt-in remains exclusively context-owned and is enforced // before key resolution. + let verification_time = match ( + policy.key_trust.verification_time, + self.config.trust.verification_time, + ) { + (Some(operation), Some(resolver)) if operation != resolver => { + return Err(crate::policy::PolicyViolation::KeyTrust { + reason: "operation and resolver verification times conflict", + } + .into()); + } + (Some(time), _) | (_, Some(time)) => Some(time), + (None, None) => None, + }; let trust = crate::policy::KeyTrustPolicy { verify_x509_chains: policy.key_trust.verify_x509_chains || self.config.trust.verify_x509_chains, @@ -756,10 +769,7 @@ impl KeyResolver for DefaultKeyResolver { .cloned() .collect(), 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), + verification_time, }; self.resolve_with_trust(key_info, algorithm, &trust, provider) } @@ -1367,6 +1377,40 @@ mod tests { )); } + #[test] + fn resolver_rejects_conflicting_explicit_verification_times() { + // Two explicit clocks are caller decisions, not tightening bounds. The + // resolver must not silently discard either source during composition. + let resolver_time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(10); + let operation_time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(20); + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + trust: crate::policy::KeyTrustPolicy { + verification_time: Some(resolver_time), + ..crate::policy::KeyTrustPolicy::default() + }, + ..KeyResolverConfig::default() + }); + let mut policy = crate::policy::VerificationPolicy::default(); + policy.key_trust.verification_time = Some(operation_time); + + assert!(matches!( + resolver.resolve_with_policy(None, SignatureAlgorithm::RsaSha256, &policy), + Err(DsigError::Policy( + crate::policy::PolicyViolation::KeyTrust { + reason: "operation and resolver verification times conflict" + } + )) + )); + + policy.key_trust.verification_time = Some(resolver_time); + assert!( + resolver + .resolve_with_policy(None, SignatureAlgorithm::RsaSha256, &policy) + .expect("identical explicit verification times must compose") + .is_none() + ); + } + #[test] fn hmac_key_rejects_empty_secret_and_wrong_algorithm() { // HMAC secrets are caller-owned and cannot be reused as asymmetric keys. diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index 40d87f0f..4cc32f08 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -1647,10 +1647,11 @@ fn verify_crls( required: "cRLSign", }); } - let time_valid = crl.last_update() <= verification_time - && crl - .next_update() - .is_none_or(|next| verification_time <= next); + // RFC 5280 requires conforming CRL issuers to provide nextUpdate; + // without it this verifier cannot establish a bounded freshness window. + let time_valid = crl.next_update().is_some_and(|next| { + crl.last_update() <= verification_time && verification_time <= next + }); if !time_valid { return Err(X509ChainError::InvalidCrl(*crl_index)); } diff --git a/src/xmlenc/decrypt.rs b/src/xmlenc/decrypt.rs index c69be7bf..8cadfd1e 100644 --- a/src/xmlenc/decrypt.rs +++ b/src/xmlenc/decrypt.rs @@ -132,6 +132,7 @@ impl<'a> DecryptContext<'a> { .provider .decrypt_data(algorithm, &key, &ciphertext) .map_err(|error| map_data_decryption_error(algorithm, ciphertext.len(), error))?; + validate_provider_plaintext_len(algorithm, ciphertext.len(), plaintext.len())?; validate_plaintext_len( plaintext.len(), self.policy.resources.max_encryption_plaintext_bytes, @@ -787,6 +788,43 @@ fn validate_possible_plaintext_len( validate_plaintext_len(ciphertext_len.saturating_sub(framing), maximum) } +fn validate_provider_plaintext_len( + algorithm: DataEncryptionAlgorithm, + ciphertext_len: usize, + plaintext_len: usize, +) -> Result<(), XmlEncError> { + use crate::provider::{ProviderError, ProviderOperation}; + + match algorithm { + DataEncryptionAlgorithm::Aes128Gcm | DataEncryptionAlgorithm::Aes256Gcm => { + let expected = ciphertext_len - algorithm.minimum_ciphertext_len(); + if plaintext_len != expected { + return Err(ProviderError::InvalidOutputSize { + operation: ProviderOperation::Decrypt, + expected, + actual: plaintext_len, + } + .into()); + } + } + DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc => { + let padded_len = ciphertext_len - 16; + let minimum = padded_len - 16; + let maximum = padded_len - 1; + if !(minimum..=maximum).contains(&plaintext_len) { + return Err(ProviderError::InvalidOutputSizeRange { + operation: ProviderOperation::Decrypt, + minimum, + maximum, + actual: plaintext_len, + } + .into()); + } + } + } + Ok(()) +} + fn validate_plaintext_len(actual: usize, maximum: usize) -> Result<(), XmlEncError> { if actual <= maximum { Ok(()) @@ -873,6 +911,7 @@ mod tests { decrypt_calls: AtomicUsize, unwrap_calls: AtomicUsize, recover_calls: AtomicUsize, + plaintext: Vec, } impl crate::provider::CryptoProvider for PermissiveUnwrapProvider { @@ -957,7 +996,7 @@ mod tests { _ciphertext: &[u8], ) -> Result, crate::provider::ProviderError> { self.decrypt_calls.fetch_add(1, Ordering::Relaxed); - Ok(b"provider plaintext".to_vec()) + Ok(self.plaintext.clone()) } fn wrap_key( @@ -1325,6 +1364,67 @@ mod tests { } } + #[test] + fn rejects_custom_provider_plaintext_outside_algorithm_bounds() { + // A provider success result is still untrusted: GCM fixes the plaintext + // length exactly, while CBC padding permits only one block-sized range. + for (algorithm, ciphertext_len, plaintext_len) in [ + (DataEncryptionAlgorithm::Aes128Gcm, 32, 5), + (DataEncryptionAlgorithm::Aes128Cbc, 32, 16), + ] { + let resolver = AllCallsResolver { + calls: Cell::new(0), + key: vec![0_u8; algorithm.key_len()], + }; + let provider = PermissiveUnwrapProvider { + plaintext: vec![0_u8; plaintext_len], + ..PermissiveUnwrapProvider::default() + }; + let encrypted = EncryptedData { + id: None, + encrypted_type: None, + key_name: None, + encryption_method: super::super::EncryptionMethod { + algorithm: algorithm.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(vec![0_u8; ciphertext_len]), + }, + }; + + let error = DecryptContext::new(&resolver) + .provider(&provider) + .decrypt_data(&encrypted) + .expect_err("impossible provider output length must fail"); + match algorithm { + DataEncryptionAlgorithm::Aes128Gcm => assert!(matches!( + error, + XmlEncError::Provider(crate::provider::ProviderError::InvalidOutputSize { + operation: crate::provider::ProviderOperation::Decrypt, + expected: 4, + actual: 5, + }) + )), + DataEncryptionAlgorithm::Aes128Cbc => assert!(matches!( + error, + XmlEncError::Provider(crate::provider::ProviderError::InvalidOutputSizeRange { + operation: crate::provider::ProviderOperation::Decrypt, + minimum: 0, + maximum: 15, + actual: 16, + }) + )), + _ => unreachable!("the regression table covers one GCM and one CBC algorithm"), + } + assert_eq!(provider.decrypt_calls.load(Ordering::Relaxed), 1); + } + } + #[test] fn rejects_malformed_aes_kw_before_custom_provider_dispatch() { // RFC 3394 adds exactly eight bytes to the transported content key; diff --git a/tests/x509_chain_integration.rs b/tests/x509_chain_integration.rs index 365d70e5..5f95eecd 100644 --- a/tests/x509_chain_integration.rs +++ b/tests/x509_chain_integration.rs @@ -5,6 +5,8 @@ use std::{ }; use base64::{Engine as _, engine::general_purpose::STANDARD}; +use der::{Decode as _, Encode as _, asn1::BitString}; +use rcgen::SigningKey as _; use rcgen::{ BasicConstraints, CertificateParams, CertificateRevocationListParams, CrlDistributionPoint, CrlIssuingDistributionPoint, CrlScope, IsCa, Issuer, KeyIdMethod, KeyPair, KeyUsagePurpose, @@ -537,6 +539,52 @@ fn rejects_malformed_crl_when_revocation_checking_is_enabled() { )); } +#[test] +fn rejects_crl_without_next_update() { + // RFC 5280 conforming issuers always provide nextUpdate; accepting an + // unbounded CRL would leave freshness undefined after thisUpdate. + let mut root_params = CertificateParams::new(Vec::new()).unwrap(); + root_params + .distinguished_name + .push(rcgen::DnType::CommonName, "missing nextUpdate root"); + root_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + root_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + let root = + rcgen::CertifiedIssuer::self_signed(root_params, KeyPair::generate().unwrap()).unwrap(); + let leaf = CertificateParams::new(Vec::new()) + .unwrap() + .signed_by(&KeyPair::generate().unwrap(), &root) + .unwrap(); + let crl = CertificateRevocationListParams { + this_update: date_time_ymd(2026, 3, 15), + next_update: date_time_ymd(2026, 4, 15), + crl_number: SerialNumber::from(1_u64), + issuing_distribution_point: None, + revoked_certs: Vec::new(), + key_identifier_method: KeyIdMethod::Sha256, + } + .signed_by(&root) + .unwrap(); + + let mut unsigned: x509_cert::crl::CertificateList = + x509_cert::crl::CertificateList::from_der(crl.der()).unwrap(); + unsigned.tbs_cert_list.next_update = None; + let signature = root + .key() + .sign(&unsigned.tbs_cert_list.to_der().unwrap()) + .unwrap(); + unsigned.signature = BitString::from_bytes(&signature).unwrap(); + + let mut info = generated_info(vec![leaf.der().to_vec(), root.der().to_vec()]); + info.crls.push(unsigned.to_der().unwrap()); + let anchors = [root.der().to_vec()]; + + assert_eq!( + verify_x509_certificate_chain(&info, &options(&anchors, true)), + Err(X509ChainError::InvalidCrl(0)) + ); +} + #[test] fn rejects_crl_with_unprocessed_critical_scope() { let mut root_params = CertificateParams::new(Vec::new()).unwrap(); From 34971e11a44e7338227fb4d8ca0239da26f341c4 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 12 Aug 2026 15:09:06 +0300 Subject: [PATCH 61/63] fix(policy): bound XML document processing - bypass inherited xml:base for absolute resource identities - share XML byte ceilings across signing, verification, and encryption - exclude internal fragment wrappers from caller node accounting --- docs/xmldsig.md | 2 ++ docs/xmlenc.md | 3 +- src/c14n/xml_base.rs | 17 +++++++++ src/hard_limits.rs | 3 +- src/policy.rs | 28 +++++++++++---- src/xmldsig/parse.rs | 37 ++++++++++++++------ src/xmldsig/sign.rs | 19 ++++++++--- src/xmldsig/uri.rs | 41 ++++++++++++++++------ src/xmldsig/verify.rs | 43 ++++++++++++++++++++--- src/xmlenc/decrypt.rs | 76 +++++++++++++++++++++++++++++++++-------- src/xmlenc/encrypt.rs | 12 +++---- src/xmlenc/parse.rs | 9 +---- tests/signing_digest.rs | 66 +++++++++++++++++++++++++++++++++++ 13 files changed, 288 insertions(+), 68 deletions(-) diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 50f0996d..68f48914 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -66,6 +66,8 @@ a trusted anchor. A trusted certificate selected directly remains an anchor, whi certificates provide key material and do not become trusted merely because they appear in ``. Exact DER duplicates across configured pools are evaluated once; when the same certificate is present in both pools, its explicit trusted classification is retained. +`ResourcePolicy::max_xml_document_bytes` rejects verification and signing inputs before DOM +parsing; the same immutable ceiling is rechecked after signing mutations that enlarge the XML. Configured chain depth and candidate-path limits are validated after resolver defaults compose with the operation policy. Candidate-path accounting includes every generated partial path, and self-issued rollover certificates continue toward a distinct same-name issuer when its signature diff --git a/docs/xmlenc.md b/docs/xmlenc.md index ff438c97..19ee8b6b 100644 --- a/docs/xmlenc.md +++ b/docs/xmlenc.md @@ -113,7 +113,8 @@ AES-GCM. Use `decrypt_document` to replace one typed `EncryptedData` in a complete XML string. Pass its `Id` when the document contains multiple encrypted regions. The compiled decryption policy checks -the caller-owned document byte ceiling before DOM allocation and applies its XML node ceiling to +the shared `ResourcePolicy::max_xml_document_bytes` ceiling before DOM allocation and applies its +XML node ceiling to the initial document, replacement-boundary validation, and final output reparse. The projected output byte length is checked before constructing the replacement. DTD parsing remains disabled by default; legacy documents that need an internal DTD can opt in through diff --git a/src/c14n/xml_base.rs b/src/c14n/xml_base.rs index 96da03fd..304731c7 100644 --- a/src/c14n/xml_base.rs +++ b/src/c14n/xml_base.rs @@ -160,6 +160,23 @@ pub(crate) fn resolve_uri_with_budget( Ok(resolved) } +/// Resolve a reference against inherited XML Base without traversing ancestors +/// when the reference already supplies an RFC 3986 scheme. +#[cfg(feature = "xmldsig")] +pub(crate) fn resolve_uri_from_node_with_budget( + origin: Node<'_, '_>, + reference: &str, + budget: &XmlBaseResolutionBudget, +) -> Result { + if has_scheme(reference) { + return resolve_uri_with_budget("", reference, budget); + } + match compute_effective_xml_base_with_budget(origin, None, budget)? { + Some(base) => resolve_uri_with_budget(&base, reference, budget), + None => resolve_uri_with_budget("", reference, budget), + } +} + /// Whether a selected element establishes its source `xml:base` context in the /// canonical output and therefore forms a boundary for descendant fixup. pub(super) fn preserves_xml_base_context( diff --git a/src/hard_limits.rs b/src/hard_limits.rs index 0378c833..12251176 100644 --- a/src/hard_limits.rs +++ b/src/hard_limits.rs @@ -27,8 +27,7 @@ pub(crate) const ENCRYPTION_CIPHER_VALUE_BASE64_BYTE_CEILING: usize = 16 * 1024 pub(crate) const ENCRYPTION_PLAINTEXT_BYTE_CEILING: usize = (ENCRYPTION_CIPHER_VALUE_BASE64_BYTE_CEILING / 4 * 3) - 32; #[cfg(any(feature = "xmldsig", feature = "xmlenc"))] -pub(crate) const ENCRYPTION_DOCUMENT_BYTE_CEILING: usize = - ENCRYPTION_CIPHER_VALUE_BASE64_BYTE_CEILING; +pub(crate) const XML_DOCUMENT_BYTE_CEILING: usize = ENCRYPTION_CIPHER_VALUE_BASE64_BYTE_CEILING; #[cfg(any(feature = "xmldsig", feature = "xmlenc"))] pub(crate) const ENCRYPTION_RECIPIENT_CEILING: usize = 64; #[cfg(any(feature = "xmldsig", feature = "xmlenc"))] diff --git a/src/policy.rs b/src/policy.rs index 979b244a..de0bde8d 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -201,8 +201,8 @@ pub struct ResourcePolicy { 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 caller-owned XML bytes accepted by any document operation. + pub max_xml_document_bytes: usize, /// Maximum independently wrapped recipients. pub max_encryption_recipients: usize, /// Maximum caller-controlled XMLEnc metadata bytes per field. @@ -222,7 +222,7 @@ impl Default for ResourcePolicy { 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_xml_document_bytes: crate::hard_limits::XML_DOCUMENT_BYTE_CEILING, max_encryption_recipients: crate::hard_limits::ENCRYPTION_RECIPIENT_CEILING, max_encryption_metadata_bytes: crate::hard_limits::ENCRYPTION_METADATA_BYTE_CEILING, } @@ -259,9 +259,9 @@ impl ResourcePolicy { crate::hard_limits::XML_BASE_RESOLUTION_BYTE_CEILING, )?; Self::within( - "encryption document", - self.max_encryption_document_bytes, - crate::hard_limits::ENCRYPTION_DOCUMENT_BYTE_CEILING, + "XML document", + self.max_xml_document_bytes, + crate::hard_limits::XML_DOCUMENT_BYTE_CEILING, )?; Self::within( "external resource bytes", @@ -290,6 +290,17 @@ impl ResourcePolicy { ) } + pub(crate) fn validate_xml_document_len(&self, actual: usize) -> Result<(), PolicyViolation> { + if actual > self.max_xml_document_bytes { + return Err(PolicyViolation::ResourceLimit { + resource: "XML document", + maximum: self.max_xml_document_bytes, + actual, + }); + } + Ok(()) + } + fn within( resource: &'static str, selected: usize, @@ -579,6 +590,9 @@ mod tests { let mut plaintext = ResourcePolicy::default(); plaintext.max_encryption_plaintext_bytes += 1; policies.push(plaintext); + let mut document = ResourcePolicy::default(); + document.max_xml_document_bytes += 1; + policies.push(document); let mut recipients = ResourcePolicy::default(); recipients.max_encryption_recipients += 1; policies.push(recipients); @@ -608,7 +622,7 @@ mod tests { max_external_resource_bytes: 0, max_external_resource_total_bytes: 0, max_encryption_plaintext_bytes: 0, - max_encryption_document_bytes: 0, + max_xml_document_bytes: 0, max_encryption_recipients: 0, max_encryption_metadata_bytes: 0, }; diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index f32ec130..ddbfc5ad 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -38,9 +38,7 @@ use super::whitespace::{ }; use super::x509::certificate_signature_matches_with_provider; use crate::c14n::C14nAlgorithm; -use crate::c14n::xml_base::{ - XmlBaseResolutionBudget, compute_effective_xml_base_with_budget, resolve_uri_with_budget, -}; +use crate::c14n::xml_base::{XmlBaseResolutionBudget, resolve_uri_from_node_with_budget}; /// XMLDSig namespace URI. pub(crate) const XMLDSIG_NS: &str = "http://www.w3.org/2000/09/xmldsig#"; @@ -674,14 +672,8 @@ pub(crate) fn parse_key_info_with_provider_and_xml_base_budget( } else { // RetrievalMethod is parsed independently from later key // materialization, so retain its resolved resource identity. - match compute_effective_xml_base_with_budget(child, None, xml_base_budget) + resolve_uri_from_node_with_budget(child, lexical_uri, xml_base_budget) .map_err(|error| ParseError::InvalidStructure(error.to_string()))? - { - Some(base) => resolve_uri_with_budget(&base, lexical_uri, xml_base_budget) - .map_err(|error| ParseError::InvalidStructure(error.to_string()))?, - None => resolve_uri_with_budget("", lexical_uri, xml_base_budget) - .map_err(|error| ParseError::InvalidStructure(error.to_string()))?, - } }; let resource_type = child.attribute("Type"); if resource_type.is_some_and(|value| value.len() > MAX_KEY_NAME_TEXT_LEN) { @@ -3811,6 +3803,31 @@ BA== )); } + #[test] + fn parse_key_info_absolute_retrieval_bypasses_xml_base_chain() { + // Absolute RetrievalMethod identities do not inherit xml:base, so an + // otherwise excessive ancestor chain must not reject them. + let mut xml = format!( + r#""# + ); + for _ in 0..65 { + xml = format!(r#"{xml}"#); + } + let document = Document::parse(&xml).unwrap(); + let key_info_node = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) + .unwrap(); + let key_info = parse_key_info(key_info_node) + .expect("absolute RetrievalMethod must not consume ancestor-base budget"); + + assert!(matches!( + key_info.sources.as_slice(), + [KeyInfoSource::RetrievalMethod { uri, .. }] + if uri == "https://example.test/key.der" + )); + } + #[test] fn parse_key_info_accepts_namespace_equivalent_retrieval_xpath_prefix() { let xml = r##" diff --git a/src/xmldsig/sign.rs b/src/xmldsig/sign.rs index 5f66b7e7..dcd2a92c 100644 --- a/src/xmldsig/sign.rs +++ b/src/xmldsig/sign.rs @@ -685,6 +685,7 @@ impl<'a> SignContext<'a> { /// base64 ``. pub fn sign_template(&self, xml: &str) -> Result { self.policy.validate()?; + self.policy.resources.validate_xml_document_len(xml.len())?; let execution_budget = TransformExecutionBudget::from_resources(&self.policy.resources); let transform_options = TransformOptions::default() .allow_internal_dtd(self.policy.xml.allow_internal_dtd) @@ -696,6 +697,9 @@ impl<'a> SignContext<'a> { self.provider, &execution_budget, )?; + self.policy + .resources + .validate_xml_document_len(with_digests.len())?; let (algorithm, canonical_signed_info) = canonicalize_signed_info(&with_digests, &self.policy, &execution_budget)?; execution_budget @@ -723,13 +727,17 @@ impl<'a> SignContext<'a> { let signature_b64 = base64::engine::general_purpose::STANDARD.encode(signature_value); let signed = fill_signature_value_with_options(&with_digests, &signature_b64, Some(&self.policy))?; + self.policy + .resources + .validate_xml_document_len(signed.len())?; if let Some(writer) = self.key_info_writer { let key_info_content = writer.write_key_info(self.signing_key)?; - Ok(fill_key_info_with_options( - &signed, - &key_info_content, - Some(&self.policy), - )?) + let signed = + fill_key_info_with_options(&signed, &key_info_content, Some(&self.policy))?; + self.policy + .resources + .validate_xml_document_len(signed.len())?; + Ok(signed) } else { Ok(signed) } @@ -742,6 +750,7 @@ impl<'a> SignContext<'a> { builder: &SignatureBuilder, ) -> Result { self.policy.validate()?; + self.policy.resources.validate_xml_document_len(xml.len())?; let template = builder.build_template()?; let templated = append_signature_to_root_with_options(xml, &template, Some(&self.policy))?; self.sign_template(&templated) diff --git a/src/xmldsig/uri.rs b/src/xmldsig/uri.rs index c79aedad..f92f327e 100644 --- a/src/xmldsig/uri.rs +++ b/src/xmldsig/uri.rs @@ -19,8 +19,7 @@ use std::collections::{HashMap, HashSet}; use roxmltree::{Document, Node, NodeId}; use crate::c14n::xml_base::{ - XmlBaseResolutionBudget, XmlBaseResolutionError, compute_effective_xml_base_with_budget, - resolve_uri_with_budget, + XmlBaseResolutionBudget, XmlBaseResolutionError, resolve_uri_from_node_with_budget, }; use super::types::{NodeSet, NodeSetMaterializationBudget, TransformData, TransformError}; @@ -247,14 +246,8 @@ impl<'a> UriReferenceResolver<'a> { if uri.is_empty() || uri.starts_with('#') { return self.dereference_with_budget(uri, budget); } - let resolved = match compute_effective_xml_base_with_budget(origin, None, xml_base_budget) - .map_err(map_xml_base_resolution_error)? - { - Some(base) => resolve_uri_with_budget(&base, uri, xml_base_budget) - .map_err(map_xml_base_resolution_error)?, - None => resolve_uri_with_budget("", uri, xml_base_budget) - .map_err(map_xml_base_resolution_error)?, - }; + let resolved = resolve_uri_from_node_with_budget(origin, uri, xml_base_budget) + .map_err(map_xml_base_resolution_error)?; self.dereference_with_budget(&resolved, budget) } @@ -674,6 +667,34 @@ mod tests { assert_eq!(data.into_binary().unwrap(), b"payload"); } + #[test] + fn absolute_external_uri_does_not_consume_xml_base_components() { + // A scheme-bearing reference supplies its own base and must remain + // resolvable even when inherited XML Base components are disallowed. + 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 resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources); + + let data = resolver + .dereference_from_with_budget( + reference.attribute("URI").unwrap(), + reference, + &NodeSetMaterializationBudget::default(), + &XmlBaseResolutionBudget::with_limits(0, 1_024), + ) + .expect("absolute references must bypass inherited XML Base traversal"); + + assert_eq!(data.into_binary().unwrap(), b"payload"); + } + #[test] fn external_uri_without_xml_base_uses_normalized_resource_identity() { // RFC 3986 normalization defines the caller map key even when the diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index 658d59ca..91c94088 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -953,6 +953,7 @@ fn verify_signature_with_context( ctx: &VerifyContext<'_>, ) -> Result { ctx.policy.validate()?; + ctx.policy.resources.validate_xml_document_len(xml.len())?; let doc = Document::parse_with_options( xml, roxmltree::ParsingOptions { @@ -2230,6 +2231,31 @@ mod tests { ); } + #[test] + fn verification_policy_bounds_document_bytes_before_parsing() { + // A small node count does not bound parser work when one text node is + // large, so the byte ceiling must reject before structural inspection. + let xml = format!("{}", "x".repeat(1_024)); + let policy = crate::policy::VerificationPolicy { + resources: crate::policy::ResourcePolicy { + max_xml_document_bytes: xml.len() - 1, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::VerificationPolicy::default() + }; + + assert!(matches!( + VerifyContext::new().policy(policy).verify(&xml), + Err(SignatureVerificationPipelineError::Policy( + crate::policy::PolicyViolation::ResourceLimit { + resource: "XML document", + maximum, + actual, + } + )) if maximum == xml.len() - 1 && actual == xml.len() + )); + } + #[test] fn verification_policy_shares_canonicalization_budget_with_signed_info() { // Reference transforms and SignedInfo canonicalization are one operation. @@ -4532,18 +4558,27 @@ mod tests { } #[test] - fn canonical_signed_info_is_bounded_without_diagnostic_retention() { + fn canonical_signed_info_obeys_policy_without_diagnostic_retention() { // SignedInfo is always materialized for crypto verification, so its - // canonical bytes must consume the ceiling even under default options. + // canonical bytes must consume the configured ceiling even when + // diagnostics do not retain reference output. let xml = signature_with_target_reference("AQ=="); let marker = " DecryptContext<'a> { encrypted, algorithm, self.policy.resources.max_encryption_plaintext_bytes, - self.policy.resources.max_encryption_document_bytes, + self.policy.resources.max_xml_document_bytes, )?; let ciphertext = STANDARD .decode(&encrypted.cipher_data.value) @@ -505,7 +505,10 @@ fn validate_plaintext_fragment( wrapped.push_str(WRAPPER_END); wrapped.push_str(&xml[replacement_end..]); - let document = Document::parse_with_options(&wrapped, decryption_parsing_options(policy))?; + let document = Document::parse_with_options( + &wrapped, + decryption_parsing_options_with_internal_nodes(policy, 1), + )?; let wrapper = document .descendants() .find(|node| { @@ -532,11 +535,23 @@ fn validate_plaintext_fragment( Ok(()) } -fn decryption_parsing_options<'a>(policy: &crate::policy::DecryptionPolicy) -> ParsingOptions<'a> { +fn decryption_parsing_options<'input>( + policy: &crate::policy::DecryptionPolicy, +) -> ParsingOptions<'input> { + decryption_parsing_options_with_internal_nodes(policy, 0) +} + +fn decryption_parsing_options_with_internal_nodes<'input>( + policy: &crate::policy::DecryptionPolicy, + internal_nodes: u32, +) -> ParsingOptions<'input> { ParsingOptions { allow_dtd: policy.xml.allow_internal_dtd, + // The temporary wrapper proves fragment boundaries but is not part of + // either caller-owned input or the final decrypted document. nodes_limit: u32::try_from(policy.resources.max_xml_nodes) - .unwrap_or(crate::hard_limits::XML_DOCUMENT_NODE_CEILING), + .unwrap_or(crate::hard_limits::XML_DOCUMENT_NODE_CEILING) + .saturating_add(internal_nodes), entity_resolver: None, } } @@ -545,14 +560,7 @@ fn validate_encryption_document_len( actual: usize, policy: &crate::policy::DecryptionPolicy, ) -> Result<(), XmlEncError> { - if actual > policy.resources.max_encryption_document_bytes { - return Err(crate::policy::PolicyViolation::ResourceLimit { - resource: "encryption document", - maximum: policy.resources.max_encryption_document_bytes, - actual, - } - .into()); - } + policy.resources.validate_xml_document_len(actual)?; Ok(()) } @@ -1975,7 +1983,7 @@ mod tests { let policy = crate::policy::DecryptionPolicy { resources: crate::policy::ResourcePolicy { max_encryption_plaintext_bytes: 4, - max_encryption_document_bytes: aggregate_encoded_len - 1, + max_xml_document_bytes: aggregate_encoded_len - 1, ..crate::policy::ResourcePolicy::default() }, ..crate::policy::DecryptionPolicy::default() @@ -2450,7 +2458,7 @@ mod tests { let document = format!("{encrypted}
"); let byte_policy = crate::policy::DecryptionPolicy { resources: crate::policy::ResourcePolicy { - max_encryption_document_bytes: document.len() - 1, + max_xml_document_bytes: document.len() - 1, ..crate::policy::ResourcePolicy::default() }, ..crate::policy::DecryptionPolicy::default() @@ -2460,7 +2468,7 @@ mod tests { .policy(byte_policy) .decrypt_document(&document, None), Err(XmlEncError::Policy(crate::policy::PolicyViolation::ResourceLimit { - resource: "encryption document", + resource: "XML document", maximum, actual, })) if maximum == document.len() - 1 && actual == document.len() @@ -2481,6 +2489,44 @@ mod tests { )); } + #[test] + fn fragment_validation_does_not_charge_its_internal_wrapper_node() { + // The caller's node ceiling applies to input and output XML, not the + // implementation-only element used to prove replacement boundaries. + let key = [0x39_u8; 16]; + let plaintext = "".repeat(20); + let encrypted = encrypted_gcm_element( + "http://www.w3.org/2001/04/xmlenc#Content", + &plaintext, + None, + false, + &key, + ); + let document = format!("{encrypted}"); + let resolver = SymmetricKeyDecryptor::new(key); + let expected = decrypt_document(&document, None, &resolver) + .expect("unbounded setup decryption must succeed"); + let exact_output_nodes = Document::parse(&expected) + .expect("decrypted output must parse") + .descendants() + .count(); + let policy = crate::policy::DecryptionPolicy { + resources: crate::policy::ResourcePolicy { + max_xml_nodes: exact_output_nodes, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::DecryptionPolicy::default() + }; + + assert_eq!( + DecryptContext::new(&resolver) + .policy(policy) + .decrypt_document(&document, None) + .expect("temporary wrapper must not consume caller node budget"), + expected + ); + } + #[test] fn validates_replacement_plaintext_in_its_namespace_context() { // Decrypted fragments inherit namespaces from the encrypted node's diff --git a/src/xmlenc/encrypt.rs b/src/xmlenc/encrypt.rs index acc86272..23aa06df 100644 --- a/src/xmlenc/encrypt.rs +++ b/src/xmlenc/encrypt.rs @@ -167,7 +167,7 @@ impl EncryptedDataBuilder { xml.len(), range.len(), result.encrypted_data_xml.len(), - self.policy.resources.max_encryption_document_bytes, + self.policy.resources.max_xml_document_bytes, )?; validate_replacement_document_nodes( &document, @@ -204,7 +204,7 @@ impl EncryptedDataBuilder { xml.len(), removed, inserted, - self.policy.resources.max_encryption_document_bytes, + self.policy.resources.max_xml_document_bytes, )?; validate_replacement_document_nodes( &document, @@ -430,7 +430,7 @@ impl EncryptedDataBuilder { } fn validate_document_len(&self, actual: usize) -> Result<(), XmlEncError> { - validate_document_len(actual, self.policy.resources.max_encryption_document_bytes) + validate_document_len(actual, self.policy.resources.max_xml_document_bytes) } } @@ -954,10 +954,10 @@ mod tests { 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, + XML_DOCUMENT_BYTE_CEILING as MAX_ENCRYPTION_DOCUMENT_LEN, }; use crate::xmlenc::{ KekDecryptor, OaepDigestAlgorithm, PrivateKeyDecryptor, SymmetricKeyDecryptor, decrypt, @@ -1376,7 +1376,7 @@ mod tests { let document = "x"; let policy = crate::policy::EncryptionPolicy { resources: crate::policy::ResourcePolicy { - max_encryption_document_bytes: document.len(), + max_xml_document_bytes: document.len(), ..crate::policy::ResourcePolicy::default() }, ..crate::policy::EncryptionPolicy::default() @@ -1610,7 +1610,7 @@ mod tests { // plaintext bounds alone do not account for framing, base64, or markup. let policy = |maximum| crate::policy::EncryptionPolicy { resources: crate::policy::ResourcePolicy { - max_encryption_document_bytes: maximum, + max_xml_document_bytes: maximum, ..crate::policy::ResourcePolicy::default() }, ..crate::policy::EncryptionPolicy::default() diff --git a/src/xmlenc/parse.rs b/src/xmlenc/parse.rs index 3fc69a17..b7ba2dd8 100644 --- a/src/xmlenc/parse.rs +++ b/src/xmlenc/parse.rs @@ -23,14 +23,7 @@ pub(super) fn parse_encrypted_data_with_policy( policy: &crate::policy::DecryptionPolicy, ) -> Result { policy.resources.validate()?; - if xml.len() > policy.resources.max_encryption_document_bytes { - return Err(crate::policy::PolicyViolation::ResourceLimit { - resource: "encryption document", - maximum: policy.resources.max_encryption_document_bytes, - actual: xml.len(), - } - .into()); - } + policy.resources.validate_xml_document_len(xml.len())?; let document = Document::parse_with_options( xml, ParsingOptions { diff --git a/tests/signing_digest.rs b/tests/signing_digest.rs index ff5811c1..525ff4d0 100644 --- a/tests/signing_digest.rs +++ b/tests/signing_digest.rs @@ -442,6 +442,72 @@ fn signing_policy_rejects_disallowed_reference_transform() { )); } +#[test] +fn signing_policy_bounds_document_bytes_before_parsing() { + // Signing must reject a large low-node document at the same policy + // boundary as verification rather than parsing it before work limits run. + 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 xml = format!("{}", "x".repeat(1_024)); + let policy = SigningPolicy { + resources: xml_sec::policy::ResourcePolicy { + max_xml_document_bytes: xml.len() - 1, + ..xml_sec::policy::ResourcePolicy::default() + }, + ..SigningPolicy::default() + }; + + assert!(matches!( + SignContext::new(&private_key) + .policy(policy) + .sign_template(&xml), + Err(SigningError::Policy( + xml_sec::policy::PolicyViolation::ResourceLimit { + resource: "XML document", + maximum, + actual, + } + )) if maximum == xml.len() - 1 && actual == xml.len() + )); +} + +#[test] +fn signing_policy_rechecks_document_bytes_after_mutation() { + // Filling DigestValue and SignatureValue grows the caller's document, so + // the same byte ceiling must cover intermediate and returned XML. + 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("", &template) + .expect("append signature"); + let policy = SigningPolicy { + resources: xml_sec::policy::ResourcePolicy { + max_xml_document_bytes: xml.len(), + ..xml_sec::policy::ResourcePolicy::default() + }, + ..SigningPolicy::default() + }; + + assert!(matches!( + SignContext::new(&private_key) + .policy(policy) + .sign_template(&xml), + Err(SigningError::Policy( + xml_sec::policy::PolicyViolation::ResourceLimit { + resource: "XML document", + maximum, + actual, + } + )) if maximum == xml.len() && actual > maximum + )); +} + #[test] fn signing_policy_shares_canonicalization_budget_with_signed_info() { // Reference transforms and SignedInfo consume one operation-wide C14N From f696d6669bf4b1590378399b77aa1f1c9766ac48 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 12 Aug 2026 18:29:00 +0300 Subject: [PATCH 62/63] fix(xmldsig): validate crypto boundaries - reject malformed provider digests before ECDSA prehash signing - align X.509 decimal selectors with sign-padded serials - validate every revoked-certificate serial in CRLs --- src/provider.rs | 66 +++++++++++++++++++++++++++++++++++++++++++- src/xmldsig/parse.rs | 57 ++++++++++++++++++++++++++++---------- src/xmldsig/sign.rs | 6 ++-- src/xmldsig/x509.rs | 48 ++++++++++++++++++++++++++++---- 4 files changed, 154 insertions(+), 23 deletions(-) diff --git a/src/provider.rs b/src/provider.rs index bc6ac2e8..0b71af36 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -1288,6 +1288,7 @@ mod tests { struct CountingRandomProvider { random_calls: AtomicUsize, reject_digest: Option, + extra_digest_byte: bool, } #[cfg(feature = "xmldsig")] @@ -1316,7 +1317,11 @@ mod tests { algorithm: Some(algorithm.uri().to_owned()), }); } - RUST_CRYPTO_PROVIDER.digest(algorithm, data) + let mut digest = RUST_CRYPTO_PROVIDER.digest(algorithm, data)?; + if self.extra_digest_byte { + digest.push(0); + } + Ok(digest) } fn sign( @@ -1472,6 +1477,7 @@ mod tests { let provider = CountingRandomProvider { random_calls: AtomicUsize::new(0), reject_digest: None, + extra_digest_byte: false, }; let signature = provider @@ -1523,6 +1529,7 @@ mod tests { let provider = CountingRandomProvider { random_calls: AtomicUsize::new(0), reject_digest: Some(digest_algorithm), + extra_digest_byte: false, }; let error = provider .sign(key.as_ref(), signature_algorithm, b"signed info") @@ -1538,6 +1545,63 @@ mod tests { } } + #[cfg(feature = "xmldsig")] + #[test] + fn ecdsa_signing_rejects_provider_digests_with_the_wrong_length() { + use crate::xmldsig::{ + EcdsaP256SigningKey, EcdsaP384SigningKey, SignatureAlgorithm, SigningKeyError, + }; + + // Prehash signers may truncate oversized input, so the provider + // boundary must reject it before either curve receives the digest. + let cases: [( + Box, + SignatureAlgorithm, + usize, + ); 2] = [ + ( + Box::new( + EcdsaP256SigningKey::from_pkcs8_pem(include_str!( + "../tests/fixtures/keys/ec/ec-prime256v1-key.pem" + )) + .expect("P-256 fixture must parse"), + ), + SignatureAlgorithm::EcdsaSha256, + 32, + ), + ( + Box::new( + EcdsaP384SigningKey::from_pkcs8_pem(include_str!( + "../tests/fixtures/keys/ec/ec-prime384v1-key.pem" + )) + .expect("P-384 fixture must parse"), + ), + SignatureAlgorithm::EcdsaSha384, + 48, + ), + ]; + + for (key, algorithm, expected) in cases { + let provider = CountingRandomProvider { + random_calls: AtomicUsize::new(0), + reject_digest: None, + extra_digest_byte: true, + }; + let error = provider + .sign(key.as_ref(), algorithm, b"signed info") + .expect_err("an oversized provider digest must not reach ECDSA prehash signing"); + + assert!(matches!( + error, + SigningKeyError::Provider(ProviderError::InvalidOutputSize { + operation: ProviderOperation::Digest, + expected: actual_expected, + actual, + }) if actual_expected == expected && actual == expected + 1 + )); + } + } + #[cfg(feature = "xmldsig")] #[test] fn rustcrypto_provider_verifies_parameterized_rsa_pss_certificates() { diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index ddbfc5ad..ec95e647 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -63,9 +63,9 @@ pub(crate) const MAX_X509_DECODED_BINARY_LEN: usize = 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 significant -// decimal digits; XML Schema permits insignificant leading zeroes. +// RFC 5280 requires consumers to handle a 20-octet unsigned serial magnitude. +// DER may add a leading sign-padding octet; XML Schema permits insignificant +// leading decimal 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; @@ -2021,11 +2021,6 @@ fn x509_serial_decimal_to_hex(serial: &str) -> Option { } } - // 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; - } if bytes.iter().all(|byte| *byte == 0) { return None; } @@ -3285,12 +3280,16 @@ BA== #[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"; + // A 20-octet unsigned magnitude may need a 21st DER sign-padding + // octet. The decimal selector denotes the value, not its DER encoding. + let max_serial = "1461501637330902918203684832716283019655932542975"; assert_eq!( x509_serial_decimal_to_hex(max_serial), - Some("7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF".into()) + Some("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF".into()) + ); + assert_eq!( + x509_serial_decimal_to_hex("730750818665451459101842416358141509827966271488"), + Some("8000000000000000000000000000000000000000".into()) ); assert_eq!( x509_serial_decimal_to_hex("0000000000000000000000000000000000000000000000001"), @@ -3310,7 +3309,6 @@ BA== "++1", "-1", "1a", - "730750818665451459101842416358141509827966271488", "1461501637330902918203684832716283019655932542976", ] { assert_eq!( @@ -3325,7 +3323,7 @@ BA== 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 max_serial = "1461501637330902918203684832716283019655932542975"; let valid = format!( "CN=issuer\n {max_serial}\t" ); @@ -3346,7 +3344,7 @@ BA== let overflow = valid.replace( max_serial, - "730750818665451459101842416358141509827966271488", + "1461501637330902918203684832716283019655932542976", ); let doc = Document::parse(&overflow).unwrap(); assert!(matches!( @@ -3356,6 +3354,35 @@ BA== )); } + #[test] + fn issuer_selector_matches_a_sign_padded_twenty_octet_serial() { + // Selector decimal text represents the unsigned magnitude; the DER + // sign-padding octet must not make the same certificate unmatchable. + let serial_hex = "8000000000000000000000000000000000000000"; + let info = X509DataInfo { + issuer_serials: vec![( + "CN=issuer".into(), + "730750818665451459101842416358141509827966271488".into(), + )], + parsed_certificates: vec![ParsedX509Certificate { + subject_dn: "CN=leaf".into(), + issuer_dn: "CN=issuer".into(), + serial_number: [vec![0, 0x80], vec![0; 19]].concat(), + serial_number_hex: serial_hex.into(), + subject_key_identifier: None, + public_key: X509PublicKeyInfo::Unsupported { + algorithm_oid: "1.2.3.4".into(), + }, + }], + ..X509DataInfo::default() + }; + + assert!( + x509_selector_categories_match_chain(&info, crate::provider::default_provider()) + .unwrap() + ); + } + #[test] fn distinguished_name_matching_preserves_rdn_order() { // RFC 4514 permits alternate encodings within an RDN, but reversing diff --git a/src/xmldsig/sign.rs b/src/xmldsig/sign.rs index dcd2a92c..c7608108 100644 --- a/src/xmldsig/sign.rs +++ b/src/xmldsig/sign.rs @@ -534,7 +534,8 @@ impl SigningKey for EcdsaP256SigningKey { }); } }; - let prehash = provider.digest(digest_algorithm, canonical_signed_info)?; + let prehash = + super::compute_digest_with_provider(provider, digest_algorithm, canonical_signed_info)?; let signature: P256Signature = self .key .sign_prehash(&prehash) @@ -604,7 +605,8 @@ impl SigningKey for EcdsaP384SigningKey { }); } }; - let prehash = provider.digest(digest_algorithm, canonical_signed_info)?; + let prehash = + super::compute_digest_with_provider(provider, digest_algorithm, canonical_signed_info)?; let signature: P384Signature = self .key .sign_prehash(&prehash) diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index 4cc32f08..a94e15de 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -272,10 +272,10 @@ fn validate_path( } fn validate_certificate_serial(cert: &X509Certificate<'_>) -> Result<(), X509ChainError> { - validate_certificate_serial_bytes(cert.raw_serial()) + validate_positive_serial_bytes(cert.raw_serial(), "certificate serial number") } -fn validate_certificate_serial_bytes(serial: &[u8]) -> Result<(), X509ChainError> { +fn validate_positive_serial_bytes(serial: &[u8], kind: &'static str) -> Result<(), X509ChainError> { let magnitude = serial.strip_prefix(&[0]).unwrap_or(serial); if serial.is_empty() || serial[0] & 0x80 != 0 @@ -284,7 +284,7 @@ fn validate_certificate_serial_bytes(serial: &[u8]) -> Result<(), X509ChainError || magnitude.iter().all(|byte| *byte == 0) { return Err(X509ChainError::InvalidDer { - kind: "certificate serial number", + kind, message: "RFC 5280 requires a positive, non-zero value of at most 20 octets".into(), }); } @@ -1536,6 +1536,8 @@ fn validate_crl_extension_uniqueness( .extensions_map() .map_err(|_| X509ChainError::InvalidCrl(crl_index))?; for revoked in crl.iter_revoked_certificates() { + validate_positive_serial_bytes(revoked.raw_serial(), "CRL revoked certificate serial") + .map_err(|_| X509ChainError::InvalidCrl(crl_index))?; revoked .extensions_map() .map_err(|_| X509ChainError::InvalidCrl(crl_index))?; @@ -2857,6 +2859,42 @@ mod tests { ); } + #[test] + fn malformed_revoked_certificate_serials_fail_closed() { + // Mutate the signed Merlin CRL fixture without changing DER lengths so + // zero and negative serials exercise the actual CRL parser path. + let original = merlin_crl_der(); + let serial = parsed_merlin_crl(&original) + .iter_revoked_certificates() + .next() + .expect("tracked Merlin CRL must contain a revoked entry") + .raw_serial() + .to_vec(); + let offsets = original + .windows(serial.len()) + .enumerate() + .filter_map(|(offset, bytes)| (bytes == serial).then_some(offset)) + .collect::>(); + assert_eq!( + offsets.len(), + 1, + "revoked serial fixture must be unambiguous" + ); + + for replacement in [vec![0; serial.len()], { + let mut negative = serial.clone(); + negative[0] = 0x80; + negative + }] { + let mut malformed = original.clone(); + malformed[offsets[0]..offsets[0] + serial.len()].copy_from_slice(&replacement); + assert_eq!( + validate_crl_extensions(&parsed_merlin_crl(&malformed), 0), + Err(X509ChainError::InvalidCrl(0)) + ); + } + } + #[test] fn delta_crl_indicator_is_rejected_regardless_of_criticality() { use der::{Decode as _, Encode as _, asn1::OctetString}; @@ -3051,8 +3089,8 @@ mod tests { )); } - assert!(validate_certificate_serial_bytes(&[0x80]).is_err()); - assert!(validate_certificate_serial_bytes(&[1; 20]).is_ok()); + assert!(validate_positive_serial_bytes(&[0x80], "certificate serial number").is_err()); + assert!(validate_positive_serial_bytes(&[1; 20], "certificate serial number").is_ok()); let root = rcgen::CertifiedIssuer::self_signed( generated_certificate_params("serial-padding root", true), From 72e9a144df8c87035d3baba92e45e908e2873a44 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 12 Aug 2026 19:26:55 +0300 Subject: [PATCH 63/63] fix(validation): enforce facade boundaries - validate XMLDSig signature framing before provider dispatch - exclude temporary fragment wrappers from caller node limits - clarify OAEP and DTD policy documentation --- docs/xmldsig.md | 5 ++ docs/xmlenc.md | 19 ++++---- src/provider.rs | 47 ++++++++++++++++++ src/xmldsig/keys.rs | 25 +++++++++- src/xmldsig/signature.rs | 100 ++++++++++++++++++++++++++++++++++++++- src/xmldsig/verify.rs | 28 +++++++++++ src/xmlenc/encrypt.rs | 24 +++++++++- 7 files changed, 237 insertions(+), 11 deletions(-) diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 68f48914..b8f6d1b2 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -55,6 +55,11 @@ capability such as ECDSA-SHA512 while a custom provider can implement it. For an hash, MGF, minimum salt, and trailer-field restrictions before verifying a certificate signature. Custom resolvers that evaluate cryptographic key metadata should override `KeyResolver::resolve_with_policy_and_provider`; source-only resolvers can retain the default hook. +Before document-signature provider dispatch, the facade calls +`VerifyingKey::validate_signature_value`. Built-in resolved keys enforce the exact RSA modulus or +EC curve width there, so a permissive custom provider cannot reinterpret malformed XMLDSig wire +framing. Custom opaque keys must override that hook when their accepted framing depends on key +metadata unavailable through the generic algorithm URI. ## Verification Policy diff --git a/docs/xmlenc.md b/docs/xmlenc.md index 19ee8b6b..91cb8f99 100644 --- a/docs/xmlenc.md +++ b/docs/xmlenc.md @@ -38,14 +38,16 @@ public keys, or use `recipient_aes_kw` with a shared KEK. `EncryptedDataBuilder` content key through `CryptoProvider::fill_random` and wraps it once per recipient. The default `RustCryptoProvider` uses the operating-system RNG; `.provider(...)` can replace that behavior together with the cryptographic primitives. The crate's secure RSA-OAEP default is -SHA-256/MGF1-SHA-256. XMLEnc 1.1 itself defaults omitted parameters to SHA-1/MGF1-SHA-1, so -`xml-sec` always emits explicit `ds:DigestMethod` and `xenc11:MGF` values rather than relying on -those implicit legacy defaults. SHA-1 OAEP remains available only through explicit parameters. +SHA-256/MGF1-SHA-256. XMLEnc 1.1 itself defaults omitted parameters to SHA-1/MGF1-SHA-1, so for +the XML Encryption 1.1 RSA-OAEP URI `xml-sec` emits explicit `ds:DigestMethod` and `xenc11:MGF` +values rather than relying on those implicit legacy defaults. SHA-1 OAEP remains available only +through explicit parameters. The legacy `rsa-oaep-mgf1p` URI fixes MGF1 to SHA-1 and has no +`xenc11:MGF` wire field. `EncryptionPolicy::rsa_keys` validates every recipient modulus and exponent before provider dispatch. New output defaults to 2048-8192-bit RSA keys; callers can explicitly tighten or relax the minimum for a deployment profile, but cannot exceed the implementation ceiling. -The legacy `rsa-oaep-mgf1p` URI fixes MGF1 to SHA-1; configuration validation rejects any other -MGF digest before provider dispatch because that URI has no wire field capable of representing it. +Configuration validation rejects any non-SHA-1 MGF digest for the legacy URI before provider +dispatch because its wire format cannot represent an alternative. AES-KW configuration similarly validates the KEK size fixed by its algorithm URI before provider dispatch, so custom providers cannot reinterpret `kw-aes128` with a 256-bit KEK or `kw-aes256` with a 128-bit KEK. A custom provider's wrapped-key output must contain the complete RFC 3394 value, @@ -117,9 +119,10 @@ the shared `ResourcePolicy::max_xml_document_bytes` ceiling before DOM allocatio XML node ceiling to the initial document, replacement-boundary validation, and final output reparse. The projected output byte length is checked before constructing the replacement. DTD parsing remains disabled by -default; legacy documents that need an internal DTD can opt in through -`decrypt_document_with_options` and `DocumentDecryptionOptions`. That API never installs an -external entity resolver. +default; legacy documents that need an internal DTD can opt in only when both +`EncryptionPolicy::xml.allow_internal_dtd` and `DocumentDecryptionOptions::allow_dtd` are enabled. +The per-call option cannot weaken the operation policy, and the API never installs an external +entity resolver. `encrypt_document` also checks the exact projected document byte length and XML node count after cipher framing, base64, and `EncryptedData` serialization but before allocating the replacement diff --git a/src/provider.rs b/src/provider.rs index 0b71af36..864233d5 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -225,6 +225,9 @@ pub trait CryptoProvider: Send + Sync { ) -> Result, crate::xmldsig::SigningKeyError>; /// Verify bytes with an opaque key handle. + /// + /// The XMLDSig facade validates algorithm- and key-specific signature + /// framing before this provider boundary. #[cfg(feature = "xmldsig")] fn verify( &self, @@ -1289,6 +1292,7 @@ mod tests { random_calls: AtomicUsize, reject_digest: Option, extra_digest_byte: bool, + accept_signatures: bool, } #[cfg(feature = "xmldsig")] @@ -1340,6 +1344,9 @@ mod tests { data: &[u8], signature: &[u8], ) -> Result { + if self.accept_signatures { + return Ok(true); + } RUST_CRYPTO_PROVIDER.verify(key, algorithm, data, signature) } @@ -1478,6 +1485,7 @@ mod tests { random_calls: AtomicUsize::new(0), reject_digest: None, extra_digest_byte: false, + accept_signatures: false, }; let signature = provider @@ -1530,6 +1538,7 @@ mod tests { random_calls: AtomicUsize::new(0), reject_digest: Some(digest_algorithm), extra_digest_byte: false, + accept_signatures: false, }; let error = provider .sign(key.as_ref(), signature_algorithm, b"signed info") @@ -1586,6 +1595,7 @@ mod tests { random_calls: AtomicUsize::new(0), reject_digest: None, extra_digest_byte: true, + accept_signatures: false, }; let error = provider .sign(key.as_ref(), algorithm, b"signed info") @@ -1602,6 +1612,43 @@ mod tests { } } + #[cfg(feature = "xmldsig")] + #[test] + fn verification_facade_rejects_malformed_dsa_before_provider_dispatch() { + use crate::xmldsig::{DefaultKeyResolver, DsigStatus, FailureReason, VerifyContext}; + + let original = include_str!( + "../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-dsa.xml" + ); + let value_start = original + .find("") + .expect("Merlin fixture must contain SignatureValue") + + "".len(); + let value_end = original[value_start..] + .find("") + .map(|offset| value_start + offset) + .expect("Merlin fixture must close SignatureValue"); + let mut malformed = original.to_owned(); + malformed.replace_range(value_start..value_end, "AQ=="); + let provider = CountingRandomProvider { + random_calls: AtomicUsize::new(0), + reject_digest: None, + extra_digest_byte: false, + accept_signatures: true, + }; + + let result = VerifyContext::new() + .provider(&provider) + .key_resolver(&DefaultKeyResolver::default()) + .verify(&malformed) + .expect("malformed framing must be a verification miss"); + + assert_eq!( + result.status, + DsigStatus::Invalid(FailureReason::SignatureMismatch) + ); + } + #[cfg(feature = "xmldsig")] #[test] fn rustcrypto_provider_verifies_parameterized_rsa_pss_certificates() { diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index ffaa0d7f..47fb21d4 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -11,7 +11,7 @@ use x509_parser::{ x509::SubjectPublicKeyInfo, }; -use super::signature::verify_rsa_signature_spki_with_minimum; +use super::signature::{signature_value_matches_spki, verify_rsa_signature_spki_with_minimum}; use super::{ DsigError, KeyInfo, KeyInfoSource, KeyResolver, KeyValueInfo, SignatureAlgorithm, VerifyingKey, X509ChainOptions, X509DataInfo, @@ -69,6 +69,17 @@ impl HmacSha1VerificationKey { } impl VerifyingKey for HmacSha1VerificationKey { + fn validate_signature_value( + &self, + algorithm: SignatureAlgorithm, + signature_value: &[u8], + ) -> Result { + if algorithm != SignatureAlgorithm::HmacSha1 { + return Err(KeyResolutionError::AlgorithmMismatch.into()); + } + Ok(signature_value.len() == self.output_len) + } + fn verify( &self, algorithm: SignatureAlgorithm, @@ -103,6 +114,18 @@ pub struct VerificationKey { } impl VerifyingKey for VerificationKey { + fn validate_signature_value( + &self, + algorithm: SignatureAlgorithm, + signature_value: &[u8], + ) -> Result { + if algorithm != self.algorithm { + return Err(KeyResolutionError::AlgorithmMismatch.into()); + } + signature_value_matches_spki(algorithm, &self.public_key_bytes, signature_value) + .map_err(DsigError::Crypto) + } + fn verify( &self, algorithm: SignatureAlgorithm, diff --git a/src/xmldsig/signature.rs b/src/xmldsig/signature.rs index 885c250d..a8439416 100644 --- a/src/xmldsig/signature.rs +++ b/src/xmldsig/signature.rs @@ -13,9 +13,12 @@ use p256::ecdsa::{Signature as P256Signature, VerifyingKey as P256VerifyingKey}; use p384::ecdsa::{Signature as P384Signature, VerifyingKey as P384VerifyingKey}; use p521::ecdsa::{Signature as P521Signature, VerifyingKey as P521VerifyingKey}; -use rsa::pkcs1v15::{Signature as RsaPkcs1v15Signature, VerifyingKey as RsaVerifyingKey}; use rsa::pkcs8::DecodePublicKey; use rsa::signature::hazmat::PrehashVerifier; +use rsa::{ + pkcs1v15::{Signature as RsaPkcs1v15Signature, VerifyingKey as RsaVerifyingKey}, + traits::PublicKeyParts, +}; use sha1::Sha1; use sha2::{Digest, Sha256, Sha384, Sha512}; use signature::Verifier; @@ -25,6 +28,74 @@ use x509_parser::x509::SubjectPublicKeyInfo; use super::parse::SignatureAlgorithm; +pub(crate) fn signature_value_matches_algorithm( + algorithm: SignatureAlgorithm, + signature_value: &[u8], +) -> bool { + match algorithm { + SignatureAlgorithm::DsaSha1 => signature_value.len() == 40, + SignatureAlgorithm::HmacSha1 => (10..=20).contains(&signature_value.len()), + // Opaque custom keys expose no modulus here, so the default can enforce + // only non-empty framing under the absolute ceiling. Built-in keys + // override this with the exact modulus width. + SignatureAlgorithm::RsaSha1 + | SignatureAlgorithm::RsaSha256 + | SignatureAlgorithm::RsaSha384 + | SignatureAlgorithm::RsaSha512 => { + (1..=crate::hard_limits::RSA_MODULUS_BIT_CEILING / 8).contains(&signature_value.len()) + } + SignatureAlgorithm::EcdsaSha256 | SignatureAlgorithm::EcdsaSha384 => { + [32, 48, 66].into_iter().any(|component_len| { + classify_ecdsa_signature_encoding(signature_value, component_len).is_ok() + }) + } + } +} + +pub(crate) fn signature_value_matches_spki( + algorithm: SignatureAlgorithm, + public_key_spki_der: &[u8], + signature_value: &[u8], +) -> Result { + let (rest, spki) = SubjectPublicKeyInfo::from_der(public_key_spki_der) + .map_err(|_| SignatureVerificationError::InvalidKeyDer)?; + if !rest.is_empty() { + return Err(SignatureVerificationError::InvalidKeyDer); + } + let public_key = spki + .parsed() + .map_err(|_| SignatureVerificationError::InvalidKeyDer)?; + + match (algorithm, public_key) { + (SignatureAlgorithm::DsaSha1, PublicKey::DSA(_)) => Ok(signature_value.len() == 40), + ( + SignatureAlgorithm::RsaSha1 + | SignatureAlgorithm::RsaSha256 + | SignatureAlgorithm::RsaSha384 + | SignatureAlgorithm::RsaSha512, + PublicKey::RSA(_), + ) => { + let key = rsa::RsaPublicKey::from_public_key_der(public_key_spki_der) + .map_err(|_| SignatureVerificationError::InvalidKeyDer)?; + Ok(signature_value.len() == key.size()) + } + (SignatureAlgorithm::EcdsaSha256 | SignatureAlgorithm::EcdsaSha384, PublicKey::EC(ec)) => { + validate_ec_public_key_encoding(&ec, &spki.subject_public_key.data)?; + let (_, component_len) = ecdsa_curve_and_component_len(&spki, &ec)?; + classify_ecdsa_signature_encoding(signature_value, component_len)?; + Ok(true) + } + (SignatureAlgorithm::HmacSha1, _) => { + Err(SignatureVerificationError::KeyAlgorithmMismatch { + uri: algorithm.uri().to_owned(), + }) + } + _ => Err(SignatureVerificationError::KeyAlgorithmMismatch { + uri: algorithm.uri().to_owned(), + }), + } +} + /// Errors while preparing or running XMLDSig signature verification. #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -687,6 +758,33 @@ mod tests { )); } + #[test] + fn spki_signature_framing_uses_the_resolved_key_width() { + let rsa = parse_public_key_pem(include_str!( + "../../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem" + )) + .expect("RSA fixture must parse"); + assert!( + signature_value_matches_spki(SignatureAlgorithm::RsaSha256, &rsa, &[0; 256]).unwrap() + ); + assert!( + !signature_value_matches_spki(SignatureAlgorithm::RsaSha256, &rsa, &[0; 255]).unwrap() + ); + + let p256 = parse_public_key_pem(include_str!( + "../../tests/fixtures/keys/ec/ec-prime256v1-pubkey.pem" + )) + .expect("P-256 fixture must parse"); + assert!( + signature_value_matches_spki(SignatureAlgorithm::EcdsaSha256, &p256, &[0xAA; 64]) + .unwrap() + ); + assert!(matches!( + signature_value_matches_spki(SignatureAlgorithm::EcdsaSha256, &p256, &[0xAA; 96]), + Err(SignatureVerificationError::InvalidSignatureFormat) + )); + } + #[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 91c94088..2c2b6774 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -56,6 +56,22 @@ const MAX_RETRIEVAL_METHOD_COUNT: usize = 64; /// This trait intentionally has no `Send + Sync` supertraits so lightweight /// single-threaded verifiers can be used without additional bounds. pub trait VerifyingKey { + /// Check that `signature_value` has the wire framing required by the + /// declared algorithm and this key before provider dispatch. + /// + /// Key implementations with key-size-dependent framing should override + /// this method. The default enforces the algorithm-wide XMLDSig envelope. + fn validate_signature_value( + &self, + algorithm: SignatureAlgorithm, + signature_value: &[u8], + ) -> Result { + Ok(super::signature::signature_value_matches_algorithm( + algorithm, + signature_value, + )) + } + /// Verify `signature_value` over `signed_data` with the declared algorithm. fn verify( &self, @@ -1180,6 +1196,18 @@ fn verify_signature_with_context( }); }; let verifier = resolved_key.as_ref(); + if !verifier.validate_signature_value(signed_info.signature_method, &signature_value)? { + return Ok(VerifyResult { + status: DsigStatus::Invalid(FailureReason::SignatureMismatch), + signed_info_references: references.results, + manifest_references: Vec::new(), + canonicalized_signed_info: if ctx.store_pre_digest { + Some(canonical_signed_info) + } else { + None + }, + }); + } let signature_valid = ctx.provider.verify( verifier, signed_info.signature_method, diff --git a/src/xmlenc/encrypt.rs b/src/xmlenc/encrypt.rs index 23aa06df..4973a70a 100644 --- a/src/xmlenc/encrypt.rs +++ b/src/xmlenc/encrypt.rs @@ -820,7 +820,11 @@ fn validate_xml_plaintext( } EncryptedDataType::Content => { let wrapped = format!("{xml}"); - let _ = Document::parse_with_options(&wrapped, parsing_options())?; + let mut options = parsing_options(); + // The wrapper exists only to parse an XML fragment. Its node must + // not consume the caller-owned plaintext node allowance. + options.nodes_limit = options.nodes_limit.saturating_add(1); + let _ = Document::parse_with_options(&wrapped, options)?; Ok(()) } EncryptedDataType::Other(_) => Err(XmlEncError::InvalidEncryptionConfig( @@ -1427,6 +1431,24 @@ mod tests { )); } + #[test] + fn content_plaintext_node_limit_excludes_the_internal_wrapper() { + let policy = |max_xml_nodes| crate::policy::EncryptionPolicy { + resources: crate::policy::ResourcePolicy { + max_xml_nodes, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::EncryptionPolicy::default() + }; + + validate_xml_plaintext("", &EncryptedDataType::Content, &policy(3)) + .expect("the caller root and two elements must fit a three-node policy"); + assert!(matches!( + validate_xml_plaintext("", &EncryptedDataType::Content, &policy(2),), + Err(XmlEncError::XmlParse(roxmltree::Error::NodesLimitReached)) + )); + } + #[test] fn document_encryption_bounds_projected_replacement_nodes() { fn policy(max_xml_nodes: usize) -> crate::policy::EncryptionPolicy {