//! XMLDSig reference processing and end-to-end signature verification pipeline.
//!
//! Implements [XMLDSig §4.3.3](https://www.w3.org/TR/xmldsig-core1/#sec-CoreValidation):
//! for each `<Reference>` in `<SignedInfo>`, dereference the URI, apply transforms,
//! compute the digest, and compare with the stored `<DigestValue>`.
//!
//! This module wires together:
//! - [`UriReferenceResolver`] for URI dereference
//! - [`super::transforms::execute_transforms`] for the transform pipeline
//! - [`compute_digest`] + [`constant_time_eq`] for digest computation and comparison
//! - [`verify_signature_with_pem_key`] for full pipeline validation (`SignedInfo` + `SignatureValue`)
use crate::xml::dom::{Node, NodeId};
use base64::Engine;
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet};
use crate::c14n::canonicalize_bounded_with_xml_base_budget;
use crate::document::{
DocumentParseSettings, DocumentView, NodeIdentity, XmlDocument, XmlDocumentError,
};
use crate::hard_limits::CANONICALIZED_SIGNATURE_DATA_BYTE_CEILING;
use crate::operation::{
OperationDecisionReason, OperationExecutionContext, OperationNodeId, OperationNodeKind,
OperationPlanError, OperationResourceIdentity, OperationStage,
};
#[cfg(test)]
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_with_policy_budgets, parse_key_info_with_policy_budgets_and_document_base,
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,
verify_rsa_signature_pem,
};
#[cfg(test)]
use super::transforms::BASE64_TRANSFORM_URI;
use super::transforms::{
DEFAULT_IMPLICIT_C14N_URI, Transform, TransformExecutionBudget, TransformOptions,
XPATH_TRANSFORM_URI, XPathHereSemantics, XPathSignatureParseBudget,
execute_transforms_with_options_and_budget, map_c14n_resource_policy_violation,
transform_chain_produces_binary,
};
use super::types::{NodeSet, TransformError, subtree_node_id_range};
use super::uri::{ExternalResourceMapError, UriReferenceResolver, validate_external_resource_map};
use super::whitespace::{is_xml_whitespace_only, normalize_xml_base64_bytes};
const MAX_SIGNATURE_VALUE_LEN: usize = 8192;
const MAX_RETRIEVAL_METHOD_COUNT: usize = 64;
/// Cryptographic verifier used by [`VerifyContext`].
///
/// This trait intentionally has no `Send + Sync` supertraits so lightweight
/// single-threaded verifiers can be used without additional bounds.
pub trait VerifyingKey {
/// Exact external execution domain; a key cannot be moved between token sessions.
fn provider_binding(&self) -> Option<&crate::provider::ProviderBinding> {
None
}
/// Composite resolvers may try individual keys through the selected engine.
/// `None` identifies a leaf handle. Wrappers must preserve per-key policy
/// when forwarding the visitor; no public/private key copy is required.
fn verify_candidate_keys(
&self,
_verify: &mut dyn FnMut(&dyn VerifyingKey) -> Result<bool, DsigError>,
) -> Result<Option<bool>, DsigError> {
Ok(None)
}
/// Borrow public SPKI for native verification, validating method/key binding.
/// Opaque external keys may return `None`; native providers then fail closed.
fn verification_spki(
&self,
_algorithm: SignatureAlgorithm,
) -> Result<Option<&[u8]>, DsigError> {
Ok(None)
}
/// Wire encoding already selected by the immutable verification policy.
fn ecdsa_encoding(&self) -> crate::policy::EcdsaSignatureValueEncoding {
crate::policy::EcdsaSignatureValueEncoding::XmlDsig
}
/// Verify with validated domain separation, rejecting unsupported contexts.
fn verify_with_context(
&self,
algorithm: SignatureAlgorithm,
context: &super::SignatureContext,
data: &[u8],
signature: &[u8],
) -> Result<bool, DsigError> {
if !context.as_bytes().is_empty() {
return Err(super::SignatureVerificationError::UnsupportedAlgorithm {
uri: algorithm.uri().to_owned(),
}
.into());
}
self.verify(algorithm, data, signature)
}
/// Validate this key against the operation's immutable trust policy.
///
/// Built-in keys override this hook so pre-resolved and resolver-produced
/// keys enforce identical strength constraints. Custom opaque keys may
/// retain their own policy enforcement by accepting the default no-op.
fn validate_policy(
&self,
_policy: &crate::policy::VerificationPolicy,
) -> Result<(), DsigError> {
Ok(())
}
/// 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<bool, DsigError> {
Ok(super::signature::signature_value_matches_algorithm(
algorithm,
signature_value,
))
}
/// Validate wire framing under the operation's immutable compatibility policy.
///
/// The default delegates to [`VerifyingKey::validate_signature_value`] and
/// therefore implements only the standard XMLDSig framing contract. Custom
/// keys that support policy-selected wire formats, such as
/// [`crate::policy::EcdsaSignatureValueEncoding::XmlSecAsn1Der`], must
/// override this hook rather than accepting both formats implicitly.
fn validate_signature_value_with_policy(
&self,
policy: &crate::policy::VerificationPolicy,
algorithm: SignatureAlgorithm,
signature_value: &[u8],
) -> Result<bool, DsigError> {
let _ = policy;
self.validate_signature_value(algorithm, signature_value)
}
/// Verify `signature_value` over `signed_data` with the declared algorithm.
fn verify(
&self,
algorithm: SignatureAlgorithm,
signed_data: &[u8],
signature_value: &[u8],
) -> Result<bool, DsigError>;
/// Verify after applying operation-scoped compatibility semantics.
///
/// The default delegates to [`VerifyingKey::verify`]. Custom keys whose
/// provider input depends on compatibility policy must override this hook
/// consistently with [`VerifyingKey::validate_signature_value_with_policy`].
fn verify_with_policy(
&self,
policy: &crate::policy::VerificationPolicy,
algorithm: SignatureAlgorithm,
signed_data: &[u8],
signature_value: &[u8],
) -> Result<bool, DsigError> {
let _ = policy;
self.verify(algorithm, signed_data, signature_value)
}
}
/// Key resolver hook used by [`VerifyContext`] when no pre-set key is provided.
///
/// This trait intentionally has no `Send + Sync` supertraits; callers that need
/// cross-thread sharing can wrap resolvers/keys in their own thread-safe types.
pub trait KeyResolver {
/// Discover and explicitly authorize a key for an end-to-end operation.
/// The default preserves discovery only: applications that authorize keys
/// themselves return `ResolvedVerificationKey::CallerTrusted` explicitly.
fn resolve_for_verification<'a>(
&'a self,
key_info: Option<&KeyInfo>,
algorithm: SignatureAlgorithm,
policy: &crate::policy::VerificationPolicy,
provider: &dyn crate::provider::CryptoProvider,
) -> Result<Option<super::ResolvedVerificationKey<'a>>, DsigError> {
Ok(self
.resolve_with_policy_and_provider(key_info, algorithm, policy, provider)?
.map(super::ResolvedVerificationKey::Candidate))
}
/// Resolve a verification key from parsed `<KeyInfo>` sources.
///
/// Return `Ok(None)` when no suitable key could be resolved from available
/// key material (for example, missing `<KeyInfo>` candidates). `VerifyContext`
/// maps `Ok(None)` to `DsigStatus::Invalid(FailureReason::KeyNotFound)`;
/// reserve `Err(...)` for resolver failures.
fn resolve<'a>(
&'a self,
key_info: Option<&KeyInfo>,
algorithm: SignatureAlgorithm,
) -> Result<Option<Box<dyn VerifyingKey + 'a>>, DsigError>;
/// Resolve under the operation's immutable policy snapshot.
///
/// Implementations that make trust or key-source decisions must override
/// this method. Resolver implementations that inspect multiple candidates
/// must enforce [`crate::policy::ResourcePolicy::max_key_candidates`] across
/// that internal search. The verification pipeline separately requires
/// capacity for the single candidate returned by any resolver. The default
/// preserves source-only custom resolvers whose behavior is independent of
/// other policy fields.
fn resolve_with_policy<'a>(
&'a self,
key_info: Option<&KeyInfo>,
algorithm: SignatureAlgorithm,
_policy: &crate::policy::VerificationPolicy,
) -> Result<Option<Box<dyn VerifyingKey + 'a>>, DsigError> {
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<Option<Box<dyn VerifyingKey + 'a>>, DsigError> {
self.resolve_with_policy(key_info, algorithm, policy)
}
/// Return `true` when this resolver consumes document `<KeyInfo>` material.
///
/// The verification pipeline uses this to decide whether malformed
/// `<KeyInfo>` should raise `DsigError::ParseKeyInfo` before resolver
/// execution. Resolvers that ignore document key material can keep the
/// default `false` to avoid fail-closed parsing on advisory `<KeyInfo>`.
fn consumes_document_key_info(&self) -> bool {
false
}
}
/// Allowed structural URI classes for XMLDSig reference and key-source policy.
///
/// 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 {
allow_empty: bool,
allow_same_document: bool,
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 {
Self {
allow_empty,
allow_same_document,
allow_external,
}
}
/// Allow only same-document references (`""`, `#id`, `#xpointer(...)`).
pub const SAME_DOCUMENT: Self = Self {
allow_empty: true,
allow_same_document: true,
allow_external: false,
};
/// Allow all URI classes.
///
/// External URIs still require an explicit caller-owned resource map.
pub const ALL: Self = Self {
allow_empty: true,
allow_same_document: true,
allow_external: true,
};
/// Return whether this set permits the URI's structural class.
pub fn allows(self, uri: &str) -> bool {
match classify_uri(uri) {
UriClass::Empty => self.allow_empty,
UriClass::SameDocument => self.allow_same_document,
UriClass::External => self.allow_external,
}
}
}
impl Default for UriTypeSet {
fn default() -> Self {
Self::SAME_DOCUMENT
}
}
/// Request-scoped selection of the XMLDSig operation node.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum SignatureSelection<'a> {
/// Require exactly one `Signature` in the complete document.
#[default]
UniqueDocumentSignature,
/// Select the first descendant `Signature` from the document root.
FirstDocumentSignature,
/// Select the first descendant `Signature` below the element with this ID.
FirstSignatureUnderId(&'a str),
}
/// Verification builder/configuration.
#[must_use = "configure the context and call verify(), or store it for reuse"]
#[derive(Clone)]
pub struct VerifyContext<'a> {
decapsulation_key: Option<&'a dyn crate::provider::KeyDecapsulationKey>,
key: Option<&'a dyn VerifyingKey>,
key_resolver: Option<&'a dyn KeyResolver>,
policy: crate::policy::VerificationPolicy,
provider: &'a dyn crate::provider::CryptoProvider,
xml_backend: crate::XmlBackend,
store_pre_digest: bool,
external_resources: Option<&'a HashMap<String, Vec<u8>>>,
signature_selection: SignatureSelection<'a>,
id_attributes: &'a [crate::IdAttributeRegistration],
}
impl<'a> VerifyContext<'a> {
/// Create a context with conservative defaults.
///
/// Defaults:
/// - no pre-set key, no key resolver
/// - manifests disabled
/// - same-document URIs only
/// - all transforms allowed
/// - pre-digest buffers not stored
pub fn new() -> Self {
Self {
decapsulation_key: None,
key: None,
key_resolver: None,
policy: crate::policy::VerificationPolicy::default(),
provider: crate::provider::default_provider(),
xml_backend: crate::XmlBackend::default(),
store_pre_digest: false,
external_resources: None,
signature_selection: SignatureSelection::UniqueDocumentSignature,
id_attributes: &[],
}
}
/// Set a pre-resolved verification key.
///
/// Built-in [`super::VerificationKey`] values are validated against the
/// same operation key-strength policy as resolver-produced keys. Custom
/// opaque [`VerifyingKey`] implementations retain responsibility for any
/// key metadata that the core cannot inspect.
pub fn key(mut self, key: &'a dyn VerifyingKey) -> Self {
self.key = Some(key);
self
}
/// Recover the HMAC secret using an explicit recipient private key.
/// Policy must grant the exact experimental KEM. A valid KEM/HMAC result
/// is not evidence of sender identity.
pub fn decapsulation_key(mut self, key: &'a dyn crate::provider::KeyDecapsulationKey) -> Self {
self.decapsulation_key = Some(key);
self
}
/// Set a key resolver fallback used when `key()` is not provided.
pub fn key_resolver(mut self, resolver: &'a dyn KeyResolver) -> Self {
self.key_resolver = Some(resolver);
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
}
/// Select the compiled XML parser backend for this verification operation.
pub fn xml_backend(mut self, backend: crate::XmlBackend) -> Self {
self.xml_backend = backend;
self
}
/// Enable or disable `<Manifest>` processing.
///
/// When enabled, references in `<ds:Manifest>` elements that are direct
/// element children of `<ds:Object>` are processed only when the direct-child
/// `<ds:Object>` or `<ds:Manifest>` itself is referenced from `<SignedInfo>`
/// by an ID-based same-document fragment URI such as `#id` or
/// `#xpointer(id('id'))`, and that reference uses only canonicalization
/// transforms (or implicit canonicalization). Filtering or binary transforms
/// do not prove that the complete Manifest structure was authenticated.
/// Only those signed Manifest references are returned in
/// `VerifyResult::manifest_references`.
/// Manifest parsing begins only after every `<SignedInfo>` reference digest
/// validates; a failure returns immediately with no Manifest results.
/// Nested `<ds:Manifest>` descendants under `<ds:Object>` are not
/// processed.
/// Direct-child unsigned/unreferenced Manifests are skipped and do not
/// appear in `VerifyResult::manifest_references`.
/// Whole-document same-document references such as `URI=""` or
/// `URI="#xpointer(/)"` do not mark a specific direct-child
/// `<ds:Object>`/`<ds:Manifest>` as signed for this option.
///
/// Manifests are parsed and processed only after the SignedInfo references
/// and SignatureValue both validate. Their digest mismatches, policy
/// violations, and processing failures are then reported independently in
/// `VerifyResult::manifest_references` and do not alter `VerifyResult::status`.
/// Callers that enable `process_manifests(true)` must inspect
/// `VerifyResult::manifest_references` in addition to `VerifyResult::status`
/// when interpreting `verify()` results.
/// Structural/parse errors in Manifest content abort `verify()` and are
/// returned as `Err(...)`.
pub fn process_manifests(mut self, enabled: bool) -> Self {
self.policy.manifest_processing = if enabled {
crate::policy::ManifestProcessing::Process
} else {
crate::policy::ManifestProcessing::Ignore
};
self
}
/// Restrict allowed reference URI classes.
pub fn allowed_uri_types(mut self, types: UriTypeSet) -> Self {
self.policy.uris.references = types;
self
}
/// Restrict URI classes used to retrieve key material from `<KeyInfo>`.
///
/// 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.policy.uris.retrieval_methods = types;
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`]. 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<String, Vec<u8>>) -> Self {
self.external_resources = Some(resources);
self
}
/// Select the operation start node by its XML ID value.
///
/// Verification selects the first descendant `<Signature>` in document
/// order. This is request context, not a policy decision, and mirrors
/// libxmlsec1's depth-first `xmlSecFindNode` start-node contract.
pub fn start_node_id(mut self, id: &'a str) -> Self {
self.signature_selection = SignatureSelection::FirstSignatureUnderId(id);
self
}
/// Select the first descendant `<Signature>` from the document root.
///
/// This is the libxmlsec1 command-line operation-root contract. The library
/// default remains fail-closed and requires a unique document signature.
pub fn first_document_signature(mut self) -> Self {
self.signature_selection = SignatureSelection::FirstDocumentSignature;
self
}
/// Add caller-declared ID attributes for start-node and Reference lookup.
pub fn id_attributes(mut self, registrations: &'a [crate::IdAttributeRegistration]) -> Self {
self.id_attributes = registrations;
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.policy.xml.allow_internal_dtd = enabled;
self
}
/// 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#`
///
/// 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<I, S>(mut self, transforms: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.policy.transforms.allowed_algorithms =
Some(transforms.into_iter().map(Into::into).collect());
self
}
/// Store pre-digest buffers for diagnostics.
///
/// Retained reference buffers and canonicalized `<SignedInfo>` share a
/// non-configurable 32 MiB safety ceiling. Canonicalized `<SignedInfo>` is
/// charged even when diagnostic retention is disabled because signature
/// verification always materializes it. Overflow remains a typed policy
/// violation at both low-level and end-to-end entry points.
pub fn store_pre_digest(mut self, enabled: bool) -> Self {
self.store_pre_digest = enabled;
self
}
/// Select the node returned by XPath's `here()` extension function.
///
/// The default follows XMLDSig and returns the `<XPath>` parameter.
/// Use [`XPathHereSemantics::XmlSecLegacy`] only for documents known to
/// have been generated with libxmlsec1's `<Transform>` interpretation.
pub fn xpath_here_semantics(mut self, semantics: XPathHereSemantics) -> Self {
self.policy.transforms.xpath_here_semantics = semantics;
self
}
fn allowed_transform_uris(&self) -> Option<&HashSet<String>> {
self.policy.transforms.allowed_algorithms.as_ref()
}
fn transform_options(&self) -> TransformOptions<'_> {
TransformOptions::default()
.id_attributes(self.id_attributes)
.allow_internal_dtd(self.policy.xml.allow_internal_dtd)
.xpath_here_semantics(self.policy.transforms.xpath_here_semantics)
.opc_relationship_edition(self.policy.transforms.opc_relationship_edition)
}
/// Verify one XMLDSig signature using this context.
///
/// Returns `Ok(VerifyResult)` for both valid and invalid signatures; inspect
/// `VerifyResult::status` for the core `<SignedInfo>` and signature-value
/// outcome. When Manifest processing is enabled, inspect every
/// `VerifyResult::manifest_references` entry separately. `Err(...)` is
/// reserved for pipeline failures.
pub fn verify(&self, xml: &str) -> Result<VerifyResult, DsigError> {
verify_signature_with_context(xml, self)
}
/// Verify a signature against a retained owned document generation.
///
/// The active XML input policy is revalidated against the document's parse
/// provenance as well as its current byte and node counts. In particular, a
/// strict context rejects a document that required internal DTD support.
pub fn verify_document(&self, document: &XmlDocument) -> Result<VerifyResult, DsigError> {
verify_signature_document_with_context(document, self)
}
/// Verify every Signature in one retained document generation.
///
/// All signatures share the operation's work and retention budgets. A
/// malformed or invalid signature is retained as an individual outcome,
/// never silently skipped. Call [`VerifyEvidence::accepted`] with the
/// current document before accepting the request; a successful return
/// alone is not validation. [`VerifyEvidence::all_valid`] is historical
/// mathematical diagnostics only and does not revalidate document generation.
pub fn verify_all(&self, document: &XmlDocument) -> Result<VerifyEvidence, DsigError> {
verify_all_document(document, self, &VerificationRequest::default())
}
/// Discover bounded Signature identities without key or digest work.
///
/// These are untrusted selection handles, not verification evidence. Use
/// them to authorize request keys even when Signatures have no XML ID.
pub fn signature_identities(
&self,
document: &XmlDocument,
) -> Result<Vec<NodeIdentity>, DsigError> {
self.policy.validate()?;
document.validate_operation_policy(&self.policy.xml, &self.policy.resources)?;
document.with_view(|view| {
let count = count_signature_nodes(view, self.policy.resources.max_signatures)?;
let mut identities = Vec::with_capacity(count);
for node in view.document().descendants() {
if node.has_tag_name((XMLDSIG_NS, "Signature")) {
identities.push(view.node_identity(node));
}
}
Ok(identities)
})
}
/// Verify all signatures against explicit caller-selected targets and keys.
///
/// Requests are not policy: they cannot permit algorithms, URI classes or
/// XML features forbidden by the immutable policy snapshot.
pub fn verify_request(
&self,
document: &XmlDocument,
request: &VerificationRequest<'_>,
) -> Result<VerifyEvidence, DsigError> {
verify_all_document(document, self, request)
}
}
/// Application requirement for the independently verified signatures.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum SignatureRequirement {
/// Every discovered signature must pass, and at least one must exist.
#[default]
All,
/// Explicitly tolerate other invalid signatures if this many pass.
AtLeast(usize),
/// Require exactly this many discovered signatures, all passing.
Exactly(usize),
}
/// Explicit key authorization for one stable, caller-selected Signature.
pub struct CallerTrustedSignatureKey<'a> {
/// Signature that this key is authorized to verify.
pub signature: NodeIdentity,
/// Application-owned key; embedded KeyInfo cannot override it.
pub key: &'a dyn VerifyingKey,
}
/// Request data, immutably borrowed for one verification operation.
#[derive(Default)]
pub struct VerificationRequest<'a> {
/// Elements whose complete expanded content must be authenticated.
/// Comments and an enveloped Signature are not part of this assertion.
pub expected_targets: &'a [NodeIdentity],
/// Per-signature trusted keys. Duplicate/foreign/stale mappings fail closed.
pub trusted_keys: &'a [CallerTrustedSignatureKey<'a>],
/// Explicit external bytes; no implicit I/O or configuration discovery.
pub external_resources: Option<&'a HashMap<String, Vec<u8>>>,
/// Required multi-signature outcome; zero counts are invalid requests.
pub signatures: SignatureRequirement,
/// Opaque caller correlation value, never read from XML or used as trust.
pub correlation: Option<[u8; 16]>,
}
/// Outcome for one exact Signature, including pipeline failures.
#[derive(Debug)]
pub struct SignatureEvidence {
identity: NodeIdentity,
result: Result<VerifyResult, DsigError>,
}
impl SignatureEvidence {
/// Stable identity of the Signature actually processed.
#[must_use]
pub const fn identity(&self) -> NodeIdentity {
self.identity
}
/// Individual outcome. Digest matches do not imply signature validity.
pub const fn result(&self) -> &Result<VerifyResult, DsigError> {
&self.result
}
}
/// Bounded outcomes for all signatures in one document generation.
#[derive(Debug)]
#[must_use = "inspect the individual outcomes before accepting the document"]
pub struct VerifyEvidence {
document: NodeIdentity,
signatures: Vec<SignatureEvidence>,
accepted: bool,
correlation: Option<[u8; 16]>,
}
impl VerifyEvidence {
/// Whether the explicit request requirements hold for this document.
/// Foreign documents or any mutation after verification invalidate acceptance.
pub fn accepted(&self, document: &XmlDocument) -> Result<bool, DsigError> {
document.with_view(|view| {
view.resolve_node(self.document)?;
Ok(self.accepted)
})
}
/// Uninterpreted correlation value supplied by the caller.
#[must_use]
pub const fn correlation(&self) -> Option<[u8; 16]> {
self.correlation
}
/// Prove complete expanded element content under an authorized signature.
///
/// This does not claim source-byte equality, comment coverage or protection
/// of the owning enveloped Signature. Arbitrary XPath/binary transforms
/// remain indeterminate rather than falsely covering their original input.
/// The exact document generation must still exist; wrapping an unsigned
/// sibling with a matching name cannot satisfy an identity-based assertion.
pub fn covers_element(
&self,
document: &XmlDocument,
target: NodeIdentity,
) -> Result<bool, DsigError> {
document.with_view(|view| {
view.resolve_node(self.document)?;
let target_node = view.resolve_node(target)?;
if !target_node.is_element() {
return Err(DsigError::InvalidRequest {
reason: "coverage target must be an element",
});
}
let target_id = target_node.id().get();
for signature in &self.signatures {
let Ok(result) = &signature.result else {
continue;
};
if result.status != DsigStatus::Valid
|| !matches!(
result.key_trust,
super::KeyTrustEvidence::CallerTrusted
| super::KeyTrustEvidence::ValidatedX509(_)
)
{
continue;
}
let signature_node = view.resolve_node(signature.identity)?;
for reference in result
.signed_info_references
.iter()
.chain(&result.manifest_references)
{
if reference.status != DsigStatus::Valid {
continue;
}
let Some(root) = reference.target_identity else {
continue;
};
if reference.coverage == ReferenceCoverage::TransformedData {
continue;
}
let root = view.resolve_node(root)?;
// The normalized arena assigns contiguous preorder IDs;
// reuse the same subtree contract as node-set subtraction,
// rather than walking depth for every report/target pair.
if !subtree_node_id_range(root).contains(&target_id) {
continue;
}
if reference.coverage == ReferenceCoverage::EnvelopedElementContent
&& subtree_node_id_range(signature_node).contains(&target_id)
{
continue;
}
return Ok(true);
}
}
Ok(false)
})
}
/// Outcomes in document order, including malformed signatures.
#[must_use]
pub fn signatures(&self) -> &[SignatureEvidence] {
&self.signatures
}
/// Whether a nonempty set of signatures and their processed Manifests passed.
///
/// This is a historical mathematical outcome, not current-document
/// authorization. Use `accepted(document)` for generation-bound acceptance.
#[must_use]
pub fn all_valid(&self) -> bool {
!self.signatures.is_empty()
&& self.signatures.iter().all(|entry| {
entry.result.as_ref().is_ok_and(|result| {
result.status == DsigStatus::Valid
&& result
.manifest_references
.iter()
.all(|reference| reference.status == DsigStatus::Valid)
})
})
}
}
impl Default for VerifyContext<'_> {
fn default() -> Self {
Self::new()
}
}
/// Per-reference verification result.
#[derive(Debug, Clone)]
#[non_exhaustive]
#[must_use = "inspect status before accepting the reference result"]
pub struct ReferenceResult {
/// Exact Reference node when processing a retained document.
pub reference_identity: Option<NodeIdentity>,
/// Resolved same-document target, not an untrusted URI claim.
pub target_identity: Option<NodeIdentity>,
/// SHA-256 identity of resolved external bytes before transforms. This is
/// provenance, not signer authorization or an original-element proof.
pub external_resource_fingerprint: Option<[u8; 32]>,
/// What can be proven about original element-content coverage.
pub coverage: ReferenceCoverage,
/// Whether this reference came from `<SignedInfo>` or `<Manifest>`.
pub reference_set: ReferenceSet,
/// Zero-based index within `reference_set`.
pub reference_index: usize,
/// URI from the `<Reference>` element (for diagnostics).
pub uri: String,
/// Digest algorithm used.
pub digest_algorithm: DigestAlgorithm,
/// Reference verification status.
pub status: DsigStatus,
/// Pre-digest bytes (populated when `store_pre_digest` is enabled).
pub pre_digest_data: Option<Vec<u8>>,
}
/// Conservative coverage classification for the original semantic document.
///
/// XMLDSig 1.1 §8.1.1 protects only transformed data. Unknown projection is
/// deliberately not promoted to complete original-element coverage.
/// https://www.w3.org/TR/2013/REC-xmldsig-core1-20130411/#sec-Signed
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ReferenceCoverage {
/// No complete original-element coverage proof (binary or arbitrary filter).
TransformedData,
/// Expanded element names, attributes and content, excluding comments.
ElementContent,
/// Element content except the owning Signature subtree and comments.
EnvelopedElementContent,
}
/// Origin of a processed `<Reference>`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ReferenceSet {
/// `<Reference>` under `<SignedInfo>`.
SignedInfo,
/// `<Reference>` under `<Object>/<Manifest>`.
Manifest,
}
/// Verification status.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum DsigStatus {
/// Signature/reference is cryptographically valid.
Valid,
/// Signature/reference is invalid with a concrete reason.
Invalid(FailureReason),
}
/// Why XMLDSig verification failed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FailureReason {
/// `<DigestValue>` mismatch for a `<Reference>` at `ref_index`.
ReferenceDigestMismatch {
/// Zero-based index of the failing `<Reference>` in its processed set.
///
/// On per-reference verification entries, use
/// `ReferenceResult::reference_set` to distinguish the `<SignedInfo>`
/// and `<Manifest>` reference sets.
///
/// When this reason appears in `VerifyResult::status` without an
/// accompanying `ReferenceResult`, `ref_index` always refers to the
/// `<SignedInfo>` reference set.
ref_index: usize,
},
/// `<Reference>` rejected by URI/transform allowlist policy.
ReferencePolicyViolation {
/// Zero-based index of the failing `<Reference>` in its processed set.
ref_index: usize,
},
/// `<Reference>` processing failed (dereference, transform, missing URI).
ReferenceProcessingFailure {
/// Zero-based index of the failing `<Reference>` in its processed set.
ref_index: usize,
},
/// `<SignatureValue>` does not match canonicalized `<SignedInfo>`.
SignatureMismatch,
/// No verification key was configured or could be resolved.
KeyNotFound,
}
/// Result of processing all `<Reference>` elements in `<SignedInfo>`.
#[derive(Debug)]
#[non_exhaustive]
#[must_use = "check first_failure/results before accepting the reference set"]
pub struct ReferencesResult {
/// Per-reference results (one per `<Reference>` in order).
/// On fail-fast, only references up to and including the failed one are present.
pub results: Vec<ReferenceResult>,
/// Index of the first failed reference, if any.
pub first_failure: Option<usize>,
}
impl ReferencesResult {
/// Whether all references passed digest verification.
#[must_use]
pub fn all_valid(&self) -> bool {
self.results
.iter()
.all(|result| matches!(result.status, DsigStatus::Valid))
}
}
/// Process a single `<Reference>`: dereference URI → apply transforms → compute
/// digest → compare with stored `<DigestValue>`.
///
/// # Arguments
///
/// - `reference`: The parsed `<Reference>` element.
/// - `resolver`: URI resolver for the document.
/// - `signature_node`: The `<Signature>` element (for enveloped-signature transform).
/// - `reference_set`: Whether this reference belongs to `<SignedInfo>` or `<Manifest>`.
/// - `reference_index`: Zero-based index of this reference inside `reference_set`.
/// - `store_pre_digest`: If true, store the pre-digest bytes in the result,
/// subject to the signature-wide diagnostic retention ceiling.
///
/// # Errors
///
/// Returns `Err` for processing failures (URI dereference, transform errors).
/// Digest mismatch is NOT an error — it produces
/// `Ok(ReferenceResult { status: Invalid(ReferenceDigestMismatch { .. }) })`.
pub fn process_reference(
reference: &Reference,
resolver: &UriReferenceResolver<'_>,
signature_node: Node<'_, '_>,
reference_set: ReferenceSet,
reference_index: usize,
store_pre_digest: bool,
) -> Result<ReferenceResult, ReferenceProcessingError> {
let execution_budget = TransformExecutionBudget::default();
let canonicalized_data_budget = CanonicalizedDataBudget::default();
let execution = ReferenceExecutionContext {
store_pre_digest,
transform_options: TransformOptions::default().id_attributes(resolver.id_registrations()),
transform_budget: &execution_budget,
canonicalized_data_budget: &canonicalized_data_budget,
provider: crate::provider::default_provider(),
};
process_reference_with_options(
reference,
resolver,
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<Node<'a, 'input>> {
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
.children()
.filter(|node| {
node.is_element()
&& node.tag_name().namespace() == Some(XMLDSIG_NS)
&& 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),
}
}
fn bind_reference_evidence(
result: &mut ReferenceResult,
reference: &Reference,
view: DocumentView<'_>,
origin: Option<Node<'_, '_>>,
observed: &OperationResourceIdentity,
) {
result.reference_identity = origin.map(|node| view.node_identity(node));
if let OperationResourceIdentity::External { fingerprint, .. } = observed {
result.external_resource_fingerprint = Some(*fingerprint);
}
if let OperationResourceIdentity::DocumentNode(target) = observed {
result.target_identity = Some(*target);
// A later node-set transform can reparse C14N output, so arbitrary
// chains cannot retain an original subtree claim. This classification
// describes proven element content, never discarded comments or the
// namespace-declaration spelling normalized by canonicalization.
let mut enveloped = false;
let mut canonicalized = false;
for transform in &reference.transforms {
match transform {
Transform::C14n(_) if !canonicalized => canonicalized = true,
Transform::Enveloped if !canonicalized => enveloped = true,
_ => return,
}
}
result.coverage = if enveloped {
ReferenceCoverage::EnvelopedElementContent
} else {
ReferenceCoverage::ElementContent
};
}
}
struct ReferenceExecutionContext<'a> {
store_pre_digest: bool,
transform_options: TransformOptions<'a>,
transform_budget: &'a TransformExecutionBudget,
canonicalized_data_budget: &'a CanonicalizedDataBudget,
provider: &'a dyn crate::provider::CryptoProvider,
}
struct CanonicalizedDataBudget {
remaining: Cell<usize>,
max_bytes: usize,
}
struct VerificationOperationBudgets {
key_establishment: RefCell<crate::key_establishment::KeyEstablishmentUsage>,
transforms: TransformExecutionBudget,
canonicalized: CanonicalizedDataBudget,
xpath_parse: RefCell<XPathSignatureParseBudget>,
key_info_materialization: RefCell<KeyInfoMaterializationState>,
external_resource_identities: RefCell<HashMap<String, OperationResourceIdentity>>,
resolved_reference_resources: RefCell<HashMap<NodeId, OperationResourceIdentity>>,
external_resource_set_identity: RefCell<Option<OperationResourceIdentity>>,
}
impl VerificationOperationBudgets {
fn with_transforms(
policy: &crate::policy::VerificationPolicy,
transforms: TransformExecutionBudget,
) -> Self {
Self {
key_establishment: Default::default(),
transforms,
canonicalized: CanonicalizedDataBudget::with_limit(
policy.resources.effective_canonicalized_bytes(),
),
xpath_parse: RefCell::new(XPathSignatureParseBudget::from_resources(&policy.resources)),
key_info_materialization: RefCell::new(KeyInfoMaterializationState::default()),
external_resource_identities: RefCell::new(HashMap::new()),
resolved_reference_resources: RefCell::new(HashMap::new()),
external_resource_set_identity: RefCell::new(None),
}
}
fn external_resource_set_identity(
&self,
resolver: &UriReferenceResolver<'_>,
) -> OperationResourceIdentity {
// One resolver borrows the same immutable map for the entire request.
// Hash/sort it once, not once per graph node or Signature.
let mut cached = self.external_resource_set_identity.borrow_mut();
cached
.get_or_insert_with(|| resolver.external_resource_set_identity())
.clone()
}
fn resource_identity_for_reference(
&self,
reference: &Reference,
index: usize,
resolver: &UriReferenceResolver<'_>,
view: DocumentView<'_>,
origin: Option<Node<'_, '_>>,
) -> OperationResourceIdentity {
let Some(uri) = reference.uri.as_deref() else {
return OperationResourceIdentity::Generated("omitted-reference", index);
};
if uri.is_empty() || uri.starts_with('#') {
return resolver
.node_id_for_reference_resource(
uri,
Some(self.transforms.node_set_materialization()),
)
.ok()
.flatten()
.map(|node| OperationResourceIdentity::DocumentNode(view.node_identity_by_id(node)))
.unwrap_or(OperationResourceIdentity::Generated(
"missing-document-reference",
index,
));
}
let resolved_uri;
let uri = if let Some(origin) = origin {
if let Some(identity) = self.resolved_reference_resources.borrow().get(&origin.id()) {
return identity.clone();
}
// XML Base Second Edition §4.2 resolves the external reference in
// its owning element's context. Identity and execution must agree.
// https://www.w3.org/TR/2009/REC-xmlbase-20090128/#resolving
let Ok(resolved) = crate::c14n::xml_base::resolve_uri_from_node_with_budget(
origin,
uri,
self.transforms.xml_base_resolution(),
) else {
return OperationResourceIdentity::Generated(
"unresolved-external-reference",
index,
);
};
resolved_uri = resolved;
resolved_uri.as_str()
} else {
uri
};
if let Some(identity) = self.external_resource_identities.borrow().get(uri).cloned() {
if let Some(origin) = origin {
self.resolved_reference_resources
.borrow_mut()
.insert(origin.id(), identity.clone());
}
return identity;
}
let identity = resolver.external_resource_identity(uri).unwrap_or(
OperationResourceIdentity::Generated("missing-external-reference", index),
);
if matches!(identity, OperationResourceIdentity::External { .. }) {
self.external_resource_identities
.borrow_mut()
.insert(uri.to_owned(), identity.clone());
if let Some(origin) = origin {
self.resolved_reference_resources
.borrow_mut()
.insert(origin.id(), identity.clone());
}
}
identity
}
}
struct VerificationPlanNodes {
document: OperationNodeId,
key_materialization: OperationNodeId,
key: OperationNodeId,
digests: Vec<OperationNodeId>,
canonicalization: OperationNodeId,
crypto: OperationNodeId,
}
fn compile_verification_operation_plan(
operation: &mut OperationExecutionContext<
&crate::policy::VerificationPolicy,
&VerificationOperationBudgets,
>,
view: DocumentView<'_>,
signature_node: Node<'_, '_>,
references: &[Reference],
resolver: &UriReferenceResolver<'_>,
) -> Result<VerificationPlanNodes, SignatureVerificationPipelineError> {
operation
.validate_document_view(view)
.map_err(map_verification_plan_error)?;
let document_node = operation.add_node(
OperationNodeKind::Document,
OperationStage::Parse,
Some(OperationResourceIdentity::DocumentNode(
view.node_identity(signature_node),
)),
);
let key_materialization_resource = operation.budgets().external_resource_set_identity(resolver);
let key_materialization = operation.add_node(
OperationNodeKind::Key { index: 0 },
OperationStage::Resolve,
Some(key_materialization_resource.clone()),
);
let key_node = operation.add_node(
OperationNodeKind::Key { index: 1 },
OperationStage::Resolve,
None,
);
operation
.add_dependency(key_materialization, document_node)
.map_err(map_verification_plan_error)?;
operation
.add_dependency(key_node, key_materialization)
.map_err(map_verification_plan_error)?;
let mut digests = Vec::with_capacity(references.len());
for (index, reference) in references.iter().enumerate() {
let resource = operation.budgets().resource_identity_for_reference(
reference,
index,
resolver,
view,
reference_origin_node(signature_node, ReferenceSet::SignedInfo, index),
);
let digest_node = operation.add_node(
OperationNodeKind::Digest { index },
OperationStage::Digest,
Some(resource.clone()),
);
operation
.add_dependency(digest_node, document_node)
.map_err(map_verification_plan_error)?;
digests.push(digest_node);
}
let canonicalization = operation.add_node(
OperationNodeKind::Canonicalization,
OperationStage::Canonicalization,
None,
);
for digest in &digests {
operation
.add_dependency(canonicalization, *digest)
.map_err(map_verification_plan_error)?;
}
let crypto = operation.add_node(OperationNodeKind::Crypto, OperationStage::Crypto, None);
operation
.add_dependency(crypto, key_node)
.map_err(map_verification_plan_error)?;
operation
.add_dependency(crypto, canonicalization)
.map_err(map_verification_plan_error)?;
operation.compile().map_err(map_verification_plan_error)?;
Ok(VerificationPlanNodes {
document: document_node,
key_materialization,
key: key_node,
digests,
canonicalization,
crypto,
})
}
fn map_verification_plan_error(error: OperationPlanError) -> SignatureVerificationPipelineError {
SignatureVerificationPipelineError::OperationPlan(error.to_string())
}
impl Default for CanonicalizedDataBudget {
fn default() -> Self {
Self {
remaining: Cell::new(CANONICALIZED_SIGNATURE_DATA_BYTE_CEILING),
max_bytes: CANONICALIZED_SIGNATURE_DATA_BYTE_CEILING,
}
}
}
impl CanonicalizedDataBudget {
fn remaining(&self) -> usize {
self.remaining.get()
}
fn charge(&self, bytes: usize) -> Result<(), ReferenceProcessingError> {
let available = self.remaining.get();
let Some(remaining) = available.checked_sub(bytes) else {
self.remaining.set(0);
return Err(crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::CANONICALIZED_BYTES,
maximum: self.max_bytes,
actual: self
.max_bytes
.saturating_add(bytes.saturating_sub(available)),
}
.into());
};
self.remaining.set(remaining);
Ok(())
}
fn with_limit(max_bytes: usize) -> Self {
Self {
remaining: Cell::new(max_bytes),
max_bytes,
}
}
}
fn process_reference_with_options(
reference: &Reference,
resolver: &UriReferenceResolver<'_>,
signature_node: Node<'_, '_>,
reference_set: ReferenceSet,
reference_index: usize,
reference_node: Option<Node<'_, '_>>,
execution: &ReferenceExecutionContext<'_>,
) -> Result<ReferenceResult, ReferenceProcessingError> {
// 1. Dereference URI. Omitted URI is distinct from URI="" in XMLDSig and
// must be rejected until caller-provided external object resolution exists.
let uri = reference
.uri
.as_deref()
.ok_or(ReferenceProcessingError::MissingUri)?;
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(),
execution.transform_budget.xml_base_resolution(),
)
},
)
.map_err(ReferenceProcessingError::UriDereference)?;
// 2. Apply transform chain
let pre_digest_bytes = execute_transforms_with_options_and_budget(
signature_node,
initial_data,
&reference.transforms,
execution.transform_options,
execution.transform_budget,
)
.map_err(ReferenceProcessingError::Transform)?;
// 3. Compute digest
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) {
DsigStatus::Valid
} else {
DsigStatus::Invalid(FailureReason::ReferenceDigestMismatch {
ref_index: reference_index,
})
};
let pre_digest_data = if execution.store_pre_digest {
execution
.canonicalized_data_budget
.charge(pre_digest_bytes.len())?;
Some(pre_digest_bytes)
} else {
None
};
Ok(ReferenceResult {
reference_identity: None,
target_identity: None,
external_resource_fingerprint: None,
coverage: ReferenceCoverage::TransformedData,
reference_set,
reference_index,
uri: uri.to_owned(),
digest_algorithm: reference.digest_method,
status,
pre_digest_data,
})
}
/// Process all `<Reference>` elements in a `<SignedInfo>`, with fail-fast
/// on the first digest mismatch.
///
/// Per XMLDSig spec: if any reference fails, the entire signature is invalid.
/// Processing stops at the first failure for efficiency.
///
/// # Errors
///
/// Returns `Err` only for processing failures (malformed XML, unsupported
/// transform, etc.). Digest mismatches are reported via
/// `ReferencesResult::first_failure`.
pub fn process_all_references(
references: &[Reference],
resolver: &UriReferenceResolver<'_>,
signature_node: Node<'_, '_>,
store_pre_digest: bool,
) -> Result<ReferencesResult, ReferenceProcessingError> {
let execution_budget = TransformExecutionBudget::default();
let canonicalized_data_budget = CanonicalizedDataBudget::default();
let execution = ReferenceExecutionContext {
store_pre_digest,
transform_options: TransformOptions::default().id_attributes(resolver.id_registrations()),
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)
}
fn process_all_references_with_options(
references: &[Reference],
resolver: &UriReferenceResolver<'_>,
signature_node: Node<'_, '_>,
execution: &ReferenceExecutionContext<'_>,
) -> Result<ReferencesResult, ReferenceProcessingError> {
let mut results = Vec::with_capacity(references.len());
for (i, reference) in references.iter().enumerate() {
let result = process_reference_with_options(
reference,
resolver,
signature_node,
ReferenceSet::SignedInfo,
i,
reference_origin_node(signature_node, ReferenceSet::SignedInfo, i),
execution,
)?;
let failed = matches!(result.status, DsigStatus::Invalid(_));
results.push(result);
if failed {
return Ok(ReferencesResult {
results,
first_failure: Some(i),
});
}
}
Ok(ReferencesResult {
results,
first_failure: None,
})
}
/// Errors during reference processing.
///
/// Distinct from digest mismatch (which is a validation result, not a processing error).
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ReferenceProcessingError {
/// The immutable verification policy rejected reference processing.
#[error("verification policy violation: {0}")]
Policy(#[from] crate::policy::PolicyViolation),
/// The selected provider could not compute the declared digest.
#[error("cryptographic provider error: {0}")]
Provider(#[from] crate::provider::ProviderError),
/// `<Reference>` omitted the `URI` attribute, which we do not resolve implicitly.
#[error("reference URI is required; omitted URI references are not supported")]
MissingUri,
/// URI dereference failed.
#[error("URI dereference failed: {0}")]
UriDereference(#[source] super::types::TransformError),
/// Transform execution failed.
#[error("transform failed: {0}")]
Transform(#[source] super::types::TransformError),
}
impl ReferenceProcessingError {
fn into_policy_violation(self) -> Result<crate::policy::PolicyViolation, Self> {
match self {
Self::Policy(error)
| Self::UriDereference(TransformError::Policy(error))
| Self::Transform(TransformError::Policy(error)) => Ok(error),
error => Err(error),
}
}
}
/// End-to-end XMLDSig verification result for one `<Signature>`.
#[derive(Debug)]
#[non_exhaustive]
#[must_use = "inspect status before accepting the document"]
pub struct VerifyResult {
/// Key authorization, independent of `status`; neither alone authorizes data.
pub key_trust: super::KeyTrustEvidence,
/// Core XMLDSig status for the `<SignedInfo>` references and signature value.
///
/// Manifest reference failures do not alter this field; inspect
/// [`Self::manifest_references`] before accepting Manifest-backed data.
pub status: DsigStatus,
/// `<Reference>` verification results from `<SignedInfo>`.
/// On fail-fast, this includes references up to and including
/// the first digest mismatch only.
pub signed_info_references: Vec<ReferenceResult>,
/// `<Manifest>` reference results.
/// Populated only when `VerifyContext::process_manifests(true)` is enabled
/// and core signature validation succeeds.
/// Includes only references from signed direct-child `<ds:Object>/<ds:Manifest>`
/// blocks that are referenced from `<SignedInfo>`.
/// Each entry has an independent status that does not alter [`Self::status`].
/// Callers must inspect every entry before accepting Manifest-backed data.
/// Unsigned/unreferenced direct-child Manifest blocks are skipped, so an
/// empty list does not imply that no Manifest elements existed in `verify()` input.
pub manifest_references: Vec<ReferenceResult>,
/// Canonicalized `<SignedInfo>` bytes when `store_pre_digest` is enabled
/// and verification reaches SignedInfo canonicalization.
pub canonicalized_signed_info: Option<Vec<u8>>,
}
/// Errors while running end-to-end XMLDSig verification.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum DsigError {
/// Key establishment failed before the consuming MAC.
#[error(transparent)]
KeyEstablishment(#[from] crate::key_establishment::KeyEstablishmentError),
/// Caller-supplied request identities or requirements are inconsistent.
#[error("invalid verification request: {reason}")]
InvalidRequest {
/// Concrete invariant violated before signature execution.
reason: &'static str,
},
/// 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] crate::xml::dom::ParseError),
/// The owned XML document boundary rejected the document or identity.
#[error("XML document error: {0}")]
Document(#[from] crate::document::XmlDocumentError),
/// Required signature element is missing.
#[error("missing required element: <{element}>")]
MissingElement {
/// Name of the missing element.
element: &'static str,
},
/// Signature element tree shape violates XMLDSig structure requirements.
#[error("invalid Signature structure: {reason}")]
InvalidStructure {
/// Validation failure reason.
reason: &'static str,
},
/// The authenticated dependency graph could not be compiled or executed.
#[error("invalid operation plan: {0}")]
OperationPlan(String),
/// The requested operation start node is absent or has a duplicate ID.
#[error("selected node ID is missing or ambiguous: {id}")]
SelectedNodeUnavailable {
/// Caller-provided XML ID value.
id: String,
},
/// `<SignedInfo>` parsing failed.
#[error("failed to parse SignedInfo: {0}")]
ParseSignedInfo(super::parse::ParseError),
/// `<KeyInfo>` parsing failed.
#[error("failed to parse KeyInfo: {0}")]
ParseKeyInfo(#[source] super::parse::ParseError),
/// Configuration-driven key resolution failed.
#[error("key resolution failed: {0}")]
KeyResolution(#[from] super::keys::KeyResolutionError),
/// `<Object>/<Manifest>/<Reference>` parsing failed.
#[error("failed to parse Manifest reference: {0}")]
ParseManifestReference(#[source] ParseError),
/// Reference processing failed.
#[error("reference processing failed: {0}")]
Reference(ReferenceProcessingError),
/// SignedInfo canonicalization failed.
#[error("SignedInfo canonicalization failed: {0}")]
Canonicalization(#[from] crate::c14n::C14nError),
/// SignatureValue base64 decoding failed.
#[error("invalid SignatureValue base64: {0}")]
SignatureValueBase64(#[from] base64::DecodeError),
/// Cryptographic verification failed before validity decision.
#[error("signature verification failed: {0}")]
Crypto(#[from] SignatureVerificationError),
}
impl From<super::parse::ParseError> for DsigError {
fn from(error: super::parse::ParseError) -> Self {
match error {
super::parse::ParseError::Policy(error) => Self::Policy(error),
super::parse::ParseError::Transform(super::TransformError::Policy(error)) => {
Self::Policy(error)
}
error => Self::ParseSignedInfo(error),
}
}
}
fn map_key_info_parse_error(error: super::parse::ParseError) -> DsigError {
match error {
super::parse::ParseError::Policy(error)
| super::parse::ParseError::Transform(super::TransformError::Policy(error)) => {
DsigError::Policy(error)
}
error => DsigError::ParseKeyInfo(error),
}
}
fn map_manifest_parse_error(error: super::parse::ParseError) -> DsigError {
match error {
super::parse::ParseError::Policy(error)
| super::parse::ParseError::Transform(super::TransformError::Policy(error)) => {
DsigError::Policy(error)
}
error => DsigError::ParseManifestReference(error),
}
}
impl From<ReferenceProcessingError> for DsigError {
fn from(error: ReferenceProcessingError) -> Self {
match error.into_policy_violation() {
Ok(error) => Self::Policy(error),
Err(error) => Self::Reference(error),
}
}
}
type SignatureVerificationPipelineError = DsigError;
impl From<OperationPlanError> for DsigError {
fn from(error: OperationPlanError) -> Self {
Self::OperationPlan(error.to_string())
}
}
/// Verify one XMLDSig `<Signature>` end-to-end with a PEM public key.
///
/// Pipeline:
/// 1. Parse `<Signature>` children and enforce structural constraints
/// 2. Parse `<SignedInfo>`
/// 3. Validate all `<Reference>` digests (fail-fast)
/// 4. Canonicalize `<SignedInfo>`
/// 5. Base64-decode `<SignatureValue>`
/// 6. Verify signature bytes against canonicalized `<SignedInfo>` using the provided PEM key
///
/// If any `<Reference>` digest mismatches, returns `Ok` with
/// `status == Invalid(ReferenceDigestMismatch { .. })`.
///
/// This API uses only the provided PEM key and does not parse embedded
/// `<KeyInfo>` key material for key selection/validation. Consequently,
/// malformed optional `<KeyInfo>` does not produce `DsigError::ParseKeyInfo`
/// on this API path.
///
/// Structural constraints enforced by this API:
/// - The document must contain exactly one XMLDSig `<Signature>` element.
/// - `<SignedInfo>` must be the first element child of `<Signature>` and appear once.
/// - `<SignatureValue>` must be the second element child of `<Signature>` and appear once.
/// - `<KeyInfo>` is optional and, when present, must be the third element child.
/// - Only XMLDSig namespace element children are allowed under `<Signature>`.
/// - Non-whitespace mixed text content under `<Signature>` is rejected.
/// - After `<SignedInfo>`, `<SignatureValue>`, and optional `<KeyInfo>`, only `<Object>` elements are allowed.
/// - `<SignatureValue>` must not contain nested element children.
pub fn verify_signature_with_pem_key(
xml: &str,
public_key_pem: &str,
store_pre_digest: bool,
) -> Result<VerifyResult, DsigError> {
struct PemVerifyingKey<'a> {
public_key_pem: &'a str,
}
impl VerifyingKey for PemVerifyingKey<'_> {
fn verify_with_context(
&self,
algorithm: SignatureAlgorithm,
context: &super::SignatureContext,
signed_data: &[u8],
signature_value: &[u8],
) -> Result<bool, DsigError> {
if context.as_bytes().is_empty() {
return self.verify(algorithm, signed_data, signature_value);
}
if algorithm.context_element().is_none() {
return Err(SignatureVerificationError::UnsupportedAlgorithm {
uri: algorithm.uri().to_owned(),
}
.into());
}
let (rest, pem) = x509_parser::pem::parse_x509_pem(self.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(super::modern::verify_with_context(
algorithm,
&pem.contents,
context,
signed_data,
signature_value,
)?)
}
fn verify(
&self,
algorithm: SignatureAlgorithm,
signed_data: &[u8],
signature_value: &[u8],
) -> Result<bool, DsigError> {
verify_with_algorithm(algorithm, self.public_key_pem, signed_data, signature_value)
}
}
let key = PemVerifyingKey { public_key_pem };
VerifyContext::new()
.key(&key)
.store_pre_digest(store_pre_digest)
.verify(xml)
}
fn verify_signature_with_context(
xml: &str,
ctx: &VerifyContext<'_>,
) -> Result<VerifyResult, SignatureVerificationPipelineError> {
ctx.policy.validate()?;
ctx.policy.resources.validate_xml_document_len(xml.len())?;
let execution_budget = TransformExecutionBudget::from_resources(&ctx.policy.resources)
.with_xml_backend(ctx.xml_backend);
let settings = DocumentParseSettings::from_policy(&ctx.policy.xml, &ctx.policy.resources)
.with_backend(ctx.xml_backend);
let document = XmlDocument::parse_with_settings_and_budget(
xml.to_owned(),
settings,
execution_budget.xml_parse_work(),
)
.map_err(|error| map_document_parse_error(error, settings))?;
verify_signature_document_with_context_and_transforms(&document, ctx, execution_budget)
}
fn map_document_parse_error(error: XmlDocumentError, settings: DocumentParseSettings) -> DsigError {
match error.into_policy_violation(settings) {
Ok(error) => DsigError::Policy(error),
Err(XmlDocumentError::Parse(error)) => DsigError::XmlParse(error),
Err(error) => DsigError::Document(error),
}
}
#[cfg(test)]
mod xml_parse_budget_tests {
use super::*;
#[test]
fn verification_initial_parse_uses_the_policy_work_budget() {
// Even a structurally invalid signature must not reach parsing when
// the immutable operation snapshot denies all XML parse work.
let xml = "<root/>";
let mut policy = crate::policy::VerificationPolicy::default();
policy.resources.max_xml_parse_work_bytes = 0;
let error = VerifyContext::new()
.policy(policy)
.verify(xml)
.expect_err("a zero parse-work budget must reject the input parse");
assert!(matches!(
error,
DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::XML_PARSE_WORK_BYTES,
maximum: 0,
actual,
}) if actual == xml.len()
));
}
}
fn verify_signature_document_with_context(
document: &XmlDocument,
ctx: &VerifyContext<'_>,
) -> Result<VerifyResult, SignatureVerificationPipelineError> {
document.validate_operation_policy(&ctx.policy.xml, &ctx.policy.resources)?;
verify_signature_document_with_context_and_transforms(
document,
ctx,
TransformExecutionBudget::from_resources(&ctx.policy.resources)
.with_xml_backend(ctx.xml_backend),
)
}
fn count_signature_nodes(view: DocumentView<'_>, maximum: usize) -> Result<usize, DsigError> {
let mut count = 0;
for node in view.document().descendants() {
if !node.has_tag_name((XMLDSIG_NS, "Signature")) {
continue;
}
if count == maximum {
return Err(crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::VERIFICATION_SIGNATURES,
maximum,
actual: count + 1,
}
.into());
}
count += 1;
}
Ok(count)
}
fn verify_all_document(
document: &XmlDocument,
ctx: &VerifyContext<'_>,
request: &VerificationRequest<'_>,
) -> Result<VerifyEvidence, DsigError> {
ctx.policy.validate()?;
document.validate_operation_policy(&ctx.policy.xml, &ctx.policy.resources)?;
if request.external_resources.is_some() && ctx.external_resources.is_some() {
return Err(DsigError::InvalidRequest {
reason: "external resources configured in both context and request",
});
}
// RFC 5280 §6.1.1: validation time is one operation input, including
// alternate certification paths and all Signatures in a batch.
// https://www.rfc-editor.org/rfc/rfc5280#section-6.1.1
let capture_time =
ctx.policy.key_trust.verify_x509_chains && ctx.policy.key_trust.verification_time.is_none();
let captured_context;
let ctx = if capture_time || request.external_resources.is_some() {
captured_context = {
let mut captured = ctx.clone();
if let Some(resources) = request.external_resources {
captured.external_resources = Some(resources);
}
if capture_time {
captured.policy.key_trust.verification_time = Some(std::time::SystemTime::now());
}
captured
};
&captured_context
} else {
ctx
};
let budgets = VerificationOperationBudgets::with_transforms(
&ctx.policy,
TransformExecutionBudget::from_resources(&ctx.policy.resources)
.with_xml_backend(ctx.xml_backend),
);
document.with_view(|view| {
match request.signatures {
SignatureRequirement::AtLeast(0) | SignatureRequirement::Exactly(0) => {
return Err(DsigError::InvalidRequest {
reason: "signature requirement must be nonzero",
});
}
_ => {}
}
if request.expected_targets.len() > ctx.policy.resources.max_references {
return Err(DsigError::InvalidRequest {
reason: "too many expected coverage targets",
});
}
if request.trusted_keys.len() > ctx.policy.resources.max_signatures {
return Err(DsigError::InvalidRequest {
reason: "too many trusted signature keys",
});
}
for (index, target) in request.expected_targets.iter().copied().enumerate() {
if !view.resolve_node(target)?.is_element() {
return Err(DsigError::InvalidRequest {
reason: "coverage target must be an element",
});
}
if request.expected_targets[..index].contains(&target) {
return Err(DsigError::InvalidRequest {
reason: "duplicate expected coverage target",
});
}
}
for (index, entry) in request.trusted_keys.iter().enumerate() {
if !view
.resolve_node(entry.signature)?
.has_tag_name((XMLDSIG_NS, "Signature"))
{
return Err(DsigError::InvalidRequest {
reason: "trusted key target must be a Signature",
});
}
if request.trusted_keys[..index]
.iter()
.any(|prior| prior.signature == entry.signature)
{
return Err(DsigError::InvalidRequest {
reason: "ambiguous trusted keys for one Signature",
});
}
}
let is_signature = |node: &Node<'_, '_>| node.has_tag_name((XMLDSIG_NS, "Signature"));
let count = count_signature_nodes(view, ctx.policy.resources.max_signatures)?;
// Cardinality is checked before allocating the report or calling a
// resolver. No Vec of backend handles or cloned XML is needed.
let mut signatures = Vec::with_capacity(count);
let resolver = UriReferenceResolver::with_document_view(view, ctx.id_attributes)
.with_same_document_id_semantics(ctx.policy.transforms.same_document_id_semantics)
.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) => resolver.with_external_resources(resources),
None => resolver,
};
for signature in view.document().descendants().filter(is_signature) {
// Authentication is signature-local; work accounting is request-
// wide. A valid first signature cannot authenticate a Manifest
// belonging to an invalid second signature.
let mut operation = OperationExecutionContext::new(
&ctx.policy,
&budgets,
Some((view.identity(), view.generation())),
);
let signature_identity = view.node_identity(signature);
let key = request
.trusted_keys
.iter()
.find(|entry| entry.signature == signature_identity)
.map(|entry| entry.key)
.or(ctx.key);
let result =
verify_signature_node(view, ctx, key, &mut operation, &resolver, signature);
signatures.push(SignatureEvidence {
identity: view.node_identity(signature),
result,
});
}
let mut evidence = VerifyEvidence {
document: view.root(),
signatures,
accepted: false,
correlation: request.correlation,
};
let valid = evidence
.signatures
.iter()
.filter(|entry| {
entry.result.as_ref().is_ok_and(|result| {
result.status == DsigStatus::Valid
&& result
.manifest_references
.iter()
.all(|reference| reference.status == DsigStatus::Valid)
})
})
.count();
evidence.accepted = match request.signatures {
SignatureRequirement::All => valid > 0 && valid == count,
SignatureRequirement::AtLeast(minimum) => valid >= minimum,
SignatureRequirement::Exactly(required) => valid == required && count == required,
};
for target in request.expected_targets {
if !evidence.covers_element(document, *target)? {
evidence.accepted = false;
break;
}
}
Ok(evidence)
})
}
fn verify_signature_document_with_context_and_transforms(
document: &XmlDocument,
ctx: &VerifyContext<'_>,
transforms: TransformExecutionBudget,
) -> Result<VerifyResult, SignatureVerificationPipelineError> {
// RFC 5280 §6.1.1 makes the validation time an input to path validation.
// Capture it once, not separately for each source or alternate path:
// https://www.rfc-editor.org/rfc/rfc5280#section-6.1.1
let captured_context;
let ctx = if ctx.policy.key_trust.verify_x509_chains
&& ctx.policy.key_trust.verification_time.is_none()
{
captured_context = {
let mut context = ctx.clone();
context.policy.key_trust.verification_time = Some(std::time::SystemTime::now());
context
};
&captured_context
} else {
ctx
};
let budgets = VerificationOperationBudgets::with_transforms(&ctx.policy, transforms);
let mut operation = OperationExecutionContext::new(
&ctx.policy,
&budgets,
Some((document.identity(), document.generation())),
);
document.with_view(|view| verify_signature_view(view, ctx, &mut operation))
}
fn verify_signature_view<'a>(
view: DocumentView<'a>,
ctx: &VerifyContext<'_>,
operation: &mut OperationExecutionContext<
&crate::policy::VerificationPolicy,
&VerificationOperationBudgets,
>,
) -> Result<VerifyResult, SignatureVerificationPipelineError> {
ctx.policy.validate()?;
let doc = view.document();
let resolver = UriReferenceResolver::with_document_view(view, ctx.id_attributes)
.with_same_document_id_semantics(ctx.policy.transforms.same_document_id_semantics)
.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) => resolver.with_external_resources(resources),
None => resolver,
};
let start_node = match ctx.signature_selection {
SignatureSelection::FirstSignatureUnderId(id) => {
resolver.node_for_id(id).ok_or_else(|| {
SignatureVerificationPipelineError::SelectedNodeUnavailable { id: id.to_owned() }
})?
}
SignatureSelection::UniqueDocumentSignature
| SignatureSelection::FirstDocumentSignature => doc.root(),
};
let mut signatures = start_node.descendants().filter(|node| {
node.is_element()
&& node.tag_name().name() == "Signature"
&& node.tag_name().namespace() == Some(XMLDSIG_NS)
});
let signature_node = match (signatures.next(), ctx.signature_selection) {
(None, _) => {
return Err(SignatureVerificationPipelineError::MissingElement {
element: "Signature",
});
}
// libxmlsec1 treats --node-id as an operation start node and performs
// a depth-first xmlSecFindNode lookup from there. Without a selector,
// the library API retains its fail-closed document-wide cardinality.
(
Some(node),
SignatureSelection::FirstDocumentSignature
| SignatureSelection::FirstSignatureUnderId(_),
) => node,
(Some(node), SignatureSelection::UniqueDocumentSignature)
if signatures.next().is_none() =>
{
node
}
(Some(_), SignatureSelection::UniqueDocumentSignature) => {
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "Signature must appear exactly once in document",
});
}
};
verify_signature_node(view, ctx, ctx.key, operation, &resolver, signature_node)
}
fn verify_signature_node<'a>(
view: DocumentView<'a>,
ctx: &VerifyContext<'_>,
key: Option<&dyn VerifyingKey>,
operation: &mut OperationExecutionContext<
&crate::policy::VerificationPolicy,
&VerificationOperationBudgets,
>,
resolver: &UriReferenceResolver<'a>,
signature_node: Node<'a, 'a>,
) -> Result<VerifyResult, DsigError> {
if ctx.policy.resources.max_signatures == 0 {
return Err(crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::VERIFICATION_SIGNATURES,
maximum: 0,
actual: 1,
}
.into());
}
let doc = view.document();
let signature_children = parse_signature_children(signature_node)?;
if ctx.decapsulation_key.is_some() {
// Public-key encapsulation establishes a recipient secret, not sender
// identity. Reject an impossible trust requirement before key work.
if ctx.policy.key_trust.mode != crate::policy::VerificationTrustMode::CryptographicOnly {
return Err(crate::policy::PolicyViolation::KeyTrust {
reason: "KEM/HMAC does not authenticate sender identity",
}
.into());
}
}
let signed_info_node = signature_children.signed_info_node;
let should_parse_key_info = ctx.decapsulation_key.is_none()
&& match (key, ctx.key_resolver) {
(Some(_), _) => false,
(None, Some(resolver)) => resolver.consumes_document_key_info(),
(None, None) => true,
};
let mut key_info = if should_parse_key_info {
signature_children
.key_info_node
.map(|node| {
super::parse::validate_x509_digest_policy(node, &|algorithm| {
ctx.policy.check_digest_algorithm(algorithm)
})?;
parse_key_info_with_policy_budgets(
node,
ctx.provider,
operation.budgets().transforms.xml_base_resolution(),
&ctx.policy.resources,
ctx.policy
.key_trust
.certificate_signature_algorithms
.as_ref(),
)
})
.transpose()
.map_err(map_key_info_parse_error)?
} else {
None
};
let signed_info = parse_signed_info_with_xpath_budget(
signed_info_node,
&mut operation.budgets().xpath_parse.borrow_mut(),
&ctx.policy.resources,
)?;
if signed_info.references.len() > ctx.policy.resources.max_references {
return Err(crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::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: crate::policy::resource_name::REFERENCE_TRANSFORMS,
maximum: ctx.policy.resources.max_transforms_per_reference,
actual: reference.transforms.len(),
}
.into());
}
}
ctx.policy
.check_signature_algorithm(signed_info.signature_method)?;
// Refuse an inadmissible authenticator before reference resolution/transforms.
// XMLDSig 1.1 section 4.4.2 explicitly requires max(80, hash_bits/2),
// independently of data; section 6.3.1 separately requires octet alignment.
// https://www.w3.org/TR/2013/REC-xmldsig-core1-20130411/#sec-SignatureMethod
if let Some(full_output_bits) = signed_info.signature_method.hmac_output_bits() {
ctx.policy.hmac.validate_output(
signed_info.signature_method,
signed_info
.hmac_output_length_bits
.unwrap_or(full_output_bits),
)?;
}
for reference in &signed_info.references {
ctx.policy.check_digest_algorithm(reference.digest_method)?;
super::transforms::validate_relationship_chain(
&reference.transforms,
ctx.policy.transforms.opc_relationship_edition,
)
.map_err(ReferenceProcessingError::Transform)?;
}
enforce_reference_policies(
&signed_info.references,
ctx.policy.uris.references,
ctx.allowed_transform_uris(),
)?;
enforce_transform_allowed(ctx.allowed_transform_uris(), signed_info.c14n_method.uri())?;
if let Some(resources) = ctx.external_resources {
validate_external_resource_map(
resources,
ctx.policy.resources.max_external_resource_bytes,
ctx.policy.resources.max_external_resource_total_bytes,
)
.map_err(|error| match error {
ExternalResourceMapError::Policy(error) => error.into(),
ExternalResourceMapError::TotalLengthOverflow => {
SignatureVerificationPipelineError::InvalidStructure {
reason: "external resource total length overflow",
}
}
})?;
}
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",
})?;
let plan_nodes = compile_verification_operation_plan(
operation,
view,
signature_node,
&signed_info.references,
resolver,
)?;
let signature_identity =
OperationResourceIdentity::DocumentNode(view.node_identity(signature_node));
operation.run_with_resource(plan_nodes.document, &signature_identity, || {
operation
.validate_document_view(view)
.map_err(map_verification_plan_error)
})?;
let observed_key_resources = operation.budgets().external_resource_set_identity(resolver);
let retrieval_materialization = operation.run_with_resource(
plan_nodes.key_materialization,
&observed_key_resources,
|| {
let budgets = operation.budgets();
let mut materialization = budgets.key_info_materialization.borrow_mut();
materialization.collect_encapsulation = true;
materialization.encapsulation = None;
if let Some(node) = signature_children.key_info_node {
// A pinned key ignores lookup hints, not mechanism instructions.
// Apply the same bounded reference discovery before key work.
if ctx.decapsulation_key.is_some() {
discover_encapsulation_with_budget(
node,
resolver,
&ctx.policy.resources,
&budgets.transforms,
Some((&ctx.policy, ctx.xml_backend)),
Some(&mut materialization),
)?;
} else if !should_parse_key_info {
if has_encapsulation_with_budget(
node,
resolver,
&ctx.policy.resources,
&budgets.transforms,
Some((&ctx.policy, ctx.xml_backend)),
)? {
return Err(crate::key_establishment::KeyEstablishmentError::Structure(
"mechanism and explicit recipient key must be supplied together",
)
.into());
}
} else {
materialization.observe_encapsulation(node, resolver, 0)?;
}
}
if let Some(info) = key_info.as_mut() {
let mut xpath_parse = budgets.xpath_parse.borrow_mut();
let mut retrieval_budgets = RetrievalMaterializationBudgets {
xpath_parse: &mut xpath_parse,
execution: &budgets.transforms,
resources: &ctx.policy.resources,
certificate_signature_algorithms: ctx
.policy
.key_trust
.certificate_signature_algorithms
.as_ref(),
xml_backend: ctx.xml_backend,
};
let mut outcome = materialize_key_info_references_with_budgets(
info,
resolver,
&ctx.policy,
ctx.provider,
&mut retrieval_budgets,
&mut materialization,
)?;
outcome.merge(materialize_retrieval_methods_with_budgets(
info,
resolver,
&ctx.policy,
ctx.provider,
&mut retrieval_budgets,
&mut materialization.candidate_work,
)?);
Ok::<_, SignatureVerificationPipelineError>(outcome)
} else {
Ok(RetrievalMaterialization::default())
}
},
)?;
let encapsulation = operation
.budgets()
.key_info_materialization
.borrow_mut()
.encapsulation
.take();
if encapsulation.is_some() != ctx.decapsulation_key.is_some() {
return Err(crate::key_establishment::KeyEstablishmentError::Structure(
"mechanism and explicit recipient key must be supplied together",
)
.into());
}
if let Some(mechanism) = &encapsulation {
ctx.policy
.key_establishment
.check_encapsulation(mechanism.algorithm)?;
if ctx
.decapsulation_key
.is_some_and(|key| key.algorithm() != mechanism.algorithm)
{
return Err(crate::key_establishment::KeyEstablishmentError::Structure(
"recipient parameter set mismatch",
)
.into());
}
}
let mut reference_results = Vec::with_capacity(signed_info.references.len());
let mut first_failure = None;
for (index, (node, reference)) in plan_nodes
.digests
.iter()
.copied()
.zip(&signed_info.references)
.enumerate()
{
let observed = operation.budgets().resource_identity_for_reference(
reference,
index,
resolver,
view,
reference_origin_node(signature_node, ReferenceSet::SignedInfo, index),
);
let mut result = operation.run_with_resource(node, &observed, || {
let budgets = operation.budgets();
let execution = ReferenceExecutionContext {
store_pre_digest: ctx.store_pre_digest,
transform_options: ctx.transform_options(),
transform_budget: &budgets.transforms,
canonicalized_data_budget: &budgets.canonicalized,
provider: ctx.provider,
};
process_reference_with_options(
reference,
resolver,
signature_node,
ReferenceSet::SignedInfo,
index,
reference_origin_node(signature_node, ReferenceSet::SignedInfo, index),
&execution,
)
.map_err(SignatureVerificationPipelineError::from)
})?;
bind_reference_evidence(
&mut result,
reference,
view,
reference_origin_node(signature_node, ReferenceSet::SignedInfo, index),
&observed,
);
let accepted = result.status == DsigStatus::Valid;
operation.set_outcome(
node,
accepted,
if accepted {
OperationDecisionReason::ReferenceDigestVerified
} else {
OperationDecisionReason::ReferenceDigestRejected
},
);
if result.status == DsigStatus::Valid
&& let Some(reference) = signed_info.references.get(result.reference_index)
&& reference
.transforms
.iter()
.all(transform_preserves_manifest_structure)
&& let Some(uri) = reference.uri.as_deref()
&& let Ok(Some(target)) = resolver.node_id_for_same_document_reference(uri)
{
let identity = view.node_identity_by_id(target);
operation.authenticate(identity);
debug_assert!(operation.is_authenticated(identity));
}
reference_results.push(result);
if !accepted {
first_failure = Some(index);
break;
}
}
debug_assert!(
plan_nodes.digests[reference_results.len()..]
.iter()
.all(|node| !operation.is_executed(*node))
);
let references = ReferencesResult {
results: reference_results,
first_failure,
};
if let Some(first_failure) = references.first_failure {
debug_assert!(operation.first_failure().is_some());
let status = references.results[first_failure].status;
return Ok(VerifyResult {
key_trust: super::KeyTrustEvidence::NotEvaluated,
status,
signed_info_references: references.results,
manifest_references: Vec::new(),
canonicalized_signed_info: None,
});
}
let canonical_signed_info = operation.run(plan_nodes.canonicalization, || {
let signed_info_subtree: HashSet<_> = signed_info_node
.descendants()
.map(|node: Node<'_, '_>| node.id())
.collect();
let mut canonical_signed_info = Vec::new();
let signed_info_limit = operation
.budgets()
.canonicalized
.remaining()
.min(operation.budgets().transforms.remaining_c14n_output());
canonicalize_bounded_with_xml_base_budget(
doc,
Some(&|node| signed_info_subtree.contains(&node.id())),
&signed_info.c14n_method,
signed_info_limit,
operation.budgets().transforms.xml_base_resolution(),
&mut canonical_signed_info,
)
.map_err(|error| {
if let Some(violation) = map_c14n_resource_policy_violation(
&error,
crate::policy::resource_name::CANONICALIZED_BYTES,
operation.budgets().canonicalized.max_bytes,
) {
SignatureVerificationPipelineError::Policy(violation)
} else {
SignatureVerificationPipelineError::Canonicalization(error)
}
})?;
operation
.budgets()
.transforms
.charge_c14n_output(canonical_signed_info.len())
.map_err(ReferenceProcessingError::Transform)?;
operation
.budgets()
.canonicalized
.charge(canonical_signed_info.len())?;
Ok::<_, SignatureVerificationPipelineError>(canonical_signed_info)
})?;
let signature_value = decode_signature_value(
signature_children.signature_value_node,
signed_info.signature_method,
&ctx.policy.resources,
)?;
if let Some(full_output_bits) = signed_info.signature_method.hmac_output_bits() {
let expected_bits = signed_info
.hmac_output_length_bits
.unwrap_or(full_output_bits);
if signature_value.len() != expected_bits / 8 {
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "SignatureValue length does not match HMACOutputLength",
});
}
}
let resolved_key = operation.run(plan_nodes.key, || {
if let Some(recipient) = ctx.decapsulation_key {
if key.is_some() || ctx.key_resolver.is_some() {
return Err(crate::key_establishment::KeyEstablishmentError::Structure(
"ambiguous verification key request",
)
.into());
}
require_verifying_key_candidate_capacity(&ctx.policy)?;
if !ctx.policy.key_sources.preset_key
|| signed_info.signature_method.hmac_output_bits().is_none()
{
return Err(crate::key_establishment::KeyEstablishmentError::Structure(
"encapsulation requires permitted explicit key and HMAC",
)
.into());
}
ctx.policy.hmac.validate_key_bits(256)?;
let mechanism = encapsulation.as_ref().ok_or(
crate::key_establishment::KeyEstablishmentError::Structure("missing mechanism"),
)?;
operation
.budgets()
.key_establishment
.borrow_mut()
.commit_all(&ctx.policy.key_establishment, 0, 0, 32)?;
let secret = operation
.budgets()
.key_establishment
.borrow_mut()
.decapsulate(
&ctx.policy.key_establishment,
ctx.provider,
recipient,
mechanism.algorithm,
&mechanism.ciphertext[..mechanism.algorithm.ciphertext_len()],
)?;
let hmac = super::HmacVerificationKey::new(secret.to_vec())?;
return Ok(Some(ResolvedVerifyingKey::Established(hmac)));
}
resolve_verifying_key(ctx, key, key_info.as_ref(), signed_info.signature_method)
})?;
let Some(resolved_key) = resolved_key else {
operation.set_outcome(
plan_nodes.key,
false,
OperationDecisionReason::KeyUnavailable,
);
if let Some(error) = retrieval_materialization.deferred_error {
return Err(error);
}
return Ok(VerifyResult {
key_trust: super::KeyTrustEvidence::NotEvaluated,
status: DsigStatus::Invalid(FailureReason::KeyNotFound),
signed_info_references: references.results,
manifest_references: Vec::new(),
canonicalized_signed_info: if ctx.store_pre_digest {
Some(canonical_signed_info)
} else {
None
},
});
};
operation.set_outcome(plan_nodes.key, true, OperationDecisionReason::KeyResolved);
let verifier = resolved_key.as_ref();
let signature_valid = operation.run(plan_nodes.crypto, || {
verifier.validate_policy(&ctx.policy)?;
if !verifier.validate_signature_value_with_policy(
&ctx.policy,
signed_info.signature_method,
&signature_value,
)? {
return Ok::<_, SignatureVerificationPipelineError>(false);
}
ctx.provider
.require_capability(crate::provider::ProviderCapability::Verify(
signed_info.signature_method,
))?;
let policy_verifier = PolicyVerifyingKey {
key: verifier,
policy: &ctx.policy,
};
ctx.provider.verify_with_context(
&policy_verifier,
signed_info.signature_method,
&signed_info.signature_context,
&canonical_signed_info,
&signature_value,
)
})?;
if !signature_valid {
operation.set_outcome(
plan_nodes.crypto,
false,
OperationDecisionReason::SignatureRejected,
);
return Ok(VerifyResult {
key_trust: resolved_key.evidence(&ctx.policy)?,
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
},
});
}
operation.set_outcome(
plan_nodes.crypto,
true,
OperationDecisionReason::SignatureVerified,
);
// Manifest syntax and references are authenticated content. Delay parsing
// until SignatureValue succeeds, then extend the same operation graph with
// the newly trusted dependency subgraph and its evidence node.
let (manifest_references, manifest_digests) =
if ctx.policy.manifest_processing == crate::policy::ManifestProcessing::Process {
process_authenticated_manifest_references(
operation,
view,
signature_node,
resolver,
ctx,
remaining_reference_capacity,
plan_nodes.crypto,
)?
} else {
(Vec::new(), Vec::new())
};
operation.extend();
let evidence = operation.add_node(OperationNodeKind::Evidence, OperationStage::Evidence, None);
operation
.add_dependency(evidence, plan_nodes.crypto)
.map_err(map_verification_plan_error)?;
for digest in manifest_digests {
operation
.add_dependency(evidence, digest)
.map_err(map_verification_plan_error)?;
}
operation.compile().map_err(map_verification_plan_error)?;
operation.run(evidence, || {
Ok::<_, SignatureVerificationPipelineError>(VerifyResult {
key_trust: resolved_key.evidence(&ctx.policy)?,
status: DsigStatus::Valid,
signed_info_references: references.results,
manifest_references,
canonicalized_signed_info: if ctx.store_pre_digest {
Some(canonical_signed_info)
} else {
None
},
})
})
}
struct PolicyVerifyingKey<'a> {
key: &'a dyn VerifyingKey,
policy: &'a crate::policy::VerificationPolicy,
}
impl VerifyingKey for PolicyVerifyingKey<'_> {
fn provider_binding(&self) -> Option<&crate::provider::ProviderBinding> {
self.key.provider_binding()
}
fn verify_candidate_keys(
&self,
verify: &mut dyn FnMut(&dyn VerifyingKey) -> Result<bool, DsigError>,
) -> Result<Option<bool>, DsigError> {
self.key.verify_candidate_keys(&mut |key| {
key.validate_policy(self.policy)?;
verify(&PolicyVerifyingKey {
key,
policy: self.policy,
})
})
}
fn verification_spki(&self, algorithm: SignatureAlgorithm) -> Result<Option<&[u8]>, DsigError> {
self.key.verification_spki(algorithm)
}
fn ecdsa_encoding(&self) -> crate::policy::EcdsaSignatureValueEncoding {
self.policy.ecdsa_signature_value_encoding
}
fn verify_with_context(
&self,
algorithm: SignatureAlgorithm,
context: &super::SignatureContext,
data: &[u8],
signature: &[u8],
) -> Result<bool, DsigError> {
if context.as_bytes().is_empty() {
self.verify(algorithm, data, signature)
} else {
self.key
.verify_with_context(algorithm, context, data, signature)
}
}
fn validate_policy(&self, policy: &crate::policy::VerificationPolicy) -> Result<(), DsigError> {
self.key.validate_policy(policy)
}
fn validate_signature_value(
&self,
algorithm: SignatureAlgorithm,
signature_value: &[u8],
) -> Result<bool, DsigError> {
self.key
.validate_signature_value_with_policy(self.policy, algorithm, signature_value)
}
fn verify(
&self,
algorithm: SignatureAlgorithm,
signed_data: &[u8],
signature_value: &[u8],
) -> Result<bool, DsigError> {
self.key
.verify_with_policy(self.policy, algorithm, signed_data, signature_value)
}
}
#[derive(Debug, Default)]
struct RetrievalMaterialization {
deferred_error: Option<SignatureVerificationPipelineError>,
}
impl RetrievalMaterialization {
fn merge(&mut self, other: Self) {
if self.deferred_error.is_none() {
self.deferred_error = other.deferred_error;
}
}
}
struct RetrievalMaterializationBudgets<'a> {
certificate_signature_algorithms: Option<&'a crate::policy::CertificateSignatureAlgorithms>,
xpath_parse: &'a mut XPathSignatureParseBudget,
execution: &'a TransformExecutionBudget,
resources: &'a crate::policy::ResourcePolicy,
xml_backend: crate::XmlBackend,
}
#[derive(Default)]
struct KeyInfoMaterializationState {
active: HashSet<(super::uri::TraversalDocumentIdentity, String)>,
candidate_work: usize,
collect_encapsulation: bool,
encapsulation_template: bool,
retain_encapsulation_target: bool,
encapsulation_depth: usize,
reference_depth_base: usize,
encapsulation_target: Option<(super::uri::TraversalDocumentIdentity, NodeId, NodeId)>,
encapsulation: Option<ResolvedEncapsulation>,
}
struct ResolvedEncapsulation {
algorithm: crate::provider::KeyEncapsulationAlgorithm,
ciphertext: [u8; 1568],
}
impl KeyInfoMaterializationState {
fn observe_encapsulation(
&mut self,
node: Node<'_, '_>,
resolver: &UriReferenceResolver<'_>,
depth: usize,
) -> Result<(), SignatureVerificationPipelineError> {
if !self.collect_encapsulation {
return Ok(());
}
// XMLDSig 1.1 §4.5.10 references KeyInfo, including its extension
// children. Collect metadata in the gated reference traversal, not a
// separate unbounded scan; external document nodes cannot escape it.
// https://www.w3.org/TR/2013/REC-xmldsig-core1-20130411/#sec-KeyInfoReference
let Some(mechanism) =
crate::key_establishment::direct_encapsulation(node, self.encapsulation_template)
.map_err(|_| SignatureVerificationPipelineError::InvalidStructure {
reason: "invalid encapsulation mechanism in resolved KeyInfo",
})?
else {
return Ok(());
};
self.record_encapsulation(mechanism, resolver, depth)
}
fn record_encapsulation(
&mut self,
mechanism: crate::key_establishment::ParsedEncapsulation<'_, '_>,
resolver: &UriReferenceResolver<'_>,
depth: usize,
) -> Result<(), SignatureVerificationPipelineError> {
if self.encapsulation.is_some() {
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "multiple encapsulation mechanisms in resolved KeyInfo",
});
}
if self.retain_encapsulation_target {
self.encapsulation_depth = depth;
self.encapsulation_target = Some((
resolver.traversal_document_identity(),
mechanism.cipher_value.id(),
mechanism.key_info.id(),
));
}
self.encapsulation = Some(ResolvedEncapsulation {
algorithm: mechanism.algorithm,
ciphertext: mechanism.into_ciphertext(),
});
Ok(())
}
}
trait KeyInfoReferencePolicy {
fn key_establishment(&self) -> &crate::policy::KeyEstablishmentPolicy;
fn check_digest_algorithm(
&self,
algorithm: DigestAlgorithm,
) -> Result<(), crate::policy::PolicyViolation>;
fn certificate_signature_algorithms(
&self,
) -> Option<&crate::policy::CertificateSignatureAlgorithms>;
fn resources(&self) -> &crate::policy::ResourcePolicy;
fn xml(&self) -> &crate::policy::XmlInputPolicy;
fn key_info_reference_uris(&self) -> UriTypeSet;
fn retrieval_method_uris(&self) -> UriTypeSet;
fn key_info_reference_source_enabled(&self) -> bool;
fn allowed_transforms(&self) -> Option<&HashSet<String>>;
}
impl KeyInfoReferencePolicy for crate::policy::SigningPolicy {
fn key_establishment(&self) -> &crate::policy::KeyEstablishmentPolicy {
&self.key_establishment
}
fn check_digest_algorithm(
&self,
algorithm: DigestAlgorithm,
) -> Result<(), crate::policy::PolicyViolation> {
self.check_digest_algorithm(algorithm)
}
fn certificate_signature_algorithms(
&self,
) -> Option<&crate::policy::CertificateSignatureAlgorithms> {
None
}
fn resources(&self) -> &crate::policy::ResourcePolicy {
&self.resources
}
fn xml(&self) -> &crate::policy::XmlInputPolicy {
&self.xml
}
fn key_info_reference_uris(&self) -> UriTypeSet {
self.uris.key_info_references
}
fn retrieval_method_uris(&self) -> UriTypeSet {
self.uris.retrieval_methods
}
fn key_info_reference_source_enabled(&self) -> bool {
true
}
fn allowed_transforms(&self) -> Option<&HashSet<String>> {
self.transforms.allowed_algorithms.as_ref()
}
}
impl KeyInfoReferencePolicy for crate::policy::VerificationPolicy {
fn key_establishment(&self) -> &crate::policy::KeyEstablishmentPolicy {
&self.key_establishment
}
fn check_digest_algorithm(
&self,
algorithm: DigestAlgorithm,
) -> Result<(), crate::policy::PolicyViolation> {
self.check_digest_algorithm(algorithm)
}
fn certificate_signature_algorithms(
&self,
) -> Option<&crate::policy::CertificateSignatureAlgorithms> {
self.key_trust.certificate_signature_algorithms.as_ref()
}
fn resources(&self) -> &crate::policy::ResourcePolicy {
&self.resources
}
fn xml(&self) -> &crate::policy::XmlInputPolicy {
&self.xml
}
fn key_info_reference_uris(&self) -> UriTypeSet {
self.uris.key_info_references
}
fn retrieval_method_uris(&self) -> UriTypeSet {
self.uris.retrieval_methods
}
fn key_info_reference_source_enabled(&self) -> bool {
self.key_sources.key_info_reference
}
fn allowed_transforms(&self) -> Option<&HashSet<String>> {
self.transforms.allowed_algorithms.as_ref()
}
}
struct KeyInfoReferenceMaterializationContext<'a, 'budget, P> {
policy: &'a P,
provider: &'a dyn crate::provider::CryptoProvider,
budgets: &'a mut RetrievalMaterializationBudgets<'budget>,
}
fn materialize_key_info_references_with_budgets<P: KeyInfoReferencePolicy>(
key_info: &mut KeyInfo,
resolver: &UriReferenceResolver<'_>,
policy: &P,
provider: &dyn crate::provider::CryptoProvider,
budgets: &mut RetrievalMaterializationBudgets<'_>,
materialization: &mut KeyInfoMaterializationState,
) -> Result<RetrievalMaterialization, SignatureVerificationPipelineError> {
fn visit<P: KeyInfoReferencePolicy>(
key_info: &mut KeyInfo,
resolver: &UriReferenceResolver<'_>,
context: &mut KeyInfoReferenceMaterializationContext<'_, '_, P>,
materialization: &mut KeyInfoMaterializationState,
depth: usize,
) -> Result<RetrievalMaterialization, SignatureVerificationPipelineError> {
let mut materialized = Vec::new();
let mut outcome = RetrievalMaterialization::default();
for source in std::mem::take(&mut key_info.sources) {
let source_work = match &source {
super::parse::KeyInfoSource::X509Data(info) => info.certificates.len().max(1),
_ => 1,
};
materialization.candidate_work =
materialization.candidate_work.saturating_add(source_work);
context
.policy
.resources()
.validate_key_candidates(materialization.candidate_work)?;
let super::parse::KeyInfoSource::KeyInfoReference { uri } = source else {
materialized.push(source);
continue;
};
if !context.policy.key_info_reference_source_enabled() {
return Err(crate::policy::PolicyViolation::KeyTrust {
reason: "KeyInfoReference key sources are disabled",
}
.into());
}
if !context.policy.key_info_reference_uris().allows(&uri) {
return Err(crate::policy::PolicyViolation::Uri {
operation: "KeyInfoReference",
reason: "URI class is disabled",
}
.into());
}
let next_depth = depth.saturating_add(1);
context
.policy
.resources()
.validate_key_info_reference_depth(next_depth)?;
let cycle_key = (resolver.traversal_document_identity(), uri.clone());
if !materialization.active.insert(cycle_key.clone()) {
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "KeyInfoReference cycle detected",
});
}
let is_same_document = uri.is_empty() || uri.starts_with('#');
let (mut referenced, mut nested_outcome) = if is_same_document {
let node = resolver
.node_for_same_document_reference(&uri)
.map_err(|error| {
SignatureVerificationPipelineError::from(
ReferenceProcessingError::UriDereference(error),
)
})?
.ok_or(SignatureVerificationPipelineError::InvalidStructure {
reason: "KeyInfoReference target is missing or ambiguous",
})?;
if !node.has_tag_name((XMLDSIG_NS, "KeyInfo")) {
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "KeyInfoReference target must be KeyInfo",
});
}
materialization.observe_encapsulation(node, resolver, next_depth)?;
if let Some(mechanism) = &materialization.encapsulation {
context
.policy
.key_establishment()
.check_encapsulation(mechanism.algorithm)?;
}
super::parse::validate_x509_digest_policy(node, &|algorithm| {
context.policy.check_digest_algorithm(algorithm)
})
.map_err(map_key_info_parse_error)?;
(
parse_key_info_with_policy_budgets(
node,
context.provider,
context.budgets.execution.xml_base_resolution(),
context.policy.resources(),
context.policy.certificate_signature_algorithms(),
)
.map_err(map_key_info_parse_error)?,
RetrievalMaterialization::default(),
)
} else {
// The fragment selects a node inside the retrieved document; it
// is not part of either the caller-owned resource identity or
// the base URI used by references nested in that document.
let (resource_uri, fragment) = uri
.split_once('#')
.map_or((uri.as_str(), None), |(resource, fragment)| {
(resource, (!fragment.is_empty()).then_some(fragment))
});
let bytes = resolver
.external_resource(resource_uri)
.map_err(|error| {
SignatureVerificationPipelineError::from(
ReferenceProcessingError::UriDereference(error),
)
})?
.ok_or(SignatureVerificationPipelineError::InvalidStructure {
reason: "KeyInfoReference external resource is unavailable",
})?;
// External KeyInfo bytes share the operation's work allowance even if decoding
// fails before a document exists. Parsing the decoded text is a separate pass.
context
.budgets
.execution
.xml_parse_work()
.charge_policy(bytes.len())?;
let xml = crate::encoding::decode_xml_octets(
bytes,
context.policy.resources().max_xml_document_bytes,
)
.map_err(map_key_info_xml_decode_error)?;
let settings = DocumentParseSettings::from_policy(
context.policy.xml(),
context.policy.resources(),
)
.with_backend(context.budgets.xml_backend);
let document = XmlDocument::parse_with_settings_and_budget(
xml.into_owned(),
settings,
context.budgets.execution.xml_parse_work(),
)
.map_err(|error| map_document_parse_error(error, settings))?;
document.with_view(|view| {
let external_resolver = resolver.for_external_document_view(view, resource_uri);
let target = match fragment {
Some(fragment) => external_resolver
.node_for_same_document_reference(&format!("#{fragment}"))
.map_err(|error| {
SignatureVerificationPipelineError::from(
ReferenceProcessingError::UriDereference(error),
)
})?
.ok_or(SignatureVerificationPipelineError::InvalidStructure {
reason: "KeyInfoReference external target is missing or ambiguous",
})?,
_ => view.document().root_element(),
};
if !target.has_tag_name((XMLDSIG_NS, "KeyInfo")) {
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "KeyInfoReference external target must be KeyInfo",
});
}
materialization.observe_encapsulation(
target,
&external_resolver,
next_depth,
)?;
if let Some(mechanism) = &materialization.encapsulation {
context
.policy
.key_establishment()
.check_encapsulation(mechanism.algorithm)?;
}
super::parse::validate_x509_digest_policy(target, &|algorithm| {
context.policy.check_digest_algorithm(algorithm)
})
.map_err(map_key_info_parse_error)?;
let mut referenced = parse_key_info_with_policy_budgets_and_document_base(
target,
context.provider,
context.budgets.execution.xml_base_resolution(),
context.policy.resources(),
Some(resource_uri),
context.policy.certificate_signature_algorithms(),
)
.map_err(map_key_info_parse_error)?;
let mut nested_outcome = visit(
&mut referenced,
&external_resolver,
context,
materialization,
next_depth,
)?;
nested_outcome.merge(materialize_retrieval_methods_with_budgets(
&mut referenced,
&external_resolver,
context.policy,
context.provider,
context.budgets,
&mut materialization.candidate_work,
)?);
// RetrievalMethod nodes are meaningful only in the document
// whose resolver owns their same-document URI context.
referenced.sources.retain(|source| {
!matches!(source, super::parse::KeyInfoSource::RetrievalMethod { .. })
});
Ok((referenced, nested_outcome))
})?
};
if is_same_document {
nested_outcome.merge(visit(
&mut referenced,
resolver,
context,
materialization,
next_depth,
)?);
}
outcome.merge(nested_outcome);
materialization.active.remove(&cycle_key);
materialized.extend(referenced.sources);
}
key_info.sources = materialized;
context
.policy
.resources()
.validate_key_candidates(key_info.embedded_candidate_count())?;
Ok(outcome)
}
let mut context = KeyInfoReferenceMaterializationContext {
policy,
provider,
budgets,
};
let depth = materialization.reference_depth_base;
visit(key_info, resolver, &mut context, materialization, depth)
}
fn map_key_info_xml_decode_error(
error: crate::encoding::XmlEncodingError,
) -> SignatureVerificationPipelineError {
match error {
crate::encoding::XmlEncodingError::DecodedLimit { maximum, actual } => {
SignatureVerificationPipelineError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::XML_DOCUMENT,
maximum,
actual,
},
)
}
_ => SignatureVerificationPipelineError::InvalidStructure {
reason: "KeyInfoReference external resource has an invalid XML encoding",
},
}
}
/// Resolved signature hints and experimental recipient mechanism metadata.
/// No key hint establishes trust or permission. The mutation handle is private
/// and bound to the original document, never an external document's local ID.
pub struct SignatureKeyInfoMetadata {
/// Materialized recipient hints for KEM, ordinary hints otherwise.
pub key_info: KeyInfo,
/// Exact KEM parameter set, if the resolved graph contains one mechanism.
pub encapsulation: Option<crate::provider::KeyEncapsulationAlgorithm>,
pub(crate) cipher_value: Option<NodeId>,
}
/// Discover operation instructions in local KeyInfo references without consuming
/// unrelated key hints. A positive result requires full metadata validation.
pub fn has_same_document_encapsulation(
node: Node<'_, '_>,
resolver: &UriReferenceResolver<'_>,
resources: &crate::policy::ResourcePolicy,
) -> Result<bool, DsigError> {
resources.validate()?;
let execution = TransformExecutionBudget::from_resources(resources);
has_same_document_encapsulation_with_budget(node, resolver, resources, &execution)
}
pub(crate) fn has_same_document_encapsulation_with_budget(
node: Node<'_, '_>,
resolver: &UriReferenceResolver<'_>,
resources: &crate::policy::ResourcePolicy,
execution: &TransformExecutionBudget,
) -> Result<bool, DsigError> {
has_encapsulation_with_budget::<crate::policy::SigningPolicy>(
node, resolver, resources, execution, None,
)
}
pub(crate) fn has_signing_encapsulation_with_budget(
node: Node<'_, '_>,
resolver: &UriReferenceResolver<'_>,
policy: &crate::policy::SigningPolicy,
execution: &TransformExecutionBudget,
backend: crate::XmlBackend,
) -> Result<bool, DsigError> {
has_encapsulation_with_budget(
node,
resolver,
&policy.resources,
execution,
Some((policy, backend)),
)
}
fn has_encapsulation_with_budget<P: KeyInfoReferencePolicy>(
node: Node<'_, '_>,
resolver: &UriReferenceResolver<'_>,
resources: &crate::policy::ResourcePolicy,
execution: &TransformExecutionBudget,
external: Option<(&P, crate::XmlBackend)>,
) -> Result<bool, DsigError> {
discover_encapsulation_with_budget(node, resolver, resources, execution, external, None)
}
fn discover_encapsulation_with_budget<P: KeyInfoReferencePolicy>(
node: Node<'_, '_>,
resolver: &UriReferenceResolver<'_>,
resources: &crate::policy::ResourcePolicy,
execution: &TransformExecutionBudget,
external: Option<(&P, crate::XmlBackend)>,
materialization: Option<&mut KeyInfoMaterializationState>,
) -> Result<bool, DsigError> {
if resolver.node_for_node_id(node.id()) != Some(node) {
return Err(DsigError::InvalidStructure {
reason: "KeyInfo and resolver belong to different documents",
});
}
struct ExternalProbePath<'a> {
uri: &'a str,
parent: Option<&'a ExternalProbePath<'a>>,
}
struct ProbeContext<'a, P> {
resources: &'a crate::policy::ResourcePolicy,
external: Option<(&'a P, crate::XmlBackend)>,
document_base: Option<&'a str>,
external_path: Option<&'a ExternalProbePath<'a>>,
}
struct DiscoveryState<'a> {
path: [Option<(usize, NodeId)>; crate::hard_limits::KEY_INFO_REFERENCE_DEPTH_CEILING + 1],
work: usize,
materialization: Option<&'a mut KeyInfoMaterializationState>,
}
fn visit<P: KeyInfoReferencePolicy>(
node: Node<'_, '_>,
resolver: &UriReferenceResolver<'_>,
context: &ProbeContext<'_, P>,
state: &mut DiscoveryState<'_>,
depth: usize,
execution: &TransformExecutionBudget,
) -> Result<bool, DsigError> {
let ProbeContext {
resources,
external,
document_base,
external_path,
} = *context;
resources.validate_key_info_reference_depth(depth)?;
if depth > crate::hard_limits::KEY_INFO_REFERENCE_DEPTH_CEILING {
return Err(DsigError::InvalidStructure {
reason: "KeyInfoReference safety depth exceeded",
});
}
// Every document on the active stack remains alive for this traversal;
// arena identity distinguishes equal node IDs in different documents.
let identity = (std::ptr::from_ref(node.document()) as usize, node.id());
if state.path[..depth].contains(&Some(identity)) {
if state.materialization.is_some() {
return Err(DsigError::InvalidStructure {
reason: "KeyInfoReference cycle detected",
});
}
return Ok(false);
}
state.path[depth] = Some(identity);
let mut found = false;
for child in node.children().filter(Node::is_element) {
let work = match state.materialization.as_deref_mut() {
Some(materialization) => &mut materialization.candidate_work,
None => &mut state.work,
};
*work += 1;
resources.validate_key_candidates(*work)?;
if child.has_tag_name((
crate::key_establishment::ENCAPSULATION_NS,
"EncapsulationMechanism",
)) {
if state.materialization.is_none() {
return Ok(true);
}
if let Some(materialization) = state.materialization.as_deref_mut() {
let mechanism = crate::key_establishment::parse_encapsulation(
child,
materialization.encapsulation_template,
)
.map_err(|_| DsigError::InvalidStructure {
reason: "invalid encapsulation mechanism in resolved KeyInfo",
})?;
materialization.record_encapsulation(mechanism, resolver, depth)?;
if let (Some(mechanism), Some((policy, _))) =
(&materialization.encapsulation, external)
{
policy
.key_establishment()
.check_encapsulation(mechanism.algorithm)?;
}
}
found = true;
continue;
}
if !child.has_tag_name(("http://www.w3.org/2009/xmldsig11#", "KeyInfoReference")) {
continue;
}
let Some(uri) = child.attribute("URI") else {
continue;
};
if uri.len() > super::parse::MAX_KEY_NAME_TEXT_LEN {
continue;
}
if state.materialization.is_some()
&& let Some((policy, _)) = external
&& !policy.key_info_reference_source_enabled()
{
return Err(crate::policy::PolicyViolation::KeyTrust {
reason: "KeyInfoReference key sources are disabled",
}
.into());
}
if !uri.is_empty() && !uri.starts_with('#') {
let Some((policy, backend)) = external else {
continue;
};
// XMLDSig 1.1 section 4.5.10 gives KeyInfoReference the same URI
// dereferencing behavior as Reference, including external XML.
// https://www.w3.org/TR/2013/REC-xmldsig-core1-20130411/#sec-KeyInfoReference
let resolved =
crate::c14n::xml_base::resolve_uri_from_node_with_document_base_with_budget(
child,
uri,
document_base,
execution.xml_base_resolution(),
)
.map_err(|error| {
ReferenceProcessingError::UriDereference(
super::uri::map_xml_base_resolution_error(error),
)
})?;
if !policy.key_info_reference_uris().allows(&resolved) {
if state.materialization.is_some() {
return Err(crate::policy::PolicyViolation::Uri {
operation: "KeyInfoReference",
reason: "URI class is disabled",
}
.into());
}
continue;
}
// Resource identity, not a reparsed arena ID, detects external
// cycles. The active URI chain borrows stack frames, no clones.
let mut ancestor = external_path;
while let Some(entry) = ancestor {
if entry.uri == resolved {
break;
}
ancestor = entry.parent;
}
if ancestor.is_some() {
if state.materialization.is_some() {
return Err(DsigError::InvalidStructure {
reason: "KeyInfoReference cycle detected",
});
}
continue;
}
let external_entry = ExternalProbePath {
uri: &resolved,
parent: external_path,
};
let (resource, fragment) = resolved
.split_once('#')
.map_or((resolved.as_str(), None), |(resource, fragment)| {
(resource, Some(fragment))
});
resources.validate_key_info_reference_depth(depth + 1)?;
if depth == crate::hard_limits::KEY_INFO_REFERENCE_DEPTH_CEILING {
return Err(DsigError::InvalidStructure {
reason: "KeyInfoReference safety depth exceeded",
});
}
let bytes = match resolver.external_resource(resource) {
Ok(Some(bytes)) => bytes,
Err(TransformError::Policy(violation)) => {
return Err(DsigError::Policy(violation));
}
_ => continue,
};
execution.xml_parse_work().charge_policy(bytes.len())?;
let xml =
crate::encoding::decode_xml_octets(bytes, resources.max_xml_document_bytes)
.map_err(map_key_info_xml_decode_error)?;
let settings = DocumentParseSettings::from_policy(policy.xml(), resources)
.with_backend(backend);
let document = XmlDocument::parse_with_settings_and_budget(
xml.into_owned(),
settings,
execution.xml_parse_work(),
)
.map_err(|error| map_document_parse_error(error, settings))?;
let target_found = document.with_view(|view| {
let external_resolver = resolver.for_external_document_view(view, resource);
let target = match fragment {
Some(fragment) if !fragment.is_empty() => external_resolver
.node_for_same_document_reference_with_budget(
&resolved[resource.len()..],
Some(execution.node_set_materialization()),
)
.map_err(ReferenceProcessingError::UriDereference)?,
_ => Some(view.document().root_element()),
};
let Some(target) = target else {
return Ok(false);
};
if !target.has_tag_name((XMLDSIG_NS, "KeyInfo")) {
return Ok(false);
}
visit(
target,
&external_resolver,
&ProbeContext {
document_base: Some(resource),
external_path: Some(&external_entry),
resources,
external,
},
state,
depth + 1,
execution,
)
})?;
if target_found {
if state.materialization.is_none() {
return Ok(true);
}
found = true;
}
continue;
}
// Unconsumed key hints are not validity requirements. Mechanism
// discovery checks only reachable KeyInfo instructions, not key
// candidates that an explicitly supplied recipient never uses.
// The target type is defined by XMLDSig 1.1 section 4.5.10.
// https://www.w3.org/TR/2013/REC-xmldsig-core1-20130411/#sec-KeyInfoReference
if state.materialization.is_some()
&& let Some((policy, _)) = external
&& !policy.key_info_reference_uris().allows(uri)
{
return Err(crate::policy::PolicyViolation::Uri {
operation: "KeyInfoReference",
reason: "URI class is disabled",
}
.into());
}
let target = match resolver.node_for_same_document_reference_with_budget(
uri,
Some(execution.node_set_materialization()),
) {
Ok(Some(target)) => target,
Err(TransformError::Policy(violation)) => return Err(DsigError::Policy(violation)),
_ => continue,
};
if target.has_tag_name((XMLDSIG_NS, "KeyInfo"))
&& visit(target, resolver, context, state, depth + 1, execution)?
{
if state.materialization.is_none() {
return Ok(true);
}
found = true;
}
}
Ok(found)
}
visit(
node,
resolver,
&ProbeContext {
resources,
external,
document_base: None,
external_path: None,
},
&mut DiscoveryState {
path: [None; crate::hard_limits::KEY_INFO_REFERENCE_DEPTH_CEILING + 1],
work: 0,
materialization,
},
0,
execution,
)
}
fn inspect_signature_key_info_with_budgets<P: KeyInfoReferencePolicy>(
node: Node<'_, '_>,
resolver: &UriReferenceResolver<'_>,
policy: &P,
provider: &dyn crate::provider::CryptoProvider,
budgets: &mut RetrievalMaterializationBudgets<'_>,
template: bool,
retain_recipient_hints: bool,
) -> Result<SignatureKeyInfoMetadata, DsigError> {
if resolver.node_for_node_id(node.id()) != Some(node) {
return Err(DsigError::InvalidStructure {
reason: "KeyInfo and resolver belong to different documents",
});
}
let mut materialization = KeyInfoMaterializationState {
collect_encapsulation: true,
encapsulation_template: template,
retain_encapsulation_target: true,
..Default::default()
};
materialization.observe_encapsulation(node, resolver, 0)?;
if let Some(mechanism) = &materialization.encapsulation {
policy
.key_establishment()
.check_encapsulation(mechanism.algorithm)?;
}
let mut key_info = parse_key_info_with_policy_budgets(
node,
provider,
budgets.execution.xml_base_resolution(),
policy.resources(),
policy.certificate_signature_algorithms(),
)
.map_err(map_key_info_parse_error)?;
materialize_key_info_references_with_budgets(
&mut key_info,
resolver,
policy,
provider,
budgets,
&mut materialization,
)?;
let encapsulation = materialization
.encapsulation
.as_ref()
.map(|value| value.algorithm);
let cipher_value =
if let Some((resource, cipher, recipient)) = materialization.encapsulation_target.take() {
// Product mutation boundary: immutable external resources cannot be
// updated by signing, and foreign node IDs must not address this DOM.
if resource != resolver.traversal_document_identity() {
return Err(DsigError::InvalidStructure {
reason: "recipient metadata requires its original document context",
});
}
if retain_recipient_hints {
let recipient =
resolver
.node_for_node_id(recipient)
.ok_or(DsigError::InvalidStructure {
reason: "recipient KeyInfo target is unavailable",
})?;
key_info = parse_key_info_with_policy_budgets(
recipient,
provider,
budgets.execution.xml_base_resolution(),
policy.resources(),
policy.certificate_signature_algorithms(),
)
.map_err(map_key_info_parse_error)?;
// Recipient references use the same cumulative candidate, parse and
// external-resource allowances as the containing mechanism graph.
materialization.reference_depth_base = materialization.encapsulation_depth;
materialize_key_info_references_with_budgets(
&mut key_info,
resolver,
policy,
provider,
budgets,
&mut materialization,
)?;
}
Some(cipher)
} else {
None
};
Ok(SignatureKeyInfoMetadata {
key_info,
encapsulation,
cipher_value,
})
}
pub(crate) fn inspect_signing_key_info_with_budgets(
node: Node<'_, '_>,
resolver: &UriReferenceResolver<'_>,
policy: &crate::policy::SigningPolicy,
provider: &dyn crate::provider::CryptoProvider,
shared_budgets: (&TransformExecutionBudget, &mut XPathSignatureParseBudget),
xml_backend: crate::XmlBackend,
) -> Result<SignatureKeyInfoMetadata, DsigError> {
inspect_signature_key_info_with_budgets(
node,
resolver,
policy,
provider,
&mut RetrievalMaterializationBudgets {
xpath_parse: shared_budgets.1,
execution: shared_budgets.0,
resources: &policy.resources,
certificate_signature_algorithms: None,
xml_backend,
},
true,
false,
)
}
fn inspect_signature_key_info_for_policy<P: KeyInfoReferencePolicy>(
node: Node<'_, '_>,
resolver: UriReferenceResolver<'_>,
policy: &P,
provider: &dyn crate::provider::CryptoProvider,
xml_backend: crate::XmlBackend,
template: bool,
) -> Result<SignatureKeyInfoMetadata, DsigError> {
let resolver = resolver.with_external_resource_limits(
policy.resources().max_external_resource_bytes,
policy.resources().max_external_resource_total_bytes,
);
let mut xpath_parse = XPathSignatureParseBudget::from_resources(policy.resources());
let execution = TransformExecutionBudget::from_resources(policy.resources());
inspect_signature_key_info_with_budgets(
node,
&resolver,
policy,
provider,
&mut RetrievalMaterializationBudgets {
xpath_parse: &mut xpath_parse,
execution: &execution,
resources: policy.resources(),
certificate_signature_algorithms: policy.certificate_signature_algorithms(),
xml_backend,
},
template,
true,
)
}
/// Inspect signing hints and KEM targets through the bounded KeyInfoReference
/// graph. KEM targets must belong to the document that will be mutated.
pub fn inspect_signing_key_info(
node: Node<'_, '_>,
resolver: UriReferenceResolver<'_>,
policy: &crate::policy::SigningPolicy,
provider: &dyn crate::provider::CryptoProvider,
xml_backend: crate::XmlBackend,
) -> Result<SignatureKeyInfoMetadata, DsigError> {
policy.validate()?;
inspect_signature_key_info_for_policy(node, resolver, policy, provider, xml_backend, true)
}
/// Inspect verification hints and KEM metadata with the same source, URI,
/// candidate, depth and cycle gates as core verification.
/// This original-document metadata API requires KEM targets in that document;
/// core verification also supports caller-supplied external KEM resources.
pub fn inspect_verification_key_info(
node: Node<'_, '_>,
resolver: UriReferenceResolver<'_>,
policy: &crate::policy::VerificationPolicy,
provider: &dyn crate::provider::CryptoProvider,
xml_backend: crate::XmlBackend,
) -> Result<SignatureKeyInfoMetadata, DsigError> {
policy.validate()?;
inspect_signature_key_info_for_policy(node, resolver, policy, provider, xml_backend, false)
}
/// Inspect operation instructions for a caller-pinned key, leaving unrelated
/// lookup hints unconsumed. Discovery and materialization share one allowance.
/// Like `inspect_verification_key_info`, this original-document metadata API
/// rejects external KEM targets; it never reports a permitted external mechanism
/// as absent. Use core verification with a decapsulation key for those targets.
pub fn inspect_pinned_verification_key_info(
node: Node<'_, '_>,
resolver: UriReferenceResolver<'_>,
policy: &crate::policy::VerificationPolicy,
provider: &dyn crate::provider::CryptoProvider,
xml_backend: crate::XmlBackend,
) -> Result<Option<SignatureKeyInfoMetadata>, DsigError> {
policy.validate()?;
let resolver = resolver.with_external_resource_limits(
policy.resources.max_external_resource_bytes,
policy.resources.max_external_resource_total_bytes,
);
let execution = TransformExecutionBudget::from_resources(&policy.resources);
if !has_encapsulation_with_budget(
node,
&resolver,
&policy.resources,
&execution,
Some((policy, xml_backend)),
)? {
return Ok(None);
}
let mut xpath_parse = XPathSignatureParseBudget::from_resources(&policy.resources);
inspect_signature_key_info_with_budgets(
node,
&resolver,
policy,
provider,
&mut RetrievalMaterializationBudgets {
xpath_parse: &mut xpath_parse,
execution: &execution,
resources: &policy.resources,
certificate_signature_algorithms: policy.certificate_signature_algorithms(),
xml_backend,
},
false,
true,
)
.map(Some)
}
fn materialize_key_info_references_for_policy<P: KeyInfoReferencePolicy>(
key_info: &mut KeyInfo,
resolver: UriReferenceResolver<'_>,
policy: &P,
provider: &dyn crate::provider::CryptoProvider,
xml_backend: crate::XmlBackend,
) -> Result<(), DsigError> {
let resolver = resolver.with_external_resource_limits(
policy.resources().max_external_resource_bytes,
policy.resources().max_external_resource_total_bytes,
);
let mut xpath_parse_budget = XPathSignatureParseBudget::from_resources(policy.resources());
let execution_budget = TransformExecutionBudget::from_resources(policy.resources());
let mut budgets = RetrievalMaterializationBudgets {
xpath_parse: &mut xpath_parse_budget,
execution: &execution_budget,
resources: policy.resources(),
certificate_signature_algorithms: policy.certificate_signature_algorithms(),
xml_backend,
};
let mut materialization = KeyInfoMaterializationState::default();
// Candidate-local retrieval failures remain deferred: this metadata-only
// entry point cannot know whether a later key-selection stage needs them.
materialize_key_info_references_with_budgets(
key_info,
&resolver,
policy,
provider,
&mut budgets,
&mut materialization,
)?;
Ok(())
}
/// Resolve and recursively expand policy-allowed `KeyInfoReference` sources
/// while preparing caller-supplied key material for signing.
///
/// The recursive reference traversal shares the verifier's candidate-work,
/// depth, cycle, target, XML parsing, and external-resource bounds. Retrieval
/// methods nested in an external referenced document are materialized while
/// that document's URI context is active; retrieval methods in the caller's
/// document remain the responsibility of the complete signing pipeline.
/// candidate failures are deferred so an earlier usable key source can still
/// be selected; the complete signing pipeline reports the retained failure if
/// no source resolves to a key. URI classes remain controlled by the signing
/// policy's `uris` field; external
/// bytes must be attached to `resolver` explicitly by the caller. The resolver
/// is consumed so this operation can bind a fresh aggregate external-resource
/// budget to the supplied policy. `xml_backend` is used for every recursively
/// referenced XML document, preserving the caller's parser semantics.
pub fn materialize_signing_key_info_references(
key_info: &mut KeyInfo,
resolver: UriReferenceResolver<'_>,
policy: &crate::policy::SigningPolicy,
provider: &dyn crate::provider::CryptoProvider,
xml_backend: crate::XmlBackend,
) -> Result<(), DsigError> {
policy.validate()?;
materialize_key_info_references_for_policy(key_info, resolver, policy, provider, xml_backend)
}
/// Resolve and recursively expand policy-allowed `KeyInfoReference` sources
/// before verification key selection.
///
/// In addition to URI and resource policy, this entry point enforces the
/// verification policy's `key_sources.key_info_reference` trust gate. Retrieval
/// methods nested in an external referenced document are materialized in that
/// document's URI context; retrieval methods in the caller's document remain
/// the responsibility of the complete verification pipeline. Candidate-local
/// retrieval failures remain deferred so usable sibling sources survive; the
/// complete pipeline reports one only when key selection has no fallback.
/// External bytes must be attached to `resolver` explicitly by the caller. The resolver is
/// consumed so this operation can bind a fresh aggregate external-resource
/// budget to the supplied policy. `xml_backend` is used for every recursively
/// referenced XML document, preserving the caller's parser semantics.
pub fn materialize_verification_key_info_references(
key_info: &mut KeyInfo,
resolver: UriReferenceResolver<'_>,
policy: &crate::policy::VerificationPolicy,
provider: &dyn crate::provider::CryptoProvider,
xml_backend: crate::XmlBackend,
) -> Result<(), DsigError> {
policy.validate()?;
materialize_key_info_references_for_policy(key_info, resolver, policy, provider, xml_backend)
}
fn materialize_retrieval_methods_with_budgets<P: KeyInfoReferencePolicy>(
key_info: &mut KeyInfo,
resolver: &UriReferenceResolver<'_>,
policy: &P,
provider: &dyn crate::provider::CryptoProvider,
budgets: &mut RetrievalMaterializationBudgets<'_>,
candidate_work: &mut usize,
) -> Result<RetrievalMaterialization, SignatureVerificationPipelineError> {
let allowed_uri_types = policy.retrieval_method_uris();
let allowed_transforms = policy.allowed_transforms();
let retrieval_count = key_info
.sources
.iter()
.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());
let mut outcome = RetrievalMaterialization::default();
for source in std::mem::take(&mut key_info.sources) {
let super::parse::KeyInfoSource::RetrievalMethod {
uri: resolved_uri,
resource_type,
transforms,
} = source
else {
materialized.push(source);
continue;
};
let identity = (
resolved_uri.clone(),
resource_type.clone(),
transforms.clone(),
);
if !seen.insert(identity) {
continue;
}
if resource_type.as_deref() == Some("http://www.w3.org/2000/09/xmldsig#rawX509Certificate")
{
if transforms != RetrievalMethodTransforms::None
|| classify_uri(&resolved_uri) != UriClass::External
{
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "raw X509 RetrievalMethod requires an untransformed external URI",
});
}
if !allowed_uri_types.allows(&resolved_uri) {
return Err(crate::policy::PolicyViolation::Uri {
operation: "verification",
reason: "retrieval method URI class is not permitted",
}
.into());
}
let certificate = resolver.external_resource(&resolved_uri).map_err(|error| {
SignatureVerificationPipelineError::from(ReferenceProcessingError::Transform(error))
})?;
let Some(certificate) = certificate else {
outcome.deferred_error.get_or_insert_with(|| {
SignatureVerificationPipelineError::Reference(
ReferenceProcessingError::Transform(super::TransformError::UnsupportedUri(
resolved_uri.clone(),
)),
)
});
materialized.push(super::parse::KeyInfoSource::RetrievalMethod {
uri: resolved_uri,
resource_type,
transforms,
});
continue;
};
*candidate_work = candidate_work.saturating_add(1);
budgets.resources.validate_key_candidates(*candidate_work)?;
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 = match parse_x509_certificate(certificate) {
Ok(parsed) => parsed,
Err(error) => {
let error = map_key_info_parse_error(error);
if matches!(error, SignatureVerificationPipelineError::Policy(_)) {
return Err(error);
}
outcome.deferred_error.get_or_insert(error);
materialized.push(super::parse::KeyInfoSource::RetrievalMethod {
uri: resolved_uri,
resource_type,
transforms,
});
continue;
}
};
materialized.push(super::parse::KeyInfoSource::X509Data(
super::parse::X509DataInfo {
certificates: vec![certificate.to_vec()],
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(&resolved_uri) {
return Err(crate::policy::PolicyViolation::Uri {
operation: "verification",
reason: "retrieval method URI class is not permitted",
}
.into());
}
let target = resolver
.node_for_same_document_reference(&resolved_uri)
.map_err(ReferenceProcessingError::Transform)?;
let Some(target) = target else {
outcome.deferred_error.get_or_insert(
SignatureVerificationPipelineError::InvalidStructure {
reason: "X509Data RetrievalMethod target is missing or ambiguous",
},
);
materialized.push(super::parse::KeyInfoSource::RetrievalMethod {
uri: resolved_uri,
resource_type,
transforms,
});
continue;
};
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 {
expression,
namespaces,
} => {
enforce_transform_allowed(allowed_transforms, XPATH_TRANSFORM_URI)?;
budgets
.xpath_parse
.validate_expression(&expression)
.map_err(ReferenceProcessingError::Transform)?;
budgets
.xpath_parse
.validate_namespaces(&namespaces)
.map_err(ReferenceProcessingError::Transform)?;
select_retrieved_x509_data_root(target, budgets.execution)?
}
RetrievalMethodTransforms::Unsupported => {
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "X509Data RetrievalMethod contains unsupported transforms",
});
}
};
super::parse::validate_x509_digest_policy(node, &|algorithm| {
policy.check_digest_algorithm(algorithm)
})
.map_err(map_key_info_parse_error)?;
let data = parse_x509_data_dispatch_with_budget_and_provider(
node,
&mut total_binary_len,
candidate_work,
provider,
budgets.resources,
budgets.certificate_signature_algorithms,
)
.map_err(map_key_info_parse_error)?;
materialized.push(super::parse::KeyInfoSource::X509Data(data));
} else {
materialized.push(super::parse::KeyInfoSource::RetrievalMethod {
uri: resolved_uri,
resource_type,
transforms,
});
}
}
key_info.sources = materialized;
Ok(outcome)
}
fn select_retrieved_x509_data_root<'a, 'input>(
target: Node<'a, 'input>,
execution_budget: &TransformExecutionBudget,
) -> Result<Node<'a, 'input>, SignatureVerificationPipelineError> {
// XMLDSig XPath filtering evaluates the predicate for every node in the
// dereferenced node-set, including attribute and namespace nodes. The
// element-only scan below selects X509Data but must not undercount that
// XPath context cardinality.
let context_nodes = NodeSet::ensure_subtree_materialization_fits_with_budget(
target,
false,
execution_budget.node_set_materialization(),
)
.map_err(ReferenceProcessingError::Transform)?;
execution_budget
.validate_xpath_context_evaluations(context_nodes)
.map_err(ReferenceProcessingError::Transform)?;
execution_budget
.charge_xpath_work(context_nodes)
.map_err(ReferenceProcessingError::Transform)?;
execution_budget
.charge_node_filter_work(context_nodes)
.map_err(ReferenceProcessingError::Transform)?;
let mut root = None;
for candidate in target.descendants() {
if !candidate.is_element()
|| candidate.tag_name().namespace() != Some(XMLDSIG_NS)
|| candidate.tag_name().name() != "X509Data"
{
continue;
}
if root.replace(candidate).is_some() {
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "X509Data RetrievalMethod selected multiple X509Data elements",
});
}
}
root.ok_or(SignatureVerificationPipelineError::InvalidStructure {
reason: "X509Data RetrievalMethod selected no X509Data element",
})
}
#[cfg(test)]
fn materialize_retrieval_methods(
key_info: &mut KeyInfo,
resolver: &UriReferenceResolver<'_>,
allowed_uri_types: UriTypeSet,
allowed_transforms: Option<&HashSet<String>>,
provider: &dyn crate::provider::CryptoProvider,
) -> Result<RetrievalMaterialization, SignatureVerificationPipelineError> {
let mut xpath_parse_budget = XPathSignatureParseBudget::default();
let execution_budget = TransformExecutionBudget::default();
let resources = crate::policy::ResourcePolicy::default();
let mut budgets = RetrievalMaterializationBudgets {
xpath_parse: &mut xpath_parse_budget,
execution: &execution_budget,
resources: &resources,
certificate_signature_algorithms: None,
xml_backend: crate::XmlBackend::default(),
};
let mut candidate_work = key_info.embedded_candidate_count();
let mut policy = crate::policy::VerificationPolicy::default();
policy.uris.retrieval_methods = allowed_uri_types;
policy.transforms.allowed_algorithms = allowed_transforms.cloned();
materialize_retrieval_methods_with_budgets(
key_info,
resolver,
&policy,
provider,
&mut budgets,
&mut candidate_work,
)
}
fn existing_x509_binary_len(
key_info: &KeyInfo,
) -> Result<usize, SignatureVerificationPipelineError> {
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)?;
}
}
}
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(())
}
fn manifest_reference_failure_reason(
error: ReferenceProcessingError,
ref_index: usize,
) -> FailureReason {
match error.into_policy_violation() {
Ok(_) => FailureReason::ReferencePolicyViolation { ref_index },
Err(_) => FailureReason::ReferenceProcessingFailure { ref_index },
}
}
struct CompiledManifestReference {
index: usize,
reference: Reference,
reference_node_id: NodeId,
}
struct CompiledManifestInvalid {
result: ReferenceResult,
}
enum ScheduledManifestReference {
Parsed {
node: OperationNodeId,
reference: CompiledManifestReference,
},
Invalid {
node: OperationNodeId,
invalid: CompiledManifestInvalid,
},
}
impl ScheduledManifestReference {
fn index(&self) -> usize {
match self {
Self::Parsed { reference, .. } => reference.index,
Self::Invalid { invalid, .. } => invalid.result.reference_index,
}
}
}
fn process_authenticated_manifest_references(
operation: &mut OperationExecutionContext<
&crate::policy::VerificationPolicy,
&VerificationOperationBudgets,
>,
view: DocumentView<'_>,
signature_node: Node<'_, '_>,
resolver: &UriReferenceResolver<'_>,
ctx: &VerifyContext<'_>,
mut remaining_reference_capacity: usize,
crypto: OperationNodeId,
) -> Result<(Vec<ReferenceResult>, Vec<OperationNodeId>), SignatureVerificationPipelineError> {
let mut processed_manifests = HashSet::new();
let mut next_reference_index = 0usize;
let mut frontier_index = 0usize;
let mut completed_digests = Vec::new();
let mut results = Vec::new();
loop {
operation.extend();
let discovery = operation.add_node(
OperationNodeKind::Manifest {
index: frontier_index,
},
OperationStage::AuthenticatedDependency,
None,
);
operation
.add_dependency(discovery, crypto)
.map_err(map_verification_plan_error)?;
for digest in &completed_digests {
operation
.add_dependency(discovery, *digest)
.map_err(map_verification_plan_error)?;
}
operation.compile().map_err(map_verification_plan_error)?;
let parsed = operation.run(discovery, || {
let mut xpath_parse = operation.budgets().xpath_parse.borrow_mut();
let mut state = ManifestDiscoveryState {
processed: &mut processed_manifests,
remaining_capacity: &mut remaining_reference_capacity,
next_reference_index: &mut next_reference_index,
xpath_parse: &mut xpath_parse,
};
parse_manifest_references(
signature_node,
operation,
view,
&mut state,
ctx.allowed_transform_uris(),
)
})?;
let discovered = parsed.references.len() + parsed.invalid_results.len();
if discovered == 0 {
break;
}
operation.extend();
let mut scheduled = Vec::with_capacity(discovered);
for item in parsed.references {
let resource = operation.budgets().resource_identity_for_reference(
&item.reference,
item.index,
resolver,
view,
resolver.node_for_node_id(item.reference_node_id),
);
let node = operation.add_node(
OperationNodeKind::Digest { index: item.index },
OperationStage::AuthenticatedDependency,
Some(resource.clone()),
);
operation
.add_dependency(node, discovery)
.map_err(map_verification_plan_error)?;
scheduled.push(ScheduledManifestReference::Parsed {
node,
reference: item,
});
}
for item in parsed.invalid_results {
let node = operation.add_node(
OperationNodeKind::Digest {
index: item.result.reference_index,
},
OperationStage::AuthenticatedDependency,
None,
);
operation
.add_dependency(node, discovery)
.map_err(map_verification_plan_error)?;
scheduled.push(ScheduledManifestReference::Invalid {
node,
invalid: item,
});
}
scheduled.sort_by_key(ScheduledManifestReference::index);
operation.compile().map_err(map_verification_plan_error)?;
for scheduled_reference in scheduled {
let (node, result) = match scheduled_reference {
ScheduledManifestReference::Invalid { node, invalid } => {
let result = operation.run(node, || {
Ok::<_, SignatureVerificationPipelineError>(invalid.result)
})?;
(node, result)
}
ScheduledManifestReference::Parsed {
node,
reference: compiled,
} => {
let observed = operation.budgets().resource_identity_for_reference(
&compiled.reference,
compiled.index,
resolver,
view,
resolver.node_for_node_id(compiled.reference_node_id),
);
let result = operation.run_with_resource(node, &observed, || {
let reference = &compiled.reference;
let budgets = operation.budgets();
let execution = ReferenceExecutionContext {
store_pre_digest: ctx.store_pre_digest,
transform_options: ctx.transform_options(),
transform_budget: &budgets.transforms,
canonicalized_data_budget: &budgets.canonicalized,
provider: ctx.provider,
};
let mut result = if execution.transform_budget.remaining_c14n_output() == 0
|| super::transforms::validate_relationship_chain(
&reference.transforms,
ctx.policy.transforms.opc_relationship_edition,
)
.is_err()
|| reference.transforms.len()
> ctx.policy.resources.max_transforms_per_reference
|| ctx
.policy
.check_digest_algorithm(reference.digest_method)
.is_err()
{
manifest_reference_invalid_result(
reference,
compiled.index,
FailureReason::ReferencePolicyViolation {
ref_index: compiled.index,
},
)
} else {
match enforce_reference_policies(
std::slice::from_ref(reference),
ctx.policy.uris.references,
ctx.allowed_transform_uris(),
) {
Ok(()) => process_reference_with_options(
reference,
resolver,
signature_node,
ReferenceSet::Manifest,
compiled.index,
resolver.node_for_node_id(compiled.reference_node_id),
&execution,
)
.unwrap_or_else(|error| {
manifest_reference_invalid_result(
reference,
compiled.index,
manifest_reference_failure_reason(error, compiled.index),
)
}),
Err(SignatureVerificationPipelineError::Policy(_)) => {
manifest_reference_invalid_result(
reference,
compiled.index,
FailureReason::ReferencePolicyViolation {
ref_index: compiled.index,
},
)
}
Err(_) => manifest_reference_invalid_result(
reference,
compiled.index,
FailureReason::ReferenceProcessingFailure {
ref_index: compiled.index,
},
),
}
};
bind_reference_evidence(
&mut result,
reference,
view,
resolver.node_for_node_id(compiled.reference_node_id),
&observed,
);
if result.status == DsigStatus::Valid
&& reference
.transforms
.iter()
.all(transform_preserves_manifest_structure)
&& let Some(uri) = reference.uri.as_deref()
&& let Ok(Some(target)) =
resolver.node_id_for_same_document_reference(uri)
{
operation.authenticate(view.node_identity_by_id(target));
}
Ok::<_, SignatureVerificationPipelineError>(result)
})?;
(node, result)
}
};
let accepted = result.status == DsigStatus::Valid;
operation.set_outcome(
node,
accepted,
if accepted {
OperationDecisionReason::ReferenceDigestVerified
} else {
OperationDecisionReason::ReferenceDigestRejected
},
);
completed_digests.push(node);
results.push(result);
}
frontier_index += 1;
}
results.sort_by_key(|result| result.reference_index);
Ok((results, completed_digests))
}
fn manifest_reference_invalid_result(
reference: &Reference,
index: usize,
reason: FailureReason,
) -> ReferenceResult {
ReferenceResult {
reference_identity: None,
target_identity: None,
external_resource_fingerprint: None,
coverage: ReferenceCoverage::TransformedData,
reference_set: ReferenceSet::Manifest,
reference_index: index,
uri: reference
.uri
.clone()
.unwrap_or_else(|| "<omitted>".to_owned()),
digest_algorithm: reference.digest_method,
status: DsigStatus::Invalid(reason),
pre_digest_data: None,
}
}
fn parse_manifest_references(
signature_node: Node<'_, '_>,
operation: &OperationExecutionContext<
&crate::policy::VerificationPolicy,
&VerificationOperationBudgets,
>,
view: DocumentView<'_>,
state: &mut ManifestDiscoveryState<'_>,
allowed_transforms: Option<&HashSet<String>>,
) -> Result<ParsedManifestReferences, SignatureVerificationPipelineError> {
let mut references = Vec::new();
let mut invalid = Vec::new();
for object_node in signature_node.children().filter(|node| {
node.is_element()
&& node.tag_name().namespace() == Some(XMLDSIG_NS)
&& node.tag_name().name() == "Object"
}) {
let object_is_signed = operation.is_authenticated(view.node_identity(object_node));
for manifest_node in object_node.children().filter(|node| {
node.is_element()
&& node.tag_name().namespace() == Some(XMLDSIG_NS)
&& node.tag_name().name() == "Manifest"
}) {
let manifest_is_signed = operation.is_authenticated(view.node_identity(manifest_node));
// Leave unauthenticated Manifests unmarked so a verified outer
// Manifest can make them eligible on the next discovery pass.
if !object_is_signed && !manifest_is_signed {
continue;
}
if !state.processed.insert(manifest_node.id()) {
continue;
}
let mut manifest_children = Vec::new();
for child in manifest_node.children() {
if child.is_text()
&& child.text().is_some_and(|text| {
text.chars().any(|c| !matches!(c, ' ' | '\t' | '\n' | '\r'))
})
{
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "Manifest contains non-whitespace mixed content",
});
}
if child.is_element() {
manifest_children.push(child);
}
}
if manifest_children.is_empty() {
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "Manifest must contain at least one ds:Reference element child",
});
}
for child in manifest_children {
if child.tag_name().namespace() != Some(XMLDSIG_NS)
|| child.tag_name().name() != "Reference"
{
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "Manifest must contain only ds:Reference element children",
});
}
if *state.remaining_capacity == 0 {
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "signed Manifests exceed the per-signature Reference limit",
});
}
*state.remaining_capacity -= 1;
let reference_index = *state.next_reference_index;
*state.next_reference_index += 1;
match parse_reference_with_xpath_budget(child, state.xpath_parse) {
Ok(reference) => references.push(CompiledManifestReference {
index: reference_index,
reference,
reference_node_id: child.id(),
}),
Err(ParseError::Transform(super::TransformError::UnsupportedTransform(
uri,
))) => {
let digest_algorithm =
reference_digest_method(child).map_err(map_manifest_parse_error)?;
let reason =
if allowed_transforms.is_some_and(|allowed| !allowed.contains(&uri)) {
FailureReason::ReferencePolicyViolation {
ref_index: reference_index,
}
} else {
FailureReason::ReferenceProcessingFailure {
ref_index: reference_index,
}
};
invalid.push(CompiledManifestInvalid {
result: ReferenceResult {
reference_identity: Some(view.node_identity(child)),
target_identity: None,
external_resource_fingerprint: None,
coverage: ReferenceCoverage::TransformedData,
reference_set: ReferenceSet::Manifest,
reference_index,
uri: child.attribute("URI").unwrap_or("<omitted>").to_owned(),
digest_algorithm,
status: DsigStatus::Invalid(reason),
pre_digest_data: None,
},
});
}
Err(error) => return Err(map_manifest_parse_error(error)),
}
}
}
}
Ok(ParsedManifestReferences {
references,
invalid_results: invalid,
})
}
struct ParsedManifestReferences {
references: Vec<CompiledManifestReference>,
invalid_results: Vec<CompiledManifestInvalid>,
}
struct ManifestDiscoveryState<'a> {
processed: &'a mut HashSet<NodeId>,
remaining_capacity: &'a mut usize,
next_reference_index: &'a mut usize,
xpath_parse: &'a mut XPathSignatureParseBudget,
}
fn transform_preserves_manifest_structure(transform: &Transform) -> bool {
match transform {
Transform::C14n(_) => true,
// Both eligible ID targets are descendants of the owning Signature.
// Enveloped subtraction therefore removes their intersection with that
// Signature subtree, even though the Signature node itself is not in the
// dereferenced node set.
Transform::Enveloped
| Transform::XpathExcludeAllSignatures
| Transform::XPath(_)
| Transform::XPathFilter2(_)
| Transform::Base64Decode
| Transform::Relationship(_) => false,
}
}
#[expect(
clippy::large_enum_variant,
reason = "single bounded authorization result avoids heap allocation"
)]
enum ResolvedVerifyingKey<'a> {
Established(super::HmacVerificationKey),
Borrowed(&'a dyn VerifyingKey),
Owned(super::ResolvedVerificationKey<'a>),
}
impl ResolvedVerifyingKey<'_> {
fn evidence(
&self,
policy: &crate::policy::VerificationPolicy,
) -> Result<super::KeyTrustEvidence, DsigError> {
match self {
Self::Established(_) => {
if policy.key_trust.mode != crate::policy::VerificationTrustMode::CryptographicOnly
{
return Err(crate::policy::PolicyViolation::KeyTrust {
reason: "KEM/HMAC does not authenticate sender identity",
}
.into());
}
Ok(super::KeyTrustEvidence::NotEstablished)
}
Self::Borrowed(_)
if policy.key_trust.mode
== crate::policy::VerificationTrustMode::CryptographicOnly =>
{
Ok(super::KeyTrustEvidence::NotEstablished)
}
Self::Borrowed(_) => Ok(super::KeyTrustEvidence::CallerTrusted),
Self::Owned(key) => key.authorize(policy),
}
}
fn as_ref(&self) -> &dyn VerifyingKey {
match self {
Self::Established(key) => key,
Self::Borrowed(key) => *key,
Self::Owned(key) => key.as_ref(),
}
}
}
fn resolve_verifying_key<'k>(
ctx: &VerifyContext<'k>,
key: Option<&'k dyn VerifyingKey>,
key_info: Option<&KeyInfo>,
algorithm: SignatureAlgorithm,
) -> Result<Option<ResolvedVerifyingKey<'k>>, SignatureVerificationPipelineError> {
if let Some(key) = key {
if !ctx.policy.key_sources.preset_key {
return Err(crate::policy::PolicyViolation::KeyTrust {
reason: "pre-resolved verification keys are disabled",
}
.into());
}
require_verifying_key_candidate_capacity(&ctx.policy)?;
return Ok(Some(ResolvedVerifyingKey::Borrowed(key)));
}
if let Some(resolver) = ctx.key_resolver {
require_verifying_key_candidate_capacity(&ctx.policy)?;
let resolved =
resolver.resolve_for_verification(key_info, algorithm, &ctx.policy, ctx.provider)?;
if let Some(key) = &resolved {
key.authorize(&ctx.policy)?;
}
return Ok(resolved.map(ResolvedVerifyingKey::Owned));
}
Ok(None)
}
fn require_verifying_key_candidate_capacity(
policy: &crate::policy::VerificationPolicy,
) -> Result<(), SignatureVerificationPipelineError> {
policy
.resources
.validate_key_candidates(1)
.map_err(Into::into)
}
fn enforce_reference_policies(
references: &[Reference],
allowed_uri_types: UriTypeSet,
allowed_transforms: Option<&HashSet<String>>,
) -> Result<(), SignatureVerificationPipelineError> {
for reference in references {
let uri = reference
.uri
.as_deref()
.ok_or(SignatureVerificationPipelineError::Reference(
ReferenceProcessingError::MissingUri,
))?;
if !allowed_uri_types.allows(uri) {
return Err(crate::policy::PolicyViolation::Uri {
operation: "verification",
reason: "reference URI class is not permitted",
}
.into());
}
if let Some(allowed) = allowed_transforms {
for transform in &reference.transforms {
let transform_uri = transform.algorithm_uri();
enforce_transform_allowed(Some(allowed), transform_uri)?;
}
// 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 produces_binary = transform_chain_produces_binary(
classify_uri(uri) == UriClass::External,
&reference.transforms,
);
if !produces_binary {
enforce_transform_allowed(Some(allowed), DEFAULT_IMPLICIT_C14N_URI)?;
}
}
}
Ok(())
}
fn enforce_transform_allowed(
allowed_transforms: Option<&HashSet<String>>,
algorithm: &str,
) -> Result<(), SignatureVerificationPipelineError> {
if allowed_transforms.is_some_and(|allowed| !allowed.contains(algorithm)) {
return Err(crate::policy::PolicyViolation::Algorithm {
operation: "verification transform",
algorithm: algorithm.to_owned(),
}
.into());
}
Ok(())
}
#[derive(Debug, Clone, Copy)]
pub(super) struct SignatureChildNodes<'a, 'input> {
signed_info_node: Node<'a, 'input>,
signature_value_node: Node<'a, 'input>,
pub(super) key_info_node: Option<Node<'a, 'input>>,
}
pub(super) fn parse_signature_children<'a, 'input>(
signature_node: Node<'a, 'input>,
) -> Result<SignatureChildNodes<'a, 'input>, SignatureVerificationPipelineError> {
let mut signed_info_node: Option<Node<'_, '_>> = None;
let mut signature_value_node: Option<Node<'_, '_>> = None;
let mut key_info_node: Option<Node<'_, '_>> = None;
let mut signed_info_index: Option<usize> = None;
let mut signature_value_index: Option<usize> = None;
let mut key_info_index: Option<usize> = None;
let mut first_unexpected_dsig_index: Option<usize> = None;
let mut element_index = 0usize;
for child in signature_node.children() {
if child.is_text() {
if child
.text()
.is_some_and(|text| !is_xml_whitespace_only(text))
{
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "Signature must not contain non-whitespace mixed content",
});
}
continue;
}
if !child.is_element() {
continue;
}
element_index += 1;
if child.tag_name().namespace() != Some(XMLDSIG_NS) {
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "Signature must contain only XMLDSIG element children",
});
}
match child.tag_name().name() {
"SignedInfo" => {
if signed_info_node.is_some() {
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "SignedInfo must appear exactly once under Signature",
});
}
signed_info_node = Some(child);
signed_info_index = Some(element_index);
}
"SignatureValue" => {
if signature_value_node.is_some() {
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "SignatureValue must appear exactly once under Signature",
});
}
signature_value_node = Some(child);
signature_value_index = Some(element_index);
}
"KeyInfo" => {
if key_info_node.is_some() {
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "KeyInfo must appear at most once under Signature",
});
}
key_info_node = Some(child);
key_info_index = Some(element_index);
}
"Object" => {
// Valid Object elements are allowed only after SignedInfo, SignatureValue,
// and optional KeyInfo; this is enforced via first_unexpected_dsig_index.
}
_ => {
if first_unexpected_dsig_index.is_none() {
first_unexpected_dsig_index = Some(element_index);
}
}
}
}
let signed_info_node =
signed_info_node.ok_or(SignatureVerificationPipelineError::MissingElement {
element: "SignedInfo",
})?;
let signature_value_node =
signature_value_node.ok_or(SignatureVerificationPipelineError::MissingElement {
element: "SignatureValue",
})?;
if signed_info_index != Some(1) {
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "SignedInfo must be the first element child of Signature",
});
}
if signature_value_index != Some(2) {
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "SignatureValue must be the second element child of Signature",
});
}
if let Some(index) = key_info_index
&& index != 3
{
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "KeyInfo must be the third element child of Signature when present",
});
}
let allowed_prefix_end = key_info_index.unwrap_or(2);
if let Some(unexpected_index) = first_unexpected_dsig_index {
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: if unexpected_index > allowed_prefix_end {
"After SignedInfo, SignatureValue, and optional KeyInfo, Signature may contain only Object elements"
} else {
"Signature may contain SignedInfo first, SignatureValue second, optional KeyInfo third, and Object elements thereafter"
},
});
}
Ok(SignatureChildNodes {
signed_info_node,
signature_value_node,
key_info_node,
})
}
fn decode_signature_value(
signature_value_node: Node<'_, '_>,
algorithm: SignatureAlgorithm,
resources: &crate::policy::ResourcePolicy,
) -> Result<Vec<u8>, SignatureVerificationPipelineError> {
if matches!(algorithm, SignatureAlgorithm::PostQuantum(_)) {
let (maximum, raw_maximum) = signature_value_limits(algorithm, resources);
// PQ signatures can be tens of kilobytes: borrow their XML text and
// stream directly into the decoded allocation, without a second
// full-size normalized Base64 buffer.
let payload = super::whitespace::XmlBase64Payload::bounded(
signature_value_node,
raw_maximum,
maximum,
)
.map_err(
|reason| SignatureVerificationPipelineError::InvalidStructure {
reason: match reason {
"unexpected nested element" => {
"SignatureValue must not contain element children"
}
"maximum allowed text length" => {
"SignatureValue exceeds maximum allowed text length"
}
"maximum allowed base64 length" => {
"SignatureValue exceeds maximum allowed length"
}
_ => "SignatureValue contains malformed Base64",
},
},
)?;
return payload.decode().map_err(|_| {
SignatureVerificationPipelineError::InvalidStructure {
reason: "SignatureValue contains malformed Base64",
}
});
}
if signature_value_node
.children()
.any(|child| child.is_element())
{
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "SignatureValue must not contain element children",
});
}
// Count bounded borrowed text first: comments may split a
// signature into many tiny text nodes. Reserving per node would repeatedly
// copy the accumulated payload; one exact reservation keeps this linear.
let (maximum, raw_maximum) = signature_value_limits(algorithm, resources);
let mut text_len = 0usize;
let mut normalized_len = 0usize;
for child in signature_value_node
.children()
.filter(|child| child.is_text())
{
if let Some(text) = child.text() {
if text.len() > raw_maximum - text_len {
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "SignatureValue exceeds maximum allowed text length",
});
}
text_len += text.len();
for byte in text.bytes() {
if matches!(byte, b' ' | b'\t' | b'\r' | b'\n') {
continue;
}
if normalized_len == maximum {
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "SignatureValue exceeds maximum allowed length",
});
}
normalized_len += 1;
}
}
}
let mut normalized = Vec::with_capacity(normalized_len);
let mut raw_text_len = 0usize;
for child in signature_value_node
.children()
.filter(|child| child.is_text())
{
if let Some(text) = child.text() {
push_normalized_signature_text(
text,
&mut raw_text_len,
&mut normalized,
algorithm,
resources,
)?;
}
}
Ok(base64::engine::general_purpose::STANDARD.decode(normalized)?)
}
fn push_normalized_signature_text(
text: &str,
raw_text_len: &mut usize,
normalized: &mut Vec<u8>,
algorithm: SignatureAlgorithm,
resources: &crate::policy::ResourcePolicy,
) -> Result<(), SignatureVerificationPipelineError> {
let (maximum, raw_maximum) = signature_value_limits(algorithm, resources);
if text.len() > raw_maximum - *raw_text_len {
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "SignatureValue exceeds maximum allowed text length",
});
}
*raw_text_len += text.len();
let additional = text
.bytes()
.filter(|byte| !matches!(byte, b' ' | b'\t' | b'\r' | b'\n'))
.count();
if additional > maximum - normalized.len() {
return Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "SignatureValue exceeds maximum allowed length",
});
}
normalize_xml_base64_bytes(text.as_bytes(), normalized, |_| true).map_err(|err| {
SignatureVerificationPipelineError::SignatureValueBase64(base64::DecodeError::InvalidByte(
err.normalized_offset,
err.invalid_byte,
))
})?;
Ok(())
}
fn signature_value_limits(
algorithm: SignatureAlgorithm,
resources: &crate::policy::ResourcePolicy,
) -> (usize, usize) {
// FIPS 205 table 2 includes signatures up to 49,856 octets. The old
// RSA-oriented encoded-text ceiling is not their wire contract.
// https://doi.org/10.6028/NIST.FIPS.205
// XSD 1.0 section 3.2.16 fixes base64Binary whitespace to collapse:
// https://www.w3.org/TR/2004/REC-xmlschema-2-20041028/#base64Binary
// Raw text is bounded by the operation's XML budget, not the wire width.
let encoded = match algorithm {
SignatureAlgorithm::PostQuantum(parameter) => parameter.signature_len().div_ceil(3) * 4,
_ => MAX_SIGNATURE_VALUE_LEN,
};
(encoded, resources.max_xml_document_bytes)
}
fn verify_with_algorithm(
algorithm: SignatureAlgorithm,
public_key_pem: &str,
signed_data: &[u8],
signature_value: &[u8],
) -> Result<bool, SignatureVerificationPipelineError> {
match algorithm {
#[cfg(feature = "legacy-algorithms")]
SignatureAlgorithm::RsaMd5 | SignatureAlgorithm::RsaRipemd160 => Ok(
verify_rsa_signature_pem(algorithm, public_key_pem, signed_data, signature_value)?,
),
#[cfg(feature = "legacy-algorithms")]
SignatureAlgorithm::EcdsaRipemd160 => Ok(verify_ecdsa_signature_pem(
algorithm,
public_key_pem,
signed_data,
signature_value,
)?),
#[cfg(feature = "legacy-algorithms")]
SignatureAlgorithm::HmacMd5 | SignatureAlgorithm::HmacRipemd160 => {
Err(SignatureVerificationError::UnsupportedAlgorithm {
uri: algorithm.uri().to_owned(),
}
.into())
}
SignatureAlgorithm::Ed25519
| SignatureAlgorithm::Ed25519Ctx
| SignatureAlgorithm::Ed25519Ph
| SignatureAlgorithm::Ed448
| SignatureAlgorithm::Ed448Ph
| SignatureAlgorithm::PostQuantum(_) => {
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(super::modern::verify(
algorithm,
&pem.contents,
signed_data,
signature_value,
)?)
}
SignatureAlgorithm::DsaSha1 | SignatureAlgorithm::DsaSha256 => {
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
| SignatureAlgorithm::HmacSha224
| SignatureAlgorithm::HmacSha256
| SignatureAlgorithm::HmacSha384
| SignatureAlgorithm::HmacSha512 => Err(SignatureVerificationError::UnsupportedAlgorithm {
uri: algorithm.uri().to_string(),
}
.into()),
SignatureAlgorithm::RsaSha1
| SignatureAlgorithm::RsaSha224
| SignatureAlgorithm::RsaSha256
| SignatureAlgorithm::RsaSha384
| SignatureAlgorithm::RsaSha512
| SignatureAlgorithm::RsaPssSha1
| SignatureAlgorithm::RsaPssSha224
| SignatureAlgorithm::RsaPssSha256
| SignatureAlgorithm::RsaPssSha384
| SignatureAlgorithm::RsaPssSha512
| SignatureAlgorithm::RsaPssSha3_224
| SignatureAlgorithm::RsaPssSha3_256
| SignatureAlgorithm::RsaPssSha3_384
| SignatureAlgorithm::RsaPssSha3_512
| SignatureAlgorithm::RsaPss(_) => Ok(verify_rsa_signature_pem(
algorithm,
public_key_pem,
signed_data,
signature_value,
)?),
SignatureAlgorithm::EcdsaSha1
| SignatureAlgorithm::EcdsaSha224
| SignatureAlgorithm::EcdsaSha256
| SignatureAlgorithm::EcdsaSha384
| SignatureAlgorithm::EcdsaSha512
| SignatureAlgorithm::EcdsaSha3_224
| SignatureAlgorithm::EcdsaSha3_256
| SignatureAlgorithm::EcdsaSha3_384
| SignatureAlgorithm::EcdsaSha3_512 => {
// 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.
match verify_ecdsa_signature_pem(
algorithm,
public_key_pem,
signed_data,
signature_value,
) {
Ok(valid) => Ok(valid),
Err(SignatureVerificationError::InvalidSignatureFormat) => Ok(false),
Err(error) => Err(error.into()),
}
}
}
}
#[cfg(test)]
#[expect(clippy::unwrap_used, reason = "tests use trusted XML fixtures")]
mod tests {
use super::*;
use crate::c14n::C14nAlgorithm;
use crate::xml::dom::Document;
use crate::xmldsig::TransformError;
use crate::xmldsig::digest::DigestAlgorithm;
use crate::xmldsig::parse::{Reference, parse_signed_info};
use crate::xmldsig::transforms::Transform;
use crate::xmldsig::uri::UriReferenceResolver;
use base64::Engine;
// ── Helpers ──────────────────────────────────────────────────────
/// Build a Reference with given URI, transforms, digest method, and expected digest.
fn make_reference(
uri: &str,
transforms: Vec<Transform>,
digest_method: DigestAlgorithm,
digest_value: Vec<u8>,
) -> Reference {
Reference {
uri: Some(uri.to_string()),
id: None,
ref_type: None,
transforms,
digest_method,
digest_value,
}
}
#[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#"<root xml:base="https://example.test/base/" xmlns:ds="{XMLDSIG_NS}">
<ds:Signature><ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<ds:Reference xml:base="one/" URI="payload.bin">
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue>{first_digest}</ds:DigestValue>
</ds:Reference>
<ds:Reference xml:base="../two/" URI="payload.bin">
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue>{second_digest}</ds:DigestValue>
</ds:Reference>
</ds:SignedInfo><ds:SignatureValue>AA==</ds:SignatureValue></ds:Signature>
</root>"#
);
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 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"<!DOCTYPE payload [<!ELEMENT payload (#PCDATA)>]><payload>ok</payload>";
let digest = base64::engine::general_purpose::STANDARD.encode(compute_digest(
DigestAlgorithm::Sha256,
b"<payload>ok</payload>",
));
let xml = format!(
r#"<root xmlns:ds="{XMLDSIG_NS}">
<ds:Signature>
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<ds:Reference URI="urn:detached-dtd">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue>{digest}</ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue>AQ==</ds:SignatureValue>
</ds:Signature>
</root>"#
);
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#"<!DOCTYPE payload [
<!ENTITY ext SYSTEM "file:///etc/passwd">
]><payload>&ext;</payload>"#;
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 owned_verification_revalidates_internal_dtd_provenance() {
// A document accepted by one permissive operation must not bypass a
// later verification context's stricter XML input policy.
let document = XmlDocument::parse_with_settings(
"<!DOCTYPE root [<!ENTITY value \"ok\">]><root>&value;</root>".into(),
DocumentParseSettings::new(
true,
crate::hard_limits::XML_DOCUMENT_NODE_CEILING,
crate::hard_limits::XML_DOCUMENT_BYTE_CEILING,
),
)
.expect("explicitly permitted DTD fixture must parse");
assert!(matches!(
VerifyContext::new().verify_document(&document),
Err(DsigError::Policy(
crate::policy::PolicyViolation::XmlInput {
reason: "owned document requires internal DTD support"
}
))
));
assert!(!matches!(
VerifyContext::new()
.allow_internal_dtd(true)
.verify_document(&document),
Err(DsigError::Policy(
crate::policy::PolicyViolation::XmlInput { .. }
))
));
}
#[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#"<root xmlns:ds="{XMLDSIG_NS}"><payload>{}</payload><ds:Signature><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI=""><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>{digest}</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>AQ==</ds:SignatureValue></ds:Signature></root>"#,
"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::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::CANONICALIZED_BYTES,
maximum: 64,
..
}
)
),
"unexpected error: {error:?}"
);
}
#[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!("<root>{}</root>", "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: crate::policy::resource_name::XML_DOCUMENT,
maximum,
actual,
}
)) if maximum == xml.len() - 1 && actual == xml.len()
));
}
#[test]
fn verification_entry_points_enforce_policy_depth() {
// Both borrowed XML and a retained document parsed under wider defaults
// must be rejected before signature selection traverses the tree.
let xml = "<root><child><leaf/></child></root>";
let policy = crate::policy::VerificationPolicy {
resources: crate::policy::ResourcePolicy {
max_xml_depth: 2,
..crate::policy::ResourcePolicy::default()
},
..crate::policy::VerificationPolicy::default()
};
let document = XmlDocument::parse(xml).expect("wide retained fixture must parse");
assert!(matches!(
VerifyContext::new().policy(policy.clone()).verify(xml),
Err(DsigError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::XML_DEPTH,
maximum: 2,
actual: 3,
}
))
));
assert!(matches!(
VerifyContext::new()
.policy(policy)
.verify_document(&document),
Err(DsigError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::XML_DEPTH,
maximum: 2,
actual: 3,
}
))
));
}
#[test]
fn verification_policy_bounds_base64_transform_input() {
// The operation snapshot must reach the transform executor rather than
// silently falling back to its implementation-wide default budget.
let digest = base64::engine::general_purpose::STANDARD.encode([0_u8; 32]);
let xml = format!(
r##"<root xmlns:ds="{XMLDSIG_NS}"><payload ID="payload">QUJDRA==</payload><ds:Signature><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI="#payload"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#base64"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>{digest}</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>AQ==</ds:SignatureValue></ds:Signature></root>"##
);
let policy = crate::policy::VerificationPolicy {
resources: crate::policy::ResourcePolicy {
max_base64_transform_input_bytes: 4,
..crate::policy::ResourcePolicy::default()
},
..crate::policy::VerificationPolicy::default()
};
let error = VerifyContext::new()
.key(&AcceptingKey)
.policy(policy)
.verify(&xml)
.expect_err("Base64 input must use the operation policy ceiling");
assert!(matches!(
error,
SignatureVerificationPipelineError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::BASE64_TRANSFORM_INPUT_BYTES,
maximum: 4,
..
}
)
));
}
#[test]
fn verification_policy_bounds_cumulative_base64_transform_output() {
// References share one operation budget. Validating each decoded value
// against the full ceiling would let a signature multiply output work.
let first_digest = base64::engine::general_purpose::STANDARD
.encode(compute_digest(DigestAlgorithm::Sha256, b"a"));
let second_digest = base64::engine::general_purpose::STANDARD
.encode(compute_digest(DigestAlgorithm::Sha256, b"b"));
let xml = format!(
r##"<root xmlns:ds="{XMLDSIG_NS}"><first ID="first">YQ==</first><second ID="second">Yg==</second><ds:Signature><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI="#first"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#base64"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>{first_digest}</ds:DigestValue></ds:Reference><ds:Reference URI="#second"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#base64"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>{second_digest}</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>AQ==</ds:SignatureValue></ds:Signature></root>"##
);
let policy = crate::policy::VerificationPolicy {
resources: crate::policy::ResourcePolicy {
max_base64_transform_input_bytes: 8,
max_base64_transform_output_bytes: 1,
..crate::policy::ResourcePolicy::default()
},
..crate::policy::VerificationPolicy::default()
};
let error = VerifyContext::new()
.key(&AcceptingKey)
.policy(policy)
.verify(&xml)
.expect_err("references must share the Base64 output allowance");
assert!(matches!(
error,
SignatureVerificationPipelineError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::BASE64_TRANSFORM_OUTPUT_BYTES,
maximum: 1,
actual: 2,
}
)
));
}
#[test]
fn verification_policy_bounds_xpath_source_before_compilation() {
// XPath parser limits are part of the same operation snapshot as the
// evaluator limits; parsing cannot use a separate hard-coded budget.
let digest = base64::engine::general_purpose::STANDARD.encode([0_u8; 32]);
let xml = format!(
r#"<root xmlns:ds="{XMLDSIG_NS}"><ds:Signature><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI=""><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116"><ds:XPath>true()</ds:XPath></ds:Transform></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>{digest}</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>AQ==</ds:SignatureValue></ds:Signature></root>"#
);
let policy = crate::policy::VerificationPolicy {
resources: crate::policy::ResourcePolicy {
max_xpath_expression_bytes: 4,
..crate::policy::ResourcePolicy::default()
},
..crate::policy::VerificationPolicy::default()
};
let error = VerifyContext::new()
.key(&AcceptingKey)
.policy(policy)
.verify(&xml)
.expect_err("XPath source must use the operation policy ceiling");
assert!(matches!(
error,
SignatureVerificationPipelineError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::XPATH_EXPRESSION_BYTES,
maximum: 4,
..
}
)
));
}
#[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 ID=\"payload\">{payload_text}</payload>");
let digest = base64::engine::general_purpose::STANDARD.encode(compute_digest(
DigestAlgorithm::Sha256,
canonical_payload.as_bytes(),
));
let xml = format!(
r##"<root xmlns:ds="{XMLDSIG_NS}">{canonical_payload}<ds:Signature><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI="#payload"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>{digest}</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>AQ==</ds:SignatureValue></ds:Signature></root>"##
);
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::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: "canonicalized bytes",
maximum: 1_024,
..
}
)
),
"unexpected error: {error:?}"
);
}
#[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##"<root xmlns:ds="{XMLDSIG_NS}"><payload Id="payload">too large</payload><ds:Signature><ds:Object Id="signed-object"><ds:Manifest><ds:Reference URI="#payload"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>{digest}</ds:DigestValue></ds:Reference><ds:Reference URI="urn:small"><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>{digest}</ds:DigestValue></ds:Reference></ds:Manifest></ds:Object></ds:Signature></root>"##
);
let resources = HashMap::from([("urn:small".to_owned(), b"small".to_vec())]);
let transform_budget = TransformExecutionBudget::with_c14n_limit(8);
let ctx = VerifyContext::new()
.allowed_uri_types(UriTypeSet::ALL)
.external_resources(&resources);
let document = XmlDocument::parse(xml).expect("test signature must parse");
let results = document
.with_view(|view| {
let signature = view
.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 resolver = UriReferenceResolver::with_document_view(view, &[])
.with_external_resources(&resources);
let budgets =
VerificationOperationBudgets::with_transforms(&ctx.policy, transform_budget);
let mut operation = OperationExecutionContext::new(
&ctx.policy,
&budgets,
Some((view.identity(), view.generation())),
);
let crypto =
operation.add_node(OperationNodeKind::Crypto, OperationStage::Crypto, None);
operation.compile().expect("base plan");
operation
.run(crypto, || Ok::<_, SignatureVerificationPipelineError>(()))
.expect("crypto gate");
operation.authenticate(view.node_identity(object));
process_authenticated_manifest_references(
&mut operation,
view,
signature,
&resolver,
&ctx,
2,
crypto,
)
.map(|(results, _)| results)
})
.expect("resource exhaustion is reported per Manifest reference");
assert_eq!(results.len(), 2);
assert!(results.iter().all(|result| {
matches!(
result.status,
DsigStatus::Invalid(FailureReason::ReferencePolicyViolation { .. })
)
}));
}
#[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!("<payload>{}</payload>", "<n/>".repeat(32));
let digest = base64::engine::general_purpose::STANDARD.encode([0_u8; 32]);
let xml = format!(
r#"<root xmlns:ds="{XMLDSIG_NS}"><ds:Signature><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI="urn:detached-nodes"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116"><ds:XPath>true()</ds:XPath></ds:Transform></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>{digest}</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>AQ==</ds:SignatureValue></ds:Signature></root>"#
);
let resources = HashMap::from([("urn:detached-nodes".to_owned(), detached.into_bytes())]);
let policy = crate::policy::VerificationPolicy {
uris: crate::policy::UriPolicy {
references: UriTypeSet::ALL,
..crate::policy::UriPolicy::default()
},
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::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::XML_NODES,
maximum: 24,
..
}
)
),
"unexpected error: {error:?}"
);
}
#[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#"<ds:Signature xmlns:ds="{XMLDSIG_NS}"><ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<ds:Reference xml:base="a/b?old" URI="?new">
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue>{digest}</ds:DigestValue>
</ds:Reference>
</ds:SignedInfo><ds:SignatureValue>AA==</ds:SignatureValue></ds:Signature>"#
);
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
// 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#"<ds:Signature xmlns:ds="{XMLDSIG_NS}" xml:base="https://example.test/">
<ds:Object><ds:Manifest xml:base="manifests/">
<ds:Reference URI="payload.bin">
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue>{digest}</ds:DigestValue>
</ds:Reference>
</ds:Manifest></ds:Object>
</ds:Signature>"#
);
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);
}
#[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#"<ds:Signature xmlns:ds="{XMLDSIG_NS}" xml:base="https://example.test/">
<ds:Object><wrapper><ds:Manifest xml:base="nested/">
<ds:Reference URI="payload.bin">
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue>{digest}</ds:DigestValue>
</ds:Reference>
</ds:Manifest></wrapper></ds:Object>
<ds:Object><ds:Manifest xml:base="direct/">
<ds:Reference URI="payload.bin">
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue>{digest}</ds:DigestValue>
</ds:Reference>
</ds:Manifest></ds:Object>
</ds:Signature>"#
);
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 {
fn verify(
&self,
_algorithm: SignatureAlgorithm,
_signed_data: &[u8],
_signature_value: &[u8],
) -> Result<bool, SignatureVerificationPipelineError> {
Ok(false)
}
}
struct AcceptingKey;
impl VerifyingKey for AcceptingKey {
fn verify(
&self,
_algorithm: SignatureAlgorithm,
_signed_data: &[u8],
_signature_value: &[u8],
) -> Result<bool, SignatureVerificationPipelineError> {
Ok(true)
}
}
struct PanicResolver;
struct UntrustedTestResolver;
impl KeyResolver for UntrustedTestResolver {
fn resolve<'a>(
&'a self,
_key_info: Option<&KeyInfo>,
_algorithm: SignatureAlgorithm,
) -> Result<Option<Box<dyn VerifyingKey + 'a>>, DsigError> {
Ok(Some(Box::new(AcceptingKey)))
}
}
#[test]
fn verification_requires_explicit_key_authorization() {
// A resolver finding a working key must not authorize its signer.
let xml = signature_with_target_reference("AQ==");
let error = VerifyContext::new()
.key_resolver(&UntrustedTestResolver)
.verify(&xml)
.expect_err("default verification must reject an untrusted resolver candidate");
assert!(matches!(
error,
DsigError::Policy(crate::policy::PolicyViolation::KeyTrust { .. })
));
}
#[test]
fn cryptographic_only_is_explicit_and_never_reports_trust() {
// Opting out of signer authorization must remain visible in the result.
let mut policy = crate::policy::VerificationPolicy::default();
policy.key_trust.mode = crate::policy::VerificationTrustMode::CryptographicOnly;
let result = VerifyContext::new()
.policy(policy)
.key_resolver(&UntrustedTestResolver)
.verify(&signature_with_target_reference("AQ=="))
.expect("explicit mathematical mode permits an untrusted candidate");
assert_eq!(result.status, DsigStatus::Valid);
assert_eq!(
result.key_trust,
super::super::KeyTrustEvidence::NotEstablished
);
}
#[test]
fn caller_key_authorization_does_not_authenticate_an_invalid_signature() {
// A trusted key is not evidence that the signature itself verified.
let result = VerifyContext::new()
.key(&RejectingKey)
.verify(&signature_with_target_reference("AQ=="))
.expect("an invalid mathematical signature is a completed result");
assert_eq!(
result.status,
DsigStatus::Invalid(FailureReason::SignatureMismatch)
);
assert_eq!(
result.key_trust,
super::super::KeyTrustEvidence::CallerTrusted
);
}
impl KeyResolver for PanicResolver {
fn resolve<'a>(
&'a self,
_key_info: Option<&KeyInfo>,
_algorithm: SignatureAlgorithm,
) -> Result<Option<Box<dyn VerifyingKey + 'a>>, SignatureVerificationPipelineError>
{
panic!("resolver should not be called when references already fail");
}
}
struct MissingKeyResolver;
impl KeyResolver for MissingKeyResolver {
fn resolve<'a>(
&'a self,
_key_info: Option<&KeyInfo>,
_algorithm: SignatureAlgorithm,
) -> Result<Option<Box<dyn VerifyingKey + 'a>>, SignatureVerificationPipelineError>
{
Ok(None)
}
}
struct ConsumingKeyInfoResolver;
impl KeyResolver for ConsumingKeyInfoResolver {
fn resolve<'a>(
&'a self,
_key_info: Option<&KeyInfo>,
_algorithm: SignatureAlgorithm,
) -> Result<Option<Box<dyn VerifyingKey + 'a>>, SignatureVerificationPipelineError>
{
Ok(None)
}
fn consumes_document_key_info(&self) -> bool {
true
}
}
struct FallbackKeyInfoResolver;
impl KeyResolver for FallbackKeyInfoResolver {
fn resolve_for_verification<'a>(
&'a self,
key_info: Option<&KeyInfo>,
algorithm: SignatureAlgorithm,
_policy: &crate::policy::VerificationPolicy,
_provider: &dyn crate::provider::CryptoProvider,
) -> Result<Option<super::super::ResolvedVerificationKey<'a>>, DsigError> {
// This test resolver explicitly authorizes its application-owned key.
self.resolve(key_info, algorithm)
.map(|key| key.map(super::super::ResolvedVerificationKey::CallerTrusted))
}
fn resolve<'a>(
&'a self,
key_info: Option<&KeyInfo>,
_algorithm: SignatureAlgorithm,
) -> Result<Option<Box<dyn VerifyingKey + 'a>>, 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
}
}
struct EarlyKeyInfoResolver;
impl KeyResolver for EarlyKeyInfoResolver {
fn resolve_for_verification<'a>(
&'a self,
key_info: Option<&KeyInfo>,
algorithm: SignatureAlgorithm,
_policy: &crate::policy::VerificationPolicy,
_provider: &dyn crate::provider::CryptoProvider,
) -> Result<Option<super::super::ResolvedVerificationKey<'a>>, DsigError> {
// Only the selected primary application key is authorized here.
self.resolve(key_info, algorithm)
.map(|key| key.map(super::super::ResolvedVerificationKey::CallerTrusted))
}
fn resolve<'a>(
&'a self,
key_info: Option<&KeyInfo>,
_algorithm: SignatureAlgorithm,
) -> Result<Option<Box<dyn VerifyingKey + 'a>>, 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#"<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<ds:Reference URI="{reference_uri}">
{transforms_xml}
<ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
<ds:DigestValue>AAAAAAAAAAAAAAAAAAAAAAAAAAA=</ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue>AQ==</ds:SignatureValue>
</ds:Signature>"#
)
}
#[test]
fn verify_all_retains_each_signature_without_vacuous_success() {
// Malformed signatures remain individually visible; an empty document
// never satisfies a request merely because there were no failures.
let document = XmlDocument::parse(format!(
"<root xmlns:ds=\"{XMLDSIG_NS}\"><ds:Signature/><ds:Signature/></root>"
))
.unwrap();
let evidence = VerifyContext::new().verify_all(&document).unwrap();
assert_eq!(evidence.signatures().len(), 2);
assert!(
evidence
.signatures()
.iter()
.all(|entry| entry.result().is_err())
);
assert!(!evidence.all_valid());
let empty = XmlDocument::parse("<root/>").unwrap();
assert!(!VerifyContext::new().verify_all(&empty).unwrap().all_valid());
}
#[test]
fn verify_all_checks_cardinality_before_running_keys() {
// The cardinality policy bounds report allocation and crypto work,
// including malformed signatures that never reach core validation.
let document = XmlDocument::parse(format!(
"<root xmlns:ds=\"{XMLDSIG_NS}\"><ds:Signature/><ds:Signature/></root>"
))
.unwrap();
let mut policy = crate::policy::VerificationPolicy::default();
policy.resources.max_signatures = 1;
assert!(matches!(
VerifyContext::new().policy(policy).verify_all(&document),
Err(DsigError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: "verification signatures",
maximum: 1,
actual: 2,
}
))
));
}
#[test]
fn verification_signature_limit_also_gates_the_single_api() {
// The same compiled resource policy must apply to both public paths.
let mut policy = crate::policy::VerificationPolicy::default();
policy.resources.max_signatures = 0;
assert!(matches!(
VerifyContext::new()
.policy(policy)
.key(&AcceptingKey)
.verify(&signature_with_target_reference("AQ==")),
Err(DsigError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: "verification signatures",
maximum: 0,
actual: 1,
}
))
));
}
#[test]
fn verify_all_preserves_generation_and_rejects_wrapped_siblings() {
// A signature over one element never authenticates a lookalike sibling.
let xml = signature_with_target_reference("AQ==")
.replace("</root>", "<target ID=\"unsigned\">payload</target></root>");
let document = XmlDocument::parse(xml).unwrap();
let (signed, unsigned) = document.with_view(|view| {
(
view.node_for_id("target", &[]).unwrap(),
view.node_for_id("unsigned", &[]).unwrap(),
)
});
let evidence = VerifyContext::new()
.key(&AcceptingKey)
.verify_all(&document)
.unwrap();
assert!(evidence.all_valid());
assert!(evidence.covers_element(&document, signed).unwrap());
assert!(!evidence.covers_element(&document, unsigned).unwrap());
let foreign = XmlDocument::parse("<target/>").unwrap();
let target = foreign.with_view(|view| view.root_element());
assert!(matches!(
evidence.covers_element(&document, target),
Err(DsigError::Document(XmlDocumentError::ForeignIdentity))
));
}
#[test]
fn verify_request_rejects_duplicate_targets_before_crypto() {
// Ambiguous request mapping is not a successful duplicate assertion.
let document = XmlDocument::parse(signature_with_target_reference("AQ==")).unwrap();
let target = document.with_view(|view| view.node_for_id("target", &[]).unwrap());
let targets = [target, target];
let request = VerificationRequest {
expected_targets: &targets,
..VerificationRequest::default()
};
assert!(matches!(
VerifyContext::new().verify_request(&document, &request),
Err(DsigError::InvalidRequest {
reason: "duplicate expected coverage target"
})
));
}
#[test]
fn verify_all_shares_canonicalization_retention_budget() {
// Repeating a Signature may not reset the retained-data allowance.
let xml = signature_with_target_reference("AQ==");
let single = VerifyContext::new()
.key(&AcceptingKey)
.store_pre_digest(true)
.verify(&xml)
.unwrap();
let bytes = single.canonicalized_signed_info.unwrap().len()
+ single.signed_info_references[0]
.pre_digest_data
.as_ref()
.unwrap()
.len();
let parsed = Document::parse(&xml).unwrap();
let signature = parsed
.descendants()
.find(|node| node.has_tag_name((XMLDSIG_NS, "Signature")))
.unwrap();
let xml = xml.replace("</root>", &format!("{}</root>", &xml[signature.range()]));
let document = XmlDocument::parse(xml).unwrap();
let mut policy = crate::policy::VerificationPolicy::default();
policy.resources.max_canonicalized_bytes = bytes * 2 - 1;
let evidence = VerifyContext::new()
.key(&AcceptingKey)
.store_pre_digest(true)
.policy(policy)
.verify_all(&document)
.unwrap();
assert_eq!(evidence.signatures.len(), 2);
assert_eq!(
evidence.signatures[0].result().as_ref().unwrap().status,
DsigStatus::Valid
);
assert!(matches!(
evidence.signatures[1].result(),
Err(DsigError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: "canonicalized bytes",
..
}
))
));
assert!(!evidence.accepted(&document).unwrap());
}
#[test]
fn verify_all_manifest_coverage_requires_its_authenticated_digest() {
// A valid enclosing Signature authenticates the Manifest declaration,
// not a target whose separately checked Manifest digest mismatches.
for valid in [true, false] {
let document = XmlDocument::parse(signature_with_manifest_xml(valid)).unwrap();
let target = document.with_view(|view| view.node_for_id("target", &[]).unwrap());
let evidence = VerifyContext::new()
.key(&AcceptingKey)
.process_manifests(true)
.verify_all(&document)
.unwrap();
let result = evidence.signatures()[0].result().as_ref().unwrap();
assert_eq!(result.manifest_references.len(), 1);
assert!(result.manifest_references[0].reference_identity.is_some());
assert_eq!(evidence.covers_element(&document, target).unwrap(), valid);
assert_eq!(evidence.accepted(&document).unwrap(), valid);
}
}
#[test]
fn verify_all_filtered_digest_does_not_claim_original_subtree() {
// A projection may discard content even when its local digest matches.
// No arbitrary filter is promoted to complete original-node coverage.
let mut reference = make_reference(
"#target",
vec![Transform::XpathExcludeAllSignatures],
DigestAlgorithm::Sha256,
vec![],
);
let document = XmlDocument::parse(signature_with_target_reference("AQ==")).unwrap();
document.with_view(|view| {
let target = view.node_for_id("target", &[]).unwrap();
let mut result = manifest_reference_invalid_result(
&reference,
0,
FailureReason::ReferenceProcessingFailure { ref_index: 0 },
);
result.status = DsigStatus::Valid;
bind_reference_evidence(
&mut result,
&reference,
view,
None,
&OperationResourceIdentity::DocumentNode(target),
);
assert_eq!(result.target_identity, Some(target));
assert_eq!(result.coverage, ReferenceCoverage::TransformedData);
reference.transforms = vec![Transform::C14n(crate::c14n::C14nAlgorithm::new(
crate::c14n::C14nMode::Exclusive1_0,
false,
))];
bind_reference_evidence(
&mut result,
&reference,
view,
None,
&OperationResourceIdentity::DocumentNode(target),
);
assert_eq!(result.coverage, ReferenceCoverage::ElementContent);
});
}
#[test]
fn verify_request_rejects_conflicting_resource_owners() {
// There is no implicit override between request bytes and old builder
// configuration: ownership must be explicit before resolver work.
let document = XmlDocument::parse("<root/>").unwrap();
let resources = HashMap::new();
let request = VerificationRequest {
external_resources: Some(&resources),
..VerificationRequest::default()
};
assert!(matches!(
VerifyContext::new()
.external_resources(&resources)
.verify_request(&document, &request),
Err(DsigError::InvalidRequest {
reason: "external resources configured in both context and request"
})
));
}
fn signature_with_target_reference(signature_value_b64: &str) -> String {
let xml_template = r##"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<target ID="target">payload</target>
<ds:Signature>
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<ds:Reference URI="#target">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
<ds:DigestValue>AAAAAAAAAAAAAAAAAAAAAAAAAAA=</ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue>SIGNATURE_VALUE_PLACEHOLDER</ds:SignatureValue>
</ds:Signature>
</root>"##;
let doc = Document::parse(xml_template).unwrap();
let sig_node = doc
.descendants()
.find(|node| node.is_element() && node.tag_name().name() == "Signature")
.unwrap();
let signed_info_node = sig_node
.children()
.find(|node| node.is_element() && node.tag_name().name() == "SignedInfo")
.unwrap();
let signed_info = parse_signed_info(signed_info_node).unwrap();
let reference = &signed_info.references[0];
let resolver = UriReferenceResolver::new(&doc);
let initial_data = resolver
.dereference(reference.uri.as_deref().unwrap())
.unwrap();
let pre_digest =
crate::xmldsig::execute_transforms(sig_node, initial_data, &reference.transforms)
.unwrap();
let digest = compute_digest(reference.digest_method, &pre_digest);
let digest_b64 = base64::engine::general_purpose::STANDARD.encode(digest);
xml_template
.replace("AAAAAAAAAAAAAAAAAAAAAAAAAAA=", &digest_b64)
.replace("SIGNATURE_VALUE_PLACEHOLDER", signature_value_b64)
}
#[test]
fn verify_context_reports_key_not_found_status_without_key_or_resolver() {
let xml = signature_with_target_reference("AQ==");
let result = VerifyContext::new()
.verify(&xml)
.expect("missing key config must be reported as verification status");
assert!(
matches!(
result.status,
DsigStatus::Invalid(FailureReason::KeyNotFound)
),
"unexpected status: {:?}",
result.status
);
}
#[test]
fn verify_context_rejects_disallowed_uri() {
let xml = minimal_signature_xml("http://example.com/external", "");
let err = VerifyContext::new()
.key(&RejectingKey)
.verify(&xml)
.expect_err("external URI should be rejected by default policy");
assert!(matches!(
err,
SignatureVerificationPipelineError::Policy(crate::policy::PolicyViolation::Uri {
operation: "verification",
..
})
));
}
#[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#"<n xml:base="segment/">{xml}</n>"#);
}
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!(matches!(
error,
SignatureVerificationPipelineError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::XML_BASE_COMPONENTS,
maximum: 64,
actual: 65,
}
)
));
}
#[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#"<n xml:base="segment/">{xml}</n>"#);
}
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::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::XML_BASE_RESOLUTION_BYTES,
maximum: 32,
..
}
)
));
}
#[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(
" <ds:Signature>",
" <outer xml:base=\"one/\"><inner xml:base=\"two/\"><ds:Signature>",
)
.replace(" </ds:Signature>", " </ds:Signature></inner></outer>");
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::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::XML_BASE_COMPONENTS,
maximum: 1,
actual: 2,
}
)
));
}
#[test]
fn verify_context_classifies_signed_info_xml_base_byte_limit_as_policy() {
// SignedInfo C14N 1.1 XML Base work is policy enforcement, not a
// malformed canonicalization request, and must retain typed diagnostics.
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(
" <ds:Signature>",
" <outer xml:base=\"segment/\"><ds:Signature>",
)
.replace(" </ds:Signature>", " </ds:Signature></outer>");
let policy = crate::policy::VerificationPolicy {
resources: crate::policy::ResourcePolicy {
max_xml_base_resolution_bytes: 1,
..crate::policy::ResourcePolicy::default()
},
..crate::policy::VerificationPolicy::default()
};
let error = VerifyContext::new()
.key(&AcceptingKey)
.policy(policy)
.verify(&xml)
.expect_err("SignedInfo XML Base byte exhaustion must be a policy error");
assert!(matches!(
error,
SignatureVerificationPipelineError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::XML_BASE_RESOLUTION_BYTES,
maximum: 1,
actual,
}
) if actual > 1
));
}
#[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#"<ds:Reference URI="urn:payload"><ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><ds:DigestValue>{digest}</ds:DigestValue></ds:Reference>"#
);
let xml = format!(
r#"<ds:Signature xmlns:ds="{XMLDSIG_NS}"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>{reference}{reference}</ds:SignedInfo><ds:SignatureValue>AQ==</ds:SignatureValue></ds:Signature>"#
);
let resources = HashMap::from([("urn:payload".to_owned(), payload.to_vec())]);
let policy = crate::policy::VerificationPolicy {
uris: crate::policy::UriPolicy {
references: UriTypeSet::ALL,
..crate::policy::UriPolicy::default()
},
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("", "");
let err = VerifyContext::new()
.key(&RejectingKey)
.allowed_uri_types(UriTypeSet::new(false, true, false))
.verify(&xml)
.expect_err("empty URI must be rejected when empty references are disabled");
assert!(matches!(
err,
SignatureVerificationPipelineError::Policy(crate::policy::PolicyViolation::Uri {
operation: "verification",
..
})
));
}
#[test]
fn verify_context_rejects_disallowed_transform() {
let xml = minimal_signature_xml(
"",
r#"<ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/></ds:Transforms>"#,
);
let err = VerifyContext::new()
.key(&RejectingKey)
.allowed_transforms(["http://www.w3.org/2001/10/xml-exc-c14n#"])
.verify(&xml)
.expect_err("enveloped transform should be rejected by allowlist");
assert!(matches!(
err,
SignatureVerificationPipelineError::Policy(crate::policy::PolicyViolation::Algorithm {
operation: "verification transform",
..
})
));
}
#[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(
"<ds:CanonicalizationMethod Algorithm=\"http://www.w3.org/2001/10/xml-exc-c14n#\"/>",
"<ds:CanonicalizationMethod Algorithm=\"http://www.w3.org/TR/2001/REC-xml-c14n-20010315\"/>",
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::Policy(
crate::policy::PolicyViolation::Algorithm {
operation: "verification transform",
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(
"</ds:Signature>",
r##"<ds:KeyInfo><ds:RetrievalMethod URI="#keys" Type="http://www.w3.org/2000/09/xmldsig#X509Data"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116"><ds:XPath>ancestor-or-self::ds:X509Data</ds:XPath></ds:Transform></ds:Transforms></ds:RetrievalMethod></ds:KeyInfo></ds:Signature>"##,
1,
)
.replacen(
"</root>",
r#"<holder ID="keys"><ds:X509Data><ds:X509SubjectName>CN=leaf</ds:X509SubjectName></ds:X509Data></holder></root>"#,
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::Policy(
crate::policy::PolicyViolation::Algorithm {
operation: "verification transform",
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)
}
fn signature_with_manifest_xml_with_manifest_mutation<F>(
valid_manifest_digest: bool,
mutate_manifest: F,
) -> String
where
F: FnOnce(String) -> String,
{
const TMP_SIGNED_INFO_DIGEST: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAA=";
const INVALID_MANIFEST_DIGEST: &str = "//////////////////////////8=";
let xml_template = r##"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<target ID="target">payload</target>
<ds:Signature>
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<ds:Reference URI="#manifest">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
<ds:DigestValue>SIGNEDINFO_OBJECT_DIGEST_PLACEHOLDER</ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue>AQ==</ds:SignatureValue>
<ds:Object>
<ds:Manifest ID="manifest">
<ds:Reference URI="#target">
<ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
<ds:DigestValue>MANIFEST_DIGEST_PLACEHOLDER</ds:DigestValue>
</ds:Reference>
</ds:Manifest>
</ds:Object>
</ds:Signature>
</root>"##;
let seed_xml = xml_template.replace(
"SIGNEDINFO_OBJECT_DIGEST_PLACEHOLDER",
TMP_SIGNED_INFO_DIGEST,
);
let doc = Document::parse(&seed_xml).unwrap();
let signature_node = doc
.descendants()
.find(|node| {
node.is_element()
&& node.tag_name().namespace() == Some(XMLDSIG_NS)
&& node.tag_name().name() == "Signature"
})
.unwrap();
let resolver = UriReferenceResolver::new(&doc);
let initial_data = resolver.dereference("#target").unwrap();
let manifest_pre_digest =
crate::xmldsig::execute_transforms(signature_node, initial_data, &[]).unwrap();
let computed_manifest_digest_b64 = base64::engine::general_purpose::STANDARD
.encode(compute_digest(DigestAlgorithm::Sha1, &manifest_pre_digest));
let final_manifest_digest_b64 = if valid_manifest_digest {
computed_manifest_digest_b64.as_str()
} else {
INVALID_MANIFEST_DIGEST
};
let xml_with_manifest_digest = mutate_manifest(
seed_xml.replace("MANIFEST_DIGEST_PLACEHOLDER", final_manifest_digest_b64),
);
let signed_doc = Document::parse(&xml_with_manifest_digest).unwrap();
let signed_signature_node = signed_doc
.descendants()
.find(|node| {
node.is_element()
&& node.tag_name().namespace() == Some(XMLDSIG_NS)
&& node.tag_name().name() == "Signature"
})
.unwrap();
let signed_info_node = signed_signature_node
.children()
.find(|node| {
node.is_element()
&& node.tag_name().namespace() == Some(XMLDSIG_NS)
&& node.tag_name().name() == "SignedInfo"
})
.unwrap();
let signed_info = parse_signed_info(signed_info_node).unwrap();
let object_reference = &signed_info.references[0];
let signed_resolver = UriReferenceResolver::new(&signed_doc);
let signed_initial_data = signed_resolver
.dereference(object_reference.uri.as_deref().unwrap())
.unwrap();
let signed_pre_digest = crate::xmldsig::execute_transforms(
signed_signature_node,
signed_initial_data,
&object_reference.transforms,
)
.unwrap();
let signed_digest_b64 = base64::engine::general_purpose::STANDARD.encode(compute_digest(
object_reference.digest_method,
&signed_pre_digest,
));
xml_with_manifest_digest.replacen(TMP_SIGNED_INFO_DIGEST, &signed_digest_b64, 1)
}
fn replace_fixture_manifest_digest(xml: &str, replacement: &str) -> String {
let object_marker = "<ds:Object>";
let object_start = xml
.find(object_marker)
.expect("fixture should contain ds:Object")
+ object_marker.len();
let open = "<ds:DigestValue>";
let close = "</ds:DigestValue>";
let value_start = xml[object_start..]
.find(open)
.map(|offset| object_start + offset + open.len())
.expect("Manifest should contain DigestValue");
let value_end = xml[value_start..]
.find(close)
.map(|offset| value_start + offset)
.expect("Manifest DigestValue must be closed");
format!("{}{replacement}{}", &xml[..value_start], &xml[value_end..])
}
#[test]
fn verify_context_processes_manifest_references_when_enabled() {
let xml = signature_with_manifest_xml(true);
let result_without_manifests = VerifyContext::new()
.key(&RejectingKey)
.verify(&xml)
.expect("manifest processing disabled should still verify SignedInfo");
assert!(
result_without_manifests.manifest_references.is_empty(),
"manifest results must stay empty when manifest processing is disabled",
);
assert!(matches!(
result_without_manifests.status,
DsigStatus::Invalid(FailureReason::SignatureMismatch)
));
let malformed_manifest_xml = signature_with_manifest_xml(true).replacen(
"</ds:Object>",
"</ds:Object><ds:Object><ds:Manifest><ds:Foo/></ds:Manifest></ds:Object>",
1,
);
let malformed_with_manifests_disabled = VerifyContext::new()
.key(&RejectingKey)
.verify(&malformed_manifest_xml)
.expect("malformed Manifest must be ignored when manifest processing is disabled");
assert!(
malformed_with_manifests_disabled
.manifest_references
.is_empty(),
"manifest parser must not run when process_manifests is disabled",
);
assert!(matches!(
malformed_with_manifests_disabled.status,
DsigStatus::Invalid(FailureReason::SignatureMismatch)
));
let result_with_manifests = VerifyContext::new()
.key(&AcceptingKey)
.process_manifests(true)
.verify(&xml)
.expect("manifest references should be processed when enabled");
assert_eq!(result_with_manifests.manifest_references.len(), 1);
assert_eq!(
result_with_manifests.manifest_references[0].reference_set,
ReferenceSet::Manifest
);
assert_eq!(
result_with_manifests.manifest_references[0].reference_index,
0
);
assert!(matches!(
result_with_manifests.manifest_references[0].status,
DsigStatus::Valid
));
assert!(matches!(result_with_manifests.status, DsigStatus::Valid));
}
#[test]
fn verify_context_skips_manifest_work_when_signature_value_is_invalid() {
// SignedInfo authenticates the Manifest bytes only after SignatureValue
// succeeds. Malformed nested content must not consume parsing work when
// the cryptographic signature itself is invalid.
let xml = signature_with_manifest_xml_with_manifest_mutation(true, |xml| {
replace_fixture_manifest_digest(&xml, "!!!")
});
assert!(
xml.split_once("<ds:Object>")
.is_some_and(|(_, object)| object.contains("<ds:DigestValue>!!!</ds:DigestValue>")),
"fixture mutation must corrupt the nested Manifest DigestValue",
);
let result = VerifyContext::new()
.key(&RejectingKey)
.process_manifests(true)
.verify(&xml)
.expect("invalid SignatureValue must short-circuit Manifest parsing");
assert!(matches!(
result.status,
DsigStatus::Invalid(FailureReason::SignatureMismatch)
));
assert!(result.manifest_references.is_empty());
}
#[test]
fn verify_context_shares_xpath_parse_budget_with_manifest_references() {
// SignedInfo and every Manifest form one attacker-controlled parse unit:
// splitting expressions across Reference sets must not reset the ceiling.
let filters = r#"<XPath xmlns="http://www.w3.org/2002/06/xmldsig-filter2" Filter="intersect">true()</XPath>"#
.repeat(64);
let transform = format!(
r#"<ds:Transform Algorithm="http://www.w3.org/2002/06/xmldsig-filter2">{filters}</ds:Transform>"#
);
let max_transforms = transform.repeat(16);
let max_manifest_reference = format!(
r##"<ds:Reference URI="#target"><ds:Transforms>{max_transforms}</ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><ds:DigestValue>AAAAAAAAAAAAAAAAAAAAAAAAAAA=</ds:DigestValue></ds:Reference>"##
);
let xml = signature_with_manifest_xml_with_manifest_mutation(true, |xml| {
xml.replacen(
r##"<ds:Reference URI="#target">"##,
&format!(
r##"<ds:Reference URI="#target"><ds:Transforms>{}</ds:Transforms>"##,
max_transforms
),
1,
)
.replacen(
"</ds:SignedInfo>",
r##"<ds:Reference URI="#target"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116"><ds:XPath>false()</ds:XPath></ds:Transform></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><ds:DigestValue>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</ds:DigestValue></ds:Reference></ds:SignedInfo>"##,
1,
)
.replacen(
"</ds:Manifest>",
&format!("{}</ds:Manifest>", max_manifest_reference.repeat(3)),
1,
)
});
let error = VerifyContext::new()
.key(&AcceptingKey)
.process_manifests(true)
.verify(&xml)
.expect_err("SignedInfo and Manifest References must share one XPath parse budget");
assert!(
matches!(
&error,
SignatureVerificationPipelineError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: "XPath expressions",
..
}
)
),
"unexpected error: {error:?}"
);
}
#[test]
fn verify_context_processes_manifest_when_signedinfo_references_object() {
let xml = signature_with_manifest_xml_with_manifest_mutation(true, |xml| {
xml.replacen("URI=\"#manifest\"", "URI=\"#object-id\"", 1)
.replacen("<ds:Object>", "<ds:Object ID=\"object-id\">", 1)
.replacen("<ds:Manifest ID=\"manifest\">", "<ds:Manifest>", 1)
});
let result = VerifyContext::new()
.key(&AcceptingKey)
.process_manifests(true)
.verify(&xml)
.expect("manifest references should be processed when SignedInfo references ds:Object");
assert_eq!(
result.manifest_references.len(),
1,
"signed ds:Object should enable processing of its direct-child ds:Manifest",
);
assert_eq!(
result.manifest_references[0].reference_set,
ReferenceSet::Manifest
);
assert_eq!(result.manifest_references[0].reference_index, 0);
assert!(matches!(
result.manifest_references[0].status,
DsigStatus::Valid
));
}
#[test]
fn verify_context_skips_manifest_removed_by_enveloped_transform() {
// The owning Signature contains both eligible ID targets. Subtracting
// its subtree therefore removes every target node from the digest input,
// so neither form authenticates the Manifest structure for processing.
for target_object in [false, true] {
let xml = signature_with_manifest_xml_with_manifest_mutation(true, |xml| {
let xml = xml.replacen(
r#"<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>"#,
r#"<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>"#,
1,
);
if target_object {
xml.replacen("URI=\"#manifest\"", "URI=\"#object-id\"", 1)
.replacen("<ds:Object>", "<ds:Object ID=\"object-id\">", 1)
.replacen("<ds:Manifest ID=\"manifest\">", "<ds:Manifest>", 1)
} else {
xml
}
});
let result = VerifyContext::new()
.key(&AcceptingKey)
.process_manifests(true)
.store_pre_digest(true)
.verify(&xml)
.expect("an emptied reference remains a valid core digest input");
assert!(matches!(result.status, DsigStatus::Valid));
assert_eq!(
result.signed_info_references[0].pre_digest_data.as_deref(),
Some([].as_slice()),
"target_object={target_object} must have empty transformed bytes",
);
assert!(
result.manifest_references.is_empty(),
"target_object={target_object} must not authenticate the Manifest",
);
}
}
#[test]
fn verify_context_ignores_manifest_excluded_from_signed_object() {
// A Reference URI authenticates only its post-transform bytes. Excluding
// the Manifest subtree must not let its independently valid digest chain
// masquerade as data authenticated by SignedInfo.
let xml = signature_with_manifest_xml_with_manifest_mutation(true, |xml| {
xml.replacen("URI=\"#manifest\"", "URI=\"#object-id\"", 1)
.replacen("<ds:Object>", "<ds:Object ID=\"object-id\">", 1)
.replacen("<ds:Manifest ID=\"manifest\">", "<ds:Manifest>", 1)
.replacen(
r#"<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>"#,
r#"<ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116"><ds:XPath>not(ancestor-or-self::ds:Manifest)</ds:XPath></ds:Transform><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>"#,
1,
)
});
let result = VerifyContext::new()
.key(&AcceptingKey)
.process_manifests(true)
.verify(&xml)
.expect("excluded Manifest content must be ignored, not parsed");
assert!(matches!(result.status, DsigStatus::Valid));
assert!(
result.manifest_references.is_empty(),
"a transform-excluded Manifest is not authenticated by SignedInfo",
);
}
#[test]
fn verify_context_skips_manifest_digest_work_when_signature_is_invalid() {
let xml = signature_with_manifest_xml(false);
let result = VerifyContext::new()
.key(&RejectingKey)
.process_manifests(true)
.verify(&xml)
.expect("invalid SignatureValue must short-circuit Manifest digest work");
assert!(result.manifest_references.is_empty());
assert!(matches!(
result.status,
DsigStatus::Invalid(FailureReason::SignatureMismatch)
));
}
#[test]
fn verify_context_manifest_digest_mismatch_is_non_fatal_with_accepting_key() {
let xml = signature_with_manifest_xml(false);
let result = VerifyContext::new()
.key(&AcceptingKey)
.process_manifests(true)
.verify(&xml)
.expect("manifest digest mismatches should be recorded while signature stays valid");
assert_eq!(result.manifest_references.len(), 1);
assert!(matches!(
result.manifest_references[0].status,
DsigStatus::Invalid(FailureReason::ReferenceDigestMismatch { ref_index: 0 })
));
assert!(matches!(result.status, DsigStatus::Valid));
}
#[test]
fn invalid_outer_manifest_digest_does_not_parse_nested_manifest() {
// The malformed inner Manifest is not authenticated until its parent
// Reference digest succeeds. A mismatch must therefore stop before
// parsing or charging work for the nested structure.
let xml = signature_with_manifest_xml_with_manifest_mutation(false, |xml| {
xml.replacen("URI=\"#target\"", "URI=\"#inner-object\"", 1)
.replacen(
"</ds:Object>",
"</ds:Object><ds:Object ID=\"inner-object\"><ds:Manifest>junk<ds:Unexpected/></ds:Manifest></ds:Object>",
1,
)
});
let result = VerifyContext::new()
.key(&AcceptingKey)
.process_manifests(true)
.verify(&xml)
.expect("an unauthenticated nested Manifest must remain opaque");
assert_eq!(result.manifest_references.len(), 1);
assert!(matches!(
result.manifest_references[0].status,
DsigStatus::Invalid(FailureReason::ReferenceDigestMismatch { ref_index: 0 })
));
}
#[test]
fn verify_context_skips_manifest_parsing_when_signedinfo_reference_fails() {
// Manifest content is not authenticated after a SignedInfo reference
// failure, so parsing it would spend work on untrusted nested input.
let xml = signature_with_manifest_xml(true);
let (signed_info_prefix, object_suffix) = xml
.split_once("<ds:Object>")
.expect("fixture should contain ds:Object");
let open = "<ds:DigestValue>";
let close = "</ds:DigestValue>";
let digest_start = signed_info_prefix
.find(open)
.expect("SignedInfo should contain DigestValue");
let digest_end = signed_info_prefix[digest_start + open.len()..]
.find(close)
.map(|offset| digest_start + open.len() + offset)
.expect("SignedInfo DigestValue must be closed");
let broken_signed_info_prefix = format!(
"{}{}AAAAAAAAAAAAAAAAAAAAAAAAAAA={}{}",
&signed_info_prefix[..digest_start],
open,
close,
&signed_info_prefix[digest_end + close.len()..],
);
let broken_xml = format!("{broken_signed_info_prefix}<ds:Object>{object_suffix}");
let result = VerifyContext::new()
.key(&RejectingKey)
.process_manifests(true)
.verify(&broken_xml)
.expect("SignedInfo digest failure should return without parsing Manifests");
assert!(matches!(
result.status,
DsigStatus::Invalid(FailureReason::ReferenceDigestMismatch { ref_index: 0 })
));
assert!(
result.manifest_references.is_empty(),
"unauthenticated Manifest content must not be parsed",
);
}
#[test]
fn verify_context_skips_manifest_policy_work_when_signature_is_invalid() {
// A digest-valid SignedInfo reference does not authenticate Manifest
// policy inputs until SignatureValue also succeeds.
let broken_xml = signature_with_manifest_xml_with_manifest_mutation(true, |xml| {
xml.replacen("URI=\"#target\"", "URI=\"http://example.com/external\"", 1)
});
let result = VerifyContext::new()
.key(&RejectingKey)
.process_manifests(true)
.verify(&broken_xml)
.expect("invalid SignatureValue must short-circuit Manifest policy work");
assert!(result.manifest_references.is_empty());
assert!(matches!(
result.status,
DsigStatus::Invalid(FailureReason::SignatureMismatch)
));
}
#[test]
fn verify_context_records_manifest_policy_violations_with_accepting_key() {
let broken_xml = signature_with_manifest_xml_with_manifest_mutation(true, |xml| {
xml.replacen("URI=\"#target\"", "URI=\"http://example.com/external\"", 1)
});
let result = VerifyContext::new()
.key(&AcceptingKey)
.process_manifests(true)
.verify(&broken_xml)
.expect("manifest policy violations should be recorded while signature stays valid");
assert_eq!(result.manifest_references.len(), 1);
assert!(matches!(
result.manifest_references[0].status,
DsigStatus::Invalid(FailureReason::ReferencePolicyViolation { ref_index: 0 })
));
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 {
manifest_processing: crate::policy::ManifestProcessing::Process,
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("<ds:DigestValue>")
.map(|relative| offset + relative + "<ds:DigestValue>".len())
.expect("Manifest DigestValue must be present");
let value_end = xml[value_start..]
.find("</ds:DigestValue>")
.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_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 {
manifest_processing: crate::policy::ManifestProcessing::Process,
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("<ds:Manifest")
.expect("fixture must contain a Manifest");
let manifest = xml[manifest_start..].replacen(
"<ds:DigestMethod Algorithm=\"http://www.w3.org/2000/09/xmldsig#sha1\"/>",
concat!(
"<ds:Transforms>",
"<ds:Transform Algorithm=\"http://www.w3.org/2001/10/xml-exc-c14n#\"/>",
"<ds:Transform Algorithm=\"http://www.w3.org/2001/10/xml-exc-c14n#\"/>",
"</ds:Transforms>",
"<ds:DigestMethod Algorithm=\"http://www.w3.org/2000/09/xmldsig#sha1\"/>"
),
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
// succeeds, so they cannot trigger Manifest policy processing here.
let broken_xml = signature_with_manifest_xml_with_manifest_mutation(true, |xml| {
xml.replacen("<ds:Reference URI=\"#target\">", "<ds:Reference>", 1)
});
let result = VerifyContext::new()
.key(&RejectingKey)
.process_manifests(true)
.verify(&broken_xml)
.expect("invalid SignatureValue must short-circuit Manifest URI processing");
assert!(result.manifest_references.is_empty());
assert!(matches!(
result.status,
DsigStatus::Invalid(FailureReason::SignatureMismatch)
));
}
#[test]
fn verify_context_records_manifest_missing_uri_with_accepting_key() {
let broken_xml = signature_with_manifest_xml_with_manifest_mutation(true, |xml| {
xml.replacen("<ds:Reference URI=\"#target\">", "<ds:Reference>", 1)
});
let result = VerifyContext::new()
.key(&AcceptingKey)
.process_manifests(true)
.verify(&broken_xml)
.expect("manifest missing URI should be recorded while signature stays valid");
assert_eq!(result.manifest_references.len(), 1);
assert_eq!(result.manifest_references[0].uri, "<omitted>");
assert!(matches!(
result.manifest_references[0].status,
DsigStatus::Invalid(FailureReason::ReferenceProcessingFailure { ref_index: 0 })
));
assert!(matches!(result.status, DsigStatus::Valid));
}
#[test]
fn verify_context_ignores_nested_manifests_in_object() {
// A digest-valid Manifest below a wrapper is outside the strict direct-
// child processing profile and must not appear in diagnostics.
let xml = signature_with_manifest_xml_with_manifest_mutation(true, |xml| {
xml.replacen(
"<ds:Manifest ID=\"manifest\">",
"<wrapper><ds:Manifest ID=\"manifest\">",
1,
)
.replacen("</ds:Manifest>", "</ds:Manifest></wrapper>", 1)
});
let result = VerifyContext::new()
.key(&AcceptingKey)
.process_manifests(true)
.verify(&xml)
.expect("nested Manifest nodes are ignored in strict mode");
assert!(
result.manifest_references.is_empty(),
"only direct ds:Manifest children of ds:Object must be processed"
);
assert!(matches!(result.status, DsigStatus::Valid));
}
#[test]
fn verify_context_reports_manifest_reference_parse_errors_explicitly() {
// Malformed nested DigestValue is parsed only after the enclosing
// Manifest structure has been authenticated by SignedInfo.
let broken_xml = signature_with_manifest_xml_with_manifest_mutation(true, |xml| {
replace_fixture_manifest_digest(&xml, "!!!")
});
let err = VerifyContext::new()
.key(&AcceptingKey)
.process_manifests(true)
.verify(&broken_xml)
.expect_err("invalid Manifest DigestValue must map to ParseManifestReference");
assert!(matches!(
err,
SignatureVerificationPipelineError::ParseManifestReference(_)
));
}
#[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(
"<ds:Reference URI=\"#target\">",
"<ds:Reference URI=\"#target\"><ds:Transforms><ds:Transform Algorithm=\"urn:unsupported\"/></ds:Transforms>",
1,
);
let xml = xml.replacen(
"</ds:Transforms>\n <ds:DigestMethod Algorithm=\"http://www.w3.org/2000/09/xmldsig#sha1\"/>",
"</ds:Transforms>\n <ds:DigestMethod Algorithm=\"http://www.w3.org/2001/04/xmlenc#sha256\"/>",
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 })
));
let restricted = VerifyContext::new()
.key(&AcceptingKey)
.process_manifests(true)
.allowed_transforms([
DEFAULT_IMPLICIT_C14N_URI,
"http://www.w3.org/2001/10/xml-exc-c14n#",
])
.verify(&xml)
.expect("a disallowed Manifest transform is a per-reference policy result");
assert!(matches!(
restricted.manifest_references[0].status,
DsigStatus::Invalid(FailureReason::ReferencePolicyViolation { ref_index: 0 })
));
}
#[test]
fn manifest_reference_limit_counts_unsupported_entries() {
let references = (0..=MAX_REFERENCES_PER_SIGNATURE)
.map(|index| {
format!(
r##"<ds:Reference URI="#target-{index}"><ds:Transforms><ds:Transform Algorithm="urn:unsupported"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><ds:DigestValue>AAAAAAAAAAAAAAAAAAAAAAAAAAA=</ds:DigestValue></ds:Reference>"##
)
})
.collect::<String>();
let xml = format!(
r#"<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:Object Id="signed"><ds:Manifest>{references}</ds:Manifest></ds:Object></ds:Signature>"#
);
let document = XmlDocument::parse(xml).unwrap();
let error = document.with_view(|view| {
let signature = view.document().root_element();
let object = signature.children().find(|node| node.is_element()).unwrap();
let policy = crate::policy::VerificationPolicy::default();
let budgets = VerificationOperationBudgets::with_transforms(
&policy,
TransformExecutionBudget::from_resources(&policy.resources),
);
let operation = OperationExecutionContext::new(
&policy,
&budgets,
Some((view.identity(), view.generation())),
);
operation.authenticate(view.node_identity(object));
let mut processed = HashSet::new();
let mut remaining = MAX_REFERENCES_PER_SIGNATURE;
let mut next_index = 0;
let mut xpath_parse = XPathSignatureParseBudget::default();
let mut state = ManifestDiscoveryState {
processed: &mut processed,
remaining_capacity: &mut remaining,
next_reference_index: &mut next_index,
xpath_parse: &mut xpath_parse,
};
match parse_manifest_references(signature, &operation, view, &mut state, None) {
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 unsigned_manifest_remains_eligible_after_trust_expands() {
// The second Object is not authenticated during the first discovery
// pass. It must remain unprocessed so a valid reference from the first
// Manifest can make its sibling Manifest eligible on the next pass.
let digest = base64::engine::general_purpose::STANDARD.encode([0_u8; 32]);
let xml = format!(
r##"<ds:Signature xmlns:ds="{XMLDSIG_NS}"><ds:Object Id="outer"><ds:Manifest><ds:Reference URI="#inner"><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>{digest}</ds:DigestValue></ds:Reference></ds:Manifest></ds:Object><ds:Object Id="inner"><ds:Manifest><ds:Reference URI="#payload"><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>{digest}</ds:DigestValue></ds:Reference></ds:Manifest></ds:Object></ds:Signature>"##
);
let document = XmlDocument::parse(xml).expect("nested Manifest fixture must parse");
document.with_view(|view| {
let signature = view.document().root_element();
let mut objects = signature.children().filter(|node| node.is_element());
let outer = objects.next().expect("outer Object");
let inner = objects.next().expect("inner Object");
let policy = crate::policy::VerificationPolicy::default();
let budgets = VerificationOperationBudgets::with_transforms(
&policy,
TransformExecutionBudget::from_resources(&policy.resources),
);
let operation = OperationExecutionContext::new(
&policy,
&budgets,
Some((view.identity(), view.generation())),
);
operation.authenticate(view.node_identity(outer));
let mut processed = HashSet::new();
let mut remaining = 2;
let mut next_index = 0;
let mut xpath_budget = XPathSignatureParseBudget::default();
let first = parse_manifest_references(
signature,
&operation,
view,
&mut ManifestDiscoveryState {
processed: &mut processed,
remaining_capacity: &mut remaining,
next_reference_index: &mut next_index,
xpath_parse: &mut xpath_budget,
},
None,
)
.expect("outer Manifest must be discovered");
assert_eq!(first.references.len(), 1);
assert_eq!(first.references[0].reference.uri.as_deref(), Some("#inner"));
operation.authenticate(view.node_identity(inner));
let second = parse_manifest_references(
signature,
&operation,
view,
&mut ManifestDiscoveryState {
processed: &mut processed,
remaining_capacity: &mut remaining,
next_reference_index: &mut next_index,
xpath_parse: &mut xpath_budget,
},
None,
)
.expect("newly authenticated sibling Manifest must remain eligible");
assert_eq!(second.references.len(), 1);
assert_eq!(
second.references[0].reference.uri.as_deref(),
Some("#payload")
);
});
}
#[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##"<ds:Reference URI="#manifest">"##)
.expect("fixture SignedInfo must reference the Manifest");
let reference_end = xml[reference_start..]
.find("</ds:Reference>")
.map(|offset| reference_start + offset + "</ds:Reference>".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 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 {
manifest_processing: crate::policy::ManifestProcessing::Process,
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 [
"#target",
"#xpointer(id('target'))",
"#xpointer(id("target"))",
] {
for target_xml in [
r#"<ds:X509Data Id="target"><ds:X509SubjectName>CN=leaf</ds:X509SubjectName></ds:X509Data>"#,
r#"<holder Id="target"><ds:X509Data><ds:X509SubjectName>CN=leaf</ds:X509SubjectName></ds:X509Data></holder>"#,
] {
let xml = format!(
r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:KeyInfo><ds:RetrievalMethod URI="{uri}" Type="http://www.w3.org/2000/09/xmldsig#X509Data"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116"><ds:XPath>ancestor-or-self::ds:X509Data</ds:XPath></ds:Transform></ds:Transforms></ds:RetrievalMethod></ds:KeyInfo>{target_xml}</root>"#
);
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,
UriTypeSet::SAME_DOCUMENT,
None,
crate::provider::default_provider(),
)
.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"]
));
}
}
}
fn retrieval_method_xpath_signature() -> String {
format!(
r##"<root xmlns:ds="{XMLDSIG_NS}">
<payload Id="payload">ok</payload>
<ds:Signature>
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<ds:Reference URI="#payload">
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue>AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue>AQ==</ds:SignatureValue>
<ds:KeyInfo>
<ds:RetrievalMethod URI="#target" Type="http://www.w3.org/2000/09/xmldsig#X509Data">
<ds:Transforms><ds:Transform Algorithm="{XPATH_TRANSFORM_URI}">
<ds:XPath>ancestor-or-self::ds:X509Data</ds:XPath>
</ds:Transform></ds:Transforms>
</ds:RetrievalMethod>
</ds:KeyInfo>
</ds:Signature>
<holder Id="target"><ds:X509Data><ds:X509SubjectName>CN=leaf</ds:X509SubjectName></ds:X509Data></holder>
</root>"##
)
}
#[test]
fn retrieval_method_xpath_uses_signature_expression_budget() {
// RetrievalMethod XPath belongs to the same untrusted Signature as
// Reference XPath and must not receive a separate parse allowance.
let mut policy = crate::policy::VerificationPolicy::default();
policy.resources.max_xpath_expressions = 0;
let error = VerifyContext::new()
.policy(policy)
.verify(&retrieval_method_xpath_signature())
.expect_err("RetrievalMethod XPath must consume the signature parse budget");
assert!(
matches!(
&error,
SignatureVerificationPipelineError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: "XPath expressions",
maximum: 0,
actual: 1,
}
)
),
"unexpected error: {error:?}"
);
}
#[test]
fn retrieval_method_xpath_obeys_expression_byte_limit() {
// Edge whitespace is semantically harmless for this restricted shape,
// but its raw untrusted bytes still belong to the operation budget.
let expression = "ancestor-or-self::ds:X509Data";
let padded_expression = format!(" {expression} ");
let xml = retrieval_method_xpath_signature().replace(expression, &padded_expression);
let mut policy = crate::policy::VerificationPolicy::default();
policy.resources.max_xpath_expression_bytes = expression.len();
let error = VerifyContext::new()
.policy(policy)
.verify(&xml)
.expect_err("RetrievalMethod XPath must obey the expression byte limit");
assert!(
matches!(
&error,
SignatureVerificationPipelineError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: "XPath expression bytes",
maximum,
actual,
}
) if *maximum == expression.len() && *actual == padded_expression.len()
),
"unexpected error: {error:?}"
);
}
#[test]
fn retrieval_method_xpath_obeys_expression_complexity_limit() {
// Recognizing a fixed safe predicate must not bypass the common XPath
// complexity policy applied to every expression in the signature.
let mut policy = crate::policy::VerificationPolicy::default();
policy.resources.max_xpath_expression_complexity = 0;
let error = VerifyContext::new()
.policy(policy)
.verify(&retrieval_method_xpath_signature())
.expect_err("RetrievalMethod XPath must obey the complexity limit");
assert!(
matches!(
&error,
SignatureVerificationPipelineError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: "XPath expression complexity",
maximum: 0,
actual,
}
) if *actual > 0
),
"unexpected error: {error:?}"
);
}
#[test]
fn retrieval_method_xpath_uses_node_filter_work_budget() {
// Attribute and namespace XPath nodes participate in filtering even
// though the optimized X509Data locator scans only tree descendants.
let mut policy = crate::policy::VerificationPolicy::default();
policy.resources.max_node_set_filter_work = 4;
let xml = retrieval_method_xpath_signature().replace(
"<holder Id=\"target\">",
"<holder Id=\"target\" role=\"signing\" xmlns:metadata=\"urn:metadata\">",
);
let error = VerifyContext::new()
.policy(policy)
.verify(&xml)
.expect_err("RetrievalMethod XPath must consume node-filter work");
assert!(
matches!(
&error,
SignatureVerificationPipelineError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::NODE_SET_FILTER_WORK,
maximum: 4,
actual,
}
) if *actual > 4
),
"unexpected error: {error:?}"
);
}
#[test]
fn retrieval_method_xpath_charges_every_context_to_evaluation_work() {
// The optimized predicate avoids a generic XPath engine, but every
// attribute and namespace context still consumes evaluation work.
let mut policy = crate::policy::VerificationPolicy::default();
policy.resources.max_xpath_evaluation_work = 4;
let xml = retrieval_method_xpath_signature().replace(
"<holder Id=\"target\">",
"<holder Id=\"target\" role=\"signing\" xmlns:metadata=\"urn:metadata\">",
);
let error = VerifyContext::new()
.policy(policy)
.verify(&xml)
.expect_err("RetrievalMethod XPath must charge every evaluation context");
assert!(matches!(
error,
SignatureVerificationPipelineError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::XPATH_EVALUATION_WORK,
maximum: 4,
actual,
}
) if actual > 4
));
}
#[test]
fn retrieval_method_xpath_obeys_namespace_binding_limit() {
// The specialized RetrievalMethod path must retain the XPath element's
// in-scope namespaces and enforce the same limit as ordinary XPath.
let mut policy = crate::policy::VerificationPolicy::default();
policy.resources.max_xpath_namespace_bindings = 0;
let error = VerifyContext::new()
.policy(policy)
.verify(&retrieval_method_xpath_signature())
.expect_err("RetrievalMethod XPath namespaces must obey the binding limit");
assert!(matches!(
error,
SignatureVerificationPipelineError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::XPATH_NAMESPACE_BINDINGS,
maximum: 0,
actual,
}
) if actual > 0
));
}
#[test]
fn retrieval_method_xpath_obeys_namespace_byte_limit() {
// Prefix and URI bytes retained from the XPath namespace axis consume
// the same per-expression byte budget as an ordinary XPath transform.
let mut policy = crate::policy::VerificationPolicy::default();
policy.resources.max_xpath_namespace_bytes = 0;
let error = VerifyContext::new()
.policy(policy)
.verify(&retrieval_method_xpath_signature())
.expect_err("RetrievalMethod XPath namespaces must obey the byte limit");
assert!(matches!(
error,
SignatureVerificationPipelineError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::XPATH_NAMESPACE_BYTES,
maximum: 0,
actual,
}
) if actual > 0
));
}
#[test]
fn retrieval_method_xpath_obeys_context_evaluation_limit() {
// A bare fragment includes attribute and namespace XPath nodes in
// addition to the four tree nodes below. Tree-only accounting would
// incorrectly admit this input at the configured ceiling.
let mut policy = crate::policy::VerificationPolicy::default();
policy.resources.max_xpath_context_evaluations = 4;
let xml = retrieval_method_xpath_signature().replace(
"<holder Id=\"target\">",
"<holder Id=\"target\" role=\"signing\" xmlns:metadata=\"urn:metadata\">",
);
let error = VerifyContext::new()
.policy(policy)
.verify(&xml)
.expect_err("RetrievalMethod XPath contexts must obey the evaluation limit");
assert!(matches!(
error,
SignatureVerificationPipelineError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::XPATH_CONTEXT_EVALUATIONS,
maximum: 4,
actual,
}
) if actual > 4
));
}
#[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##"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:KeyInfo><ds:RetrievalMethod URI="#target" Type="http://www.w3.org/2000/09/xmldsig#X509Data"/></ds:KeyInfo>
<ds:X509Data Id="target"><ds:X509SubjectName>CN=leaf</ds:X509SubjectName></ds:X509Data>
</root>"##;
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),
UriTypeSet::SAME_DOCUMENT,
None,
crate::provider::default_provider(),
)
.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_respects_configured_same_document_id_semantics() {
// RetrievalMethod is another consumer of same-document URIs and must
// not bypass the grammar selected for normal Reference dereferencing.
fn materialize(
uri: &str,
id: &str,
semantics: crate::policy::SameDocumentIdSemantics,
) -> Result<RetrievalMaterialization, SignatureVerificationPipelineError> {
let xml = format!(
r#"<root xmlns:ds="{XMLDSIG_NS}">
<ds:KeyInfo><ds:RetrievalMethod URI="{uri}" Type="{XMLDSIG_NS}X509Data"/></ds:KeyInfo>
<ds:X509Data Id="{id}"><ds:X509SubjectName>CN=leaf</ds:X509SubjectName></ds:X509Data>
</root>"#
);
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).with_same_document_id_semantics(semantics);
materialize_retrieval_methods(
&mut key_info,
&resolver,
UriTypeSet::SAME_DOCUMENT,
None,
crate::provider::default_provider(),
)
}
assert!(
materialize(
"#12345",
"12345",
crate::policy::SameDocumentIdSemantics::Specification,
)
.is_err(),
"the standards mode must reject a non-NCName bare fragment"
);
assert!(
materialize(
"#visa'3d",
"visa'3d",
crate::policy::SameDocumentIdSemantics::XmlSecBarename,
)
.is_err(),
"the donor barename wrapper cannot represent an apostrophe"
);
assert!(
materialize(
"#visa'3d",
"visa'3d",
crate::policy::SameDocumentIdSemantics::XmlSecVisa3d,
)
.is_ok(),
"Visa3D mode resolves the registered ID without an XPointer literal"
);
}
#[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#"<root xml:base="https://example.test/keys/nested/" xmlns:ds="{XMLDSIG_NS}">
<ds:KeyInfo><ds:RetrievalMethod URI="../signer.der" Type="{RAW_X509_TYPE}"/></ds:KeyInfo>
</root>"#
);
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,
)]);
let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources);
materialize_retrieval_methods(
&mut key_info,
&resolver,
UriTypeSet::ALL,
None,
crate::provider::default_provider(),
)
.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,
// is the result and therefore cannot masquerade as typed X509Data.
let xml = r##"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:KeyInfo><ds:RetrievalMethod URI="#target" Type="http://www.w3.org/2000/09/xmldsig#X509Data"/></ds:KeyInfo>
<holder Id="target"><ds:X509Data><ds:X509SubjectName>CN=leaf</ds:X509SubjectName></ds:X509Data></holder>
</root>"##;
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),
UriTypeSet::SAME_DOCUMENT,
None,
crate::provider::default_provider(),
)
.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
// dereferenced node-set, so this result is not rooted at X509Data.
let xml = r##"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:KeyInfo><ds:RetrievalMethod URI="#target" Type="http://www.w3.org/2000/09/xmldsig#X509Data"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116"><ds:XPath>ancestor-or-self::ds:X509Data</ds:XPath></ds:Transform></ds:Transforms></ds:RetrievalMethod></ds:KeyInfo>
<ds:X509Data><ds:X509SubjectName Id="target">CN=leaf</ds:X509SubjectName></ds:X509Data>
</root>"##;
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),
UriTypeSet::SAME_DOCUMENT,
None,
crate::provider::default_provider(),
)
.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##"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:KeyInfo><ds:RetrievalMethod URI="#target" Type="http://www.w3.org/2000/09/xmldsig#X509Data"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116"><ds:XPath>ancestor-or-self::ds:X509Data</ds:XPath></ds:Transform></ds:Transforms></ds:RetrievalMethod></ds:KeyInfo>
<holder Id="target"><ds:X509Data/><ds:X509Data/></holder>
</root>"##;
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),
UriTypeSet::SAME_DOCUMENT,
None,
crate::provider::default_provider(),
)
.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##"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:KeyInfo>
<ds:RetrievalMethod URI="#target" Type="http://www.w3.org/2000/09/xmldsig#X509Data"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116"><ds:XPath>ancestor-or-self::ds:X509Data</ds:XPath></ds:Transform></ds:Transforms></ds:RetrievalMethod>
<ds:KeyName>fallback</ds:KeyName>
</ds:KeyInfo>
<holder Id="target"><ds:X509Data><ds:X509SubjectName>CN=leaf</ds:X509SubjectName></ds:X509Data></holder>
</root>"##;
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),
UriTypeSet::SAME_DOCUMENT,
None,
crate::provider::default_provider(),
)
.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("<root/>").unwrap();
let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources);
let error = materialize_retrieval_methods(
&mut key_info,
&resolver,
UriTypeSet::ALL,
None,
crate::provider::default_provider(),
)
.expect_err("retrieval count must be bounded before materialization");
assert!(matches!(
error,
SignatureVerificationPipelineError::InvalidStructure {
reason: "KeyInfo contains too many RetrievalMethod elements"
}
));
}
#[test]
fn key_info_reference_materializes_an_allowed_empty_uri() {
// Empty URI is a same-document Reference URI. Element-valued
// KeyInfoReference dereferencing therefore selects a KeyInfo document
// element rather than consulting the external-resource map.
let document = XmlDocument::parse(format!(
r#"<ds:KeyInfo xmlns:ds="{XMLDSIG_NS}"><ds:KeyName>root-key</ds:KeyName></ds:KeyInfo>"#
))
.unwrap();
let mut key_info = KeyInfo {
sources: vec![super::super::parse::KeyInfoSource::KeyInfoReference {
uri: String::new(),
}],
};
let mut policy = crate::policy::VerificationPolicy::default();
policy.key_sources.key_info_reference = true;
let mut xpath_parse_budget = XPathSignatureParseBudget::default();
let execution_budget = TransformExecutionBudget::from_resources(&policy.resources);
let mut budgets = RetrievalMaterializationBudgets {
xpath_parse: &mut xpath_parse_budget,
execution: &execution_budget,
resources: &policy.resources,
certificate_signature_algorithms: policy
.key_trust
.certificate_signature_algorithms
.as_ref(),
xml_backend: crate::XmlBackend::default(),
};
let mut materialization = KeyInfoMaterializationState::default();
document
.with_view(|view| {
materialize_key_info_references_with_budgets(
&mut key_info,
&UriReferenceResolver::with_document_view(view, &[]),
&policy,
crate::provider::default_provider(),
&mut budgets,
&mut materialization,
)?;
Ok::<_, SignatureVerificationPipelineError>(())
})
.unwrap();
assert!(matches!(
key_info.sources.as_slice(),
[super::super::parse::KeyInfoSource::KeyName(name)] if name == "root-key"
));
}
#[test]
fn empty_key_info_reference_participates_in_cycle_detection() {
// Empty URI resolves to the owning document's root KeyInfo. It must
// recurse through the same traversal state rather than leave the
// self-reference materialized and bypass cycle/depth enforcement.
let document = XmlDocument::parse(format!(
r#"<ds:KeyInfo xmlns:ds="{XMLDSIG_NS}" xmlns:dsig11="http://www.w3.org/2009/xmldsig11#"><dsig11:KeyInfoReference URI=""/></ds:KeyInfo>"#
))
.unwrap();
let mut key_info = KeyInfo {
sources: vec![super::super::parse::KeyInfoSource::KeyInfoReference {
uri: String::new(),
}],
};
let mut policy = crate::policy::VerificationPolicy::default();
policy.key_sources.key_info_reference = true;
let mut xpath_parse_budget = XPathSignatureParseBudget::default();
let execution_budget = TransformExecutionBudget::from_resources(&policy.resources);
let mut budgets = RetrievalMaterializationBudgets {
xpath_parse: &mut xpath_parse_budget,
execution: &execution_budget,
resources: &policy.resources,
certificate_signature_algorithms: policy
.key_trust
.certificate_signature_algorithms
.as_ref(),
xml_backend: crate::XmlBackend::default(),
};
let mut materialization = KeyInfoMaterializationState::default();
let error = document
.with_view(|view| {
materialize_key_info_references_with_budgets(
&mut key_info,
&UriReferenceResolver::with_document_view(view, &[]),
&policy,
crate::provider::default_provider(),
&mut budgets,
&mut materialization,
)
})
.expect_err("empty-URI self-reference must be rejected as a cycle");
assert!(matches!(
error,
SignatureVerificationPipelineError::InvalidStructure {
reason: "KeyInfoReference cycle detected"
}
));
}
#[test]
fn key_info_materialization_shares_candidate_work_across_source_kinds() {
// The reference, nested RetrievalMethod, and resulting certificate are
// three units of materialization work in one operation.
const RAW_X509_TYPE: &str = "http://www.w3.org/2000/09/xmldsig#rawX509Certificate";
let document = XmlDocument::parse(format!(
r##"<root xmlns:ds="{XMLDSIG_NS}" xmlns:dsig11="http://www.w3.org/2009/xmldsig11#"><ds:KeyInfo ID="target"><ds:RetrievalMethod URI="signer.der" Type="{RAW_X509_TYPE}"/></ds:KeyInfo></root>"##
))
.unwrap();
let certificate = include_bytes!(
"../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/balor.der"
)
.to_vec();
let resources = HashMap::from([("signer.der".to_owned(), certificate)]);
let mut key_info = KeyInfo {
sources: vec![super::super::parse::KeyInfoSource::KeyInfoReference {
uri: "#target".into(),
}],
};
let mut policy = crate::policy::VerificationPolicy::default();
policy.key_sources.key_info_reference = true;
policy.uris.retrieval_methods = UriTypeSet::ALL;
policy.resources.max_key_candidates = 2;
let mut xpath_parse_budget = XPathSignatureParseBudget::default();
let execution_budget = TransformExecutionBudget::from_resources(&policy.resources);
let mut budgets = RetrievalMaterializationBudgets {
xpath_parse: &mut xpath_parse_budget,
execution: &execution_budget,
resources: &policy.resources,
certificate_signature_algorithms: policy
.key_trust
.certificate_signature_algorithms
.as_ref(),
xml_backend: crate::XmlBackend::default(),
};
let mut materialization = KeyInfoMaterializationState::default();
let error = document
.with_view(|view| {
let resolver = UriReferenceResolver::with_document_view(view, &[])
.with_external_resources(&resources);
let mut outcome = materialize_key_info_references_with_budgets(
&mut key_info,
&resolver,
&policy,
crate::provider::default_provider(),
&mut budgets,
&mut materialization,
)?;
outcome.merge(materialize_retrieval_methods_with_budgets(
&mut key_info,
&resolver,
&policy,
crate::provider::default_provider(),
&mut budgets,
&mut materialization.candidate_work,
)?);
Ok::<_, DsigError>(outcome)
})
.expect_err("all materializers must share the candidate-work limit");
assert!(matches!(
error,
DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::KEY_CANDIDATES,
maximum: 2,
actual: 3,
})
));
}
#[test]
fn public_key_info_materialization_binds_external_resource_policy() {
// Public helpers must not inherit the resolver's permissive hard-limit
// budget when the operation policy selects a stricter byte ceiling.
let document = Document::parse("<root/>").unwrap();
let external = format!(
r#"<ds:KeyInfo xmlns:ds="{XMLDSIG_NS}"><ds:KeyName>external</ds:KeyName></ds:KeyInfo>"#
);
let resources = HashMap::from([("key.xml".to_owned(), external.into_bytes())]);
let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources);
let mut key_info = KeyInfo {
sources: vec![super::super::parse::KeyInfoSource::KeyInfoReference {
uri: "key.xml".into(),
}],
};
let mut policy = crate::policy::VerificationPolicy::default();
policy.key_sources.key_info_reference = true;
policy.uris.key_info_references = UriTypeSet::ALL;
policy.resources.max_external_resource_bytes = 0;
let error = materialize_verification_key_info_references(
&mut key_info,
resolver,
&policy,
crate::provider::default_provider(),
crate::XmlBackend::default(),
)
.expect_err("policy must reject non-empty external KeyInfo bytes");
assert!(matches!(
error,
DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES,
maximum: 0,
actual,
}) if actual == resources["key.xml"].len()
));
}
#[cfg(all(feature = "xml-backend-roxmltree", feature = "xml-backend-xmloxide"))]
#[test]
fn public_key_info_materialization_uses_the_selected_backend() {
// Public materialization is a standalone parse boundary. Its backend
// must be caller-selected just like the complete sign/verify pipeline.
let external = format!(
r#"<ds:KeyInfo xmlns:ds="{XMLDSIG_NS}"><ds:KeyName>external</ds:KeyName></ds:KeyInfo>"#
);
let resources = HashMap::from([("key.xml".to_owned(), external.into_bytes())]);
let document = Document::parse("<root/>").unwrap();
for backend in [crate::XmlBackend::Xmloxide, crate::XmlBackend::Roxmltree] {
let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources);
let mut key_info = KeyInfo {
sources: vec![super::super::parse::KeyInfoSource::KeyInfoReference {
uri: "key.xml".into(),
}],
};
let mut policy = crate::policy::VerificationPolicy::default();
policy.key_sources.key_info_reference = true;
policy.uris.key_info_references = UriTypeSet::ALL;
materialize_verification_key_info_references(
&mut key_info,
resolver,
&policy,
crate::provider::default_provider(),
backend,
)
.unwrap_or_else(|error| panic!("{backend:?} materialization failed: {error}"));
assert!(matches!(
key_info.sources.as_slice(),
[super::super::parse::KeyInfoSource::KeyName(name)] if name == "external"
));
}
}
#[test]
fn public_key_info_materialization_defers_nested_retrieval_failure() {
// Metadata expansion must preserve an earlier usable source when a
// later retrieval candidate fails. The complete operation reports the
// deferred error only if key selection exhausts all usable sources.
const RAW_X509_TYPE: &str = "http://www.w3.org/2000/09/xmldsig#rawX509Certificate";
let external = format!(
r#"<ds:KeyInfo xmlns:ds="{XMLDSIG_NS}"><ds:KeyName>usable</ds:KeyName><ds:RetrievalMethod URI="missing.der" Type="{RAW_X509_TYPE}"/></ds:KeyInfo>"#
);
let resources = HashMap::from([("key.xml".to_owned(), external.into_bytes())]);
let document = Document::parse("<root/>").unwrap();
for operation in ["signing", "verification"] {
let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources);
let mut key_info = KeyInfo {
sources: vec![super::super::parse::KeyInfoSource::KeyInfoReference {
uri: "key.xml".into(),
}],
};
match operation {
"signing" => {
let mut policy = crate::policy::SigningPolicy::default();
policy.uris.key_info_references = UriTypeSet::ALL;
policy.uris.retrieval_methods = UriTypeSet::ALL;
materialize_signing_key_info_references(
&mut key_info,
resolver,
&policy,
crate::provider::default_provider(),
crate::XmlBackend::default(),
)
}
"verification" => {
let mut policy = crate::policy::VerificationPolicy::default();
policy.key_sources.key_info_reference = true;
policy.uris.key_info_references = UriTypeSet::ALL;
policy.uris.retrieval_methods = UriTypeSet::ALL;
materialize_verification_key_info_references(
&mut key_info,
resolver,
&policy,
crate::provider::default_provider(),
crate::XmlBackend::default(),
)
}
_ => unreachable!(),
}
.unwrap_or_else(|error| panic!("{operation} materialization failed: {error}"));
assert!(matches!(
key_info.sources.as_slice(),
[super::super::parse::KeyInfoSource::KeyName(name)] if name == "usable"
));
}
}
#[test]
fn public_key_info_materialization_binds_xpath_policy() {
// Both public helpers must carry the caller's XPath parse limits into
// RetrievalMethod transforms nested in an external KeyInfo document.
let external = format!(
r##"<ds:KeyInfo xmlns:ds="{XMLDSIG_NS}">
<ds:RetrievalMethod URI="#target" Type="http://www.w3.org/2000/09/xmldsig#X509Data">
<ds:Transforms><ds:Transform Algorithm="{XPATH_TRANSFORM_URI}">
<ds:XPath>ancestor-or-self::ds:X509Data</ds:XPath>
</ds:Transform></ds:Transforms>
</ds:RetrievalMethod>
<ds:X509Data Id="target"><ds:X509SubjectName>CN=leaf</ds:X509SubjectName></ds:X509Data>
</ds:KeyInfo>"##
);
let resources = HashMap::from([("key.xml".to_owned(), external.into_bytes())]);
let document = Document::parse("<root/>").unwrap();
for operation in ["signing", "verification"] {
let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources);
let mut key_info = KeyInfo {
sources: vec![super::super::parse::KeyInfoSource::KeyInfoReference {
uri: "key.xml".into(),
}],
};
let error = match operation {
"signing" => {
let mut policy = crate::policy::SigningPolicy::default();
policy.uris.key_info_references = UriTypeSet::ALL;
policy.resources.max_xpath_expressions = 0;
materialize_signing_key_info_references(
&mut key_info,
resolver,
&policy,
crate::provider::default_provider(),
crate::XmlBackend::default(),
)
}
"verification" => {
let mut policy = crate::policy::VerificationPolicy::default();
policy.key_sources.key_info_reference = true;
policy.uris.key_info_references = UriTypeSet::ALL;
policy.resources.max_xpath_expressions = 0;
materialize_verification_key_info_references(
&mut key_info,
resolver,
&policy,
crate::provider::default_provider(),
crate::XmlBackend::default(),
)
}
_ => unreachable!(),
}
.expect_err("the public helper must enforce the supplied XPath expression limit");
assert!(
matches!(
error,
DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::XPATH_EXPRESSIONS,
maximum: 0,
actual: 1,
})
),
"unexpected {operation} error: {error:?}"
);
}
}
fn materialize_external_key_info_chain(
terminal_reference: &str,
) -> Result<KeyInfo, SignatureVerificationPipelineError> {
let a = format!(
r##"<doc xmlns:ds="{XMLDSIG_NS}" xmlns:dsig11="http://www.w3.org/2009/xmldsig11#">
<ds:KeyInfo ID="root"><dsig11:KeyInfoReference URI="#next"/></ds:KeyInfo>
<ds:KeyInfo ID="next"><dsig11:KeyInfoReference URI="b.xml#root"/></ds:KeyInfo>
</doc>"##
);
let b = format!(
r##"<doc xmlns:ds="{XMLDSIG_NS}" xmlns:dsig11="http://www.w3.org/2009/xmldsig11#">
<ds:KeyInfo ID="root"><dsig11:KeyInfoReference URI="#next"/></ds:KeyInfo>
<ds:KeyInfo ID="next">{terminal_reference}</ds:KeyInfo>
</doc>"##
);
let resources = HashMap::from([
("a.xml".to_owned(), a.into_bytes()),
("b.xml".to_owned(), b.into_bytes()),
]);
let document = XmlDocument::parse("<root/>").unwrap();
let mut key_info = KeyInfo {
sources: vec![super::super::parse::KeyInfoSource::KeyInfoReference {
uri: "a.xml#root".into(),
}],
};
let mut policy = crate::policy::VerificationPolicy::default();
policy.key_sources.key_info_reference = true;
policy.uris.key_info_references = UriTypeSet::ALL;
let mut xpath_parse_budget = XPathSignatureParseBudget::default();
let execution_budget = TransformExecutionBudget::from_resources(&policy.resources);
let mut budgets = RetrievalMaterializationBudgets {
xpath_parse: &mut xpath_parse_budget,
execution: &execution_budget,
resources: &policy.resources,
certificate_signature_algorithms: policy
.key_trust
.certificate_signature_algorithms
.as_ref(),
xml_backend: crate::XmlBackend::default(),
};
let mut materialization = KeyInfoMaterializationState::default();
document.with_view(|view| {
let resolver = UriReferenceResolver::with_document_view(view, &[])
.with_external_resources(&resources);
materialize_key_info_references_with_budgets(
&mut key_info,
&resolver,
&policy,
crate::provider::default_provider(),
&mut budgets,
&mut materialization,
)?;
Ok::<_, SignatureVerificationPipelineError>(())
})?;
Ok(key_info)
}
fn materialize_external_key_info_bytes_with_limit(
encoded: Vec<u8>,
maximum_document_bytes: usize,
) -> Result<KeyInfo, SignatureVerificationPipelineError> {
let resources = HashMap::from([("key.xml".to_owned(), encoded)]);
let document = XmlDocument::parse("<root/>").unwrap();
let mut key_info = KeyInfo {
sources: vec![super::super::parse::KeyInfoSource::KeyInfoReference {
uri: "key.xml".into(),
}],
};
let mut policy = crate::policy::VerificationPolicy::default();
policy.key_sources.key_info_reference = true;
policy.uris.key_info_references = UriTypeSet::ALL;
policy.resources.max_xml_document_bytes = maximum_document_bytes;
let mut xpath_parse_budget = XPathSignatureParseBudget::default();
let execution_budget = TransformExecutionBudget::from_resources(&policy.resources);
let mut budgets = RetrievalMaterializationBudgets {
xpath_parse: &mut xpath_parse_budget,
execution: &execution_budget,
resources: &policy.resources,
certificate_signature_algorithms: policy
.key_trust
.certificate_signature_algorithms
.as_ref(),
xml_backend: crate::XmlBackend::default(),
};
let mut materialization = KeyInfoMaterializationState::default();
document.with_view(|view| {
let resolver = UriReferenceResolver::with_document_view(view, &[])
.with_external_resources(&resources);
materialize_key_info_references_with_budgets(
&mut key_info,
&resolver,
&policy,
crate::provider::default_provider(),
&mut budgets,
&mut materialization,
)?;
Ok::<_, SignatureVerificationPipelineError>(())
})?;
Ok(key_info)
}
fn materialize_external_key_info_bytes(
encoded: Vec<u8>,
) -> Result<KeyInfo, SignatureVerificationPipelineError> {
materialize_external_key_info_bytes_with_limit(
encoded,
crate::hard_limits::XML_DOCUMENT_BYTE_CEILING,
)
}
#[test]
fn external_key_info_reference_decodes_utf16_xml_octets() {
// External XML follows the XML encoding declaration/BOM contract, not
// the UTF-8-only contract of Rust strings passed by direct callers.
let xml = format!(
r#"<ds:KeyInfo xmlns:ds="{XMLDSIG_NS}"><ds:KeyName>utf16-key</ds:KeyName></ds:KeyInfo>"#
);
let mut encoded = vec![0xff, 0xfe];
encoded.extend(xml.encode_utf16().flat_map(u16::to_le_bytes));
let key_info = materialize_external_key_info_bytes(encoded)
.expect("UTF-16 external KeyInfo must materialize");
assert!(matches!(
key_info.sources.as_slice(),
[super::super::parse::KeyInfoSource::KeyName(name)] if name == "utf16-key"
));
}
#[test]
fn external_key_info_reference_rejects_malformed_xml_encoding() {
// A BOM selecting UTF-16 must not permit a truncated code unit to reach
// the XML parser under a misleading structural diagnostic.
let error = materialize_external_key_info_bytes(vec![0xff, 0xfe, b'<'])
.expect_err("truncated UTF-16 must be rejected during octet decoding");
assert!(matches!(
error,
SignatureVerificationPipelineError::InvalidStructure {
reason: "KeyInfoReference external resource has an invalid XML encoding"
}
));
}
#[test]
fn external_key_info_reference_preserves_decoded_document_limit() {
// Decoding can expand a compact single-byte resource beyond the XML document policy.
// That is a typed policy failure, not malformed KeyInfo structure.
let encoded = b"<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?><ds:KeyInfo xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\"><ds:KeyName>\xe9\xe9</ds:KeyName></ds:KeyInfo>".to_vec();
let maximum = encoded.len();
let error = materialize_external_key_info_bytes_with_limit(encoded, maximum)
.expect_err("decoded XML expansion must retain resource-limit classification");
assert!(matches!(
error,
SignatureVerificationPipelineError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::XML_DOCUMENT,
maximum: observed_maximum,
actual,
}
) if observed_maximum == maximum && actual > maximum
));
}
#[test]
fn key_info_reference_cycle_identity_includes_the_owning_resource() {
// Equal fragment spellings in separate external documents are distinct
// references and must not be rejected as a recursive cycle.
let key_info = materialize_external_key_info_chain("<ds:KeyName>terminal</ds:KeyName>")
.expect("cross-document duplicate fragments must materialize");
assert!(matches!(
key_info.sources.as_slice(),
[super::super::parse::KeyInfoSource::KeyName(name)] if name == "terminal"
));
}
#[test]
fn key_info_reference_cycle_identity_survives_external_reparse() {
// Returning to the same external resource must remain a cycle even
// though each dereference creates a fresh owned XML document.
let error =
materialize_external_key_info_chain("<dsig11:KeyInfoReference URI=\"a.xml#root\"/>")
.expect_err("a resource cycle must fail before exhausting depth");
assert!(matches!(
error,
SignatureVerificationPipelineError::InvalidStructure {
reason: "KeyInfoReference cycle detected"
}
));
}
#[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("<root/>").unwrap();
let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources);
materialize_retrieval_methods(
&mut key_info,
&resolver,
UriTypeSet::ALL,
None,
crate::provider::default_provider(),
)
.unwrap();
assert!(matches!(
key_info.sources.as_slice(),
[super::super::parse::KeyInfoSource::X509Data(info)]
if info.certificates.len() == 1
));
}
#[test]
fn retrieval_method_candidate_budget_includes_embedded_key_values() {
// A previously parsed KeyValue consumes the sole candidate slot, so
// malformed retrieved DER must be rejected by policy before X.509 parsing.
const RAW_X509_TYPE: &str = "http://www.w3.org/2000/09/xmldsig#rawX509Certificate";
let resources = HashMap::from([("urn:certificate".to_string(), vec![1, 2, 3])]);
let mut key_info = KeyInfo {
sources: vec![
super::super::parse::KeyInfoSource::KeyValue(
super::super::parse::KeyValueInfo::Unsupported {
namespace: Some(XMLDSIG_NS.into()),
local_name: "FutureKeyValue".into(),
},
),
super::super::parse::KeyInfoSource::RetrievalMethod {
uri: "urn:certificate".into(),
resource_type: Some(RAW_X509_TYPE.into()),
transforms: RetrievalMethodTransforms::None,
},
],
};
let document = Document::parse("<root/>").unwrap();
let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources);
let mut xpath_parse_budget = XPathSignatureParseBudget::default();
let execution_budget = TransformExecutionBudget::default();
let resource_policy = crate::policy::ResourcePolicy {
max_key_candidates: 1,
..crate::policy::ResourcePolicy::default()
};
let mut budgets = RetrievalMaterializationBudgets {
xpath_parse: &mut xpath_parse_budget,
execution: &execution_budget,
resources: &resource_policy,
certificate_signature_algorithms: None,
xml_backend: crate::XmlBackend::default(),
};
let mut candidate_work = key_info.embedded_candidate_count();
let mut policy = crate::policy::VerificationPolicy::default();
policy.uris.retrieval_methods = UriTypeSet::ALL;
let error = materialize_retrieval_methods_with_budgets(
&mut key_info,
&resolver,
&policy,
crate::provider::default_provider(),
&mut budgets,
&mut candidate_work,
)
.expect_err("the retrieved certificate must exceed the aggregate candidate limit");
assert!(matches!(
error,
SignatureVerificationPipelineError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::KEY_CANDIDATES,
maximum: 1,
actual: 2,
}
)
));
}
#[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("<root/>").unwrap();
let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources);
let error = materialize_retrieval_methods(
&mut key_info,
&resolver,
UriTypeSet::ALL,
None,
crate::provider::default_provider(),
)
.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.
let broken_xml = signature_with_manifest_xml_with_manifest_mutation(true, |xml| {
let xml = xml.replacen(
"<ds:Reference URI=\"#target\">",
"<ds:Reference URI=\"#target\"><ds:Transforms><ds:Transform Algorithm=\"urn:unsupported\"/></ds:Transforms>",
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
// Manifest element-only grammar.
let xml = signature_with_manifest_xml_with_manifest_mutation(true, |xml| {
xml.replacen(
"<ds:Manifest ID=\"manifest\">",
"<ds:Manifest ID=\"manifest\">junk",
1,
)
});
let err = VerifyContext::new()
.key(&AcceptingKey)
.process_manifests(true)
.verify(&xml)
.expect_err("Manifest mixed content must fail verification");
assert!(matches!(
err,
SignatureVerificationPipelineError::InvalidStructure {
reason: "Manifest contains non-whitespace mixed content"
}
));
}
#[test]
fn verify_context_rejects_empty_manifest_children() {
// An authenticated empty Manifest violates the required Reference+
// content model rather than disappearing as an unsigned block.
let xml = signature_with_manifest_xml_with_manifest_mutation(true, |xml| {
let (prefix, rest) = xml
.split_once("<ds:Manifest ID=\"manifest\">")
.expect("fixture should contain Manifest");
let (_, suffix) = rest
.split_once("</ds:Manifest>")
.expect("fixture should contain closing Manifest");
format!("{prefix}<ds:Manifest ID=\"manifest\"></ds:Manifest>{suffix}")
});
let err = VerifyContext::new()
.key(&AcceptingKey)
.process_manifests(true)
.verify(&xml)
.expect_err("empty Manifest must fail verification");
assert!(matches!(
err,
SignatureVerificationPipelineError::InvalidStructure {
reason: "Manifest must contain at least one ds:Reference element child"
}
));
}
#[test]
fn verify_context_ignores_unsigned_malformed_manifest_blocks() {
let xml = signature_with_manifest_xml(true).replacen(
"</ds:Object>",
"</ds:Object><ds:Object><ds:Manifest>junk<ds:Foo/></ds:Manifest></ds:Object>",
1,
);
let result = VerifyContext::new()
.key(&AcceptingKey)
.process_manifests(true)
.verify(&xml)
.expect("unsigned malformed Manifest must be ignored");
assert_eq!(
result.manifest_references.len(),
1,
"only signed Manifest references must be reported",
);
assert!(matches!(result.status, DsigStatus::Valid));
}
#[test]
fn verify_context_skips_ambiguous_manifest_id_blocks() {
let xml = signature_with_manifest_xml(true).replacen(
"</ds:Object>",
"</ds:Object><ds:Object><ds:Manifest ID=\"manifest\">junk<ds:Foo/></ds:Manifest></ds:Object>",
1,
);
let err = VerifyContext::new()
.key(&RejectingKey)
.process_manifests(true)
.verify(&xml)
.expect_err("ambiguous manifest IDs should make SignedInfo #manifest dereference fail");
assert!(matches!(
err,
SignatureVerificationPipelineError::Reference(
ReferenceProcessingError::UriDereference(
crate::xmldsig::types::TransformError::ElementNotFound(id)
)
) if id == "manifest"
));
}
#[test]
fn verify_context_rejects_implicit_default_c14n_when_not_allowlisted() {
let xml = minimal_signature_xml("", "");
let err = VerifyContext::new()
.key(&RejectingKey)
.allowed_transforms(["http://www.w3.org/2001/10/xml-exc-c14n#"])
.verify(&xml)
.expect_err("implicit default C14N must be checked against allowlist");
assert!(matches!(
err,
SignatureVerificationPipelineError::Policy(crate::policy::PolicyViolation::Algorithm {
operation: "verification transform",
..
})
));
}
#[test]
fn verify_context_skips_resolver_when_reference_processing_fails() {
let xml = minimal_signature_xml("", "");
let result = VerifyContext::new()
.key_resolver(&PanicResolver)
.verify(&xml)
.expect("reference digest mismatch should short-circuit before resolver");
assert!(matches!(
result.status,
DsigStatus::Invalid(FailureReason::ReferenceDigestMismatch { ref_index: 0 })
));
}
#[test]
fn verify_context_leaves_fail_fast_reference_tail_unexecuted() {
// A mismatch on the first Reference must not produce graph evidence for
// the second Reference, which was never dereferenced or digested.
let second_reference = r#"
<ds:Reference URI="">
<ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
<ds:DigestValue>AAAAAAAAAAAAAAAAAAAAAAAAAAA=</ds:DigestValue>
</ds:Reference>"#;
let xml = minimal_signature_xml("", "").replacen(
" </ds:SignedInfo>",
&format!("{second_reference}\n </ds:SignedInfo>"),
1,
);
let result = VerifyContext::new()
.key(&AcceptingKey)
.verify(&xml)
.expect("digest mismatch must remain a verification result");
assert_eq!(result.signed_info_references.len(), 1);
assert!(matches!(
result.signed_info_references[0].status,
DsigStatus::Invalid(FailureReason::ReferenceDigestMismatch { ref_index: 0 })
));
}
#[test]
fn verify_context_reports_key_not_found_when_resolver_misses() {
let xml = signature_with_target_reference("AQ==");
let result = VerifyContext::new()
.key_resolver(&MissingKeyResolver)
.verify(&xml)
.expect("resolver miss should report status, not pipeline error");
assert!(matches!(
result.status,
DsigStatus::Invalid(FailureReason::KeyNotFound)
));
assert_eq!(
result.signed_info_references.len(),
1,
"KeyNotFound path must preserve SignedInfo reference diagnostics",
);
assert!(matches!(
result.signed_info_references[0].status,
DsigStatus::Valid
));
}
#[test]
fn verification_candidate_budget_covers_preset_and_custom_resolver_paths() {
// Zero is a valid deny-all ceiling. Neither an already-resolved key nor
// a custom resolver may bypass the operation-wide candidate policy.
let xml = signature_with_target_reference("AQ==");
let mut policy = crate::policy::VerificationPolicy::default();
policy.resources.max_key_candidates = 0;
let preset_error = VerifyContext::new()
.key(&RejectingKey)
.policy(policy.clone())
.verify(&xml)
.expect_err("a preset key consumes one candidate");
assert!(matches!(
preset_error,
SignatureVerificationPipelineError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::KEY_CANDIDATES,
maximum: 0,
actual: 1,
}
)
));
let resolver_error = VerifyContext::new()
.key_resolver(&PanicResolver)
.policy(policy)
.verify(&xml)
.expect_err("a custom resolver requires candidate capacity before dispatch");
assert!(matches!(
resolver_error,
SignatureVerificationPipelineError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::KEY_CANDIDATES,
maximum: 0,
actual: 1,
}
)
));
}
#[test]
fn verification_candidate_budget_precedes_embedded_x509_parsing() {
// The first certificate is valid and consumes the sole permitted slot;
// malformed bytes in the second must never reach the X.509 parser.
let first_certificate = base64::engine::general_purpose::STANDARD.encode(include_bytes!(
"../../tests/fixtures/xmldsig/phaos-xmldsig-three/certs/rsa-cert.der"
));
let xml = signature_with_target_reference("AQ==").replace(
"</ds:SignatureValue>\n </ds:Signature>",
&format!(
"</ds:SignatureValue>\n <ds:KeyInfo><ds:X509Data><ds:X509Certificate>{first_certificate}</ds:X509Certificate><ds:X509Certificate>AQID</ds:X509Certificate></ds:X509Data></ds:KeyInfo>\n </ds:Signature>"
),
);
let mut policy = crate::policy::VerificationPolicy::default();
policy.resources.max_key_candidates = 1;
let error = VerifyContext::new()
.policy(policy)
.verify(&xml)
.expect_err("candidate policy must run before embedded certificate parsing");
assert!(matches!(
error,
SignatureVerificationPipelineError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::KEY_CANDIDATES,
maximum: 1,
actual: 2,
}
)
));
}
#[test]
fn verify_context_resolver_can_ignore_malformed_keyinfo_by_default() {
let base_xml = signature_with_target_reference("AQ==");
let xml = base_xml
.replace(
r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#">"#,
r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:dsig11="http://www.w3.org/2009/xmldsig11#">"#,
)
.replace(
"</ds:SignatureValue>\n </ds:Signature>",
"</ds:SignatureValue>\n <ds:KeyInfo><dsig11:DEREncodedKeyValue>%%%invalid%%%</dsig11:DEREncodedKeyValue></ds:KeyInfo>\n </ds:Signature>",
);
let result = VerifyContext::new()
.key_resolver(&MissingKeyResolver)
.verify(&xml)
.expect("resolver path should not hard-fail on advisory malformed KeyInfo by default");
assert!(matches!(
result.status,
DsigStatus::Invalid(FailureReason::KeyNotFound)
));
}
#[test]
fn verify_context_resolver_can_opt_in_to_keyinfo_parse_failures() {
let base_xml = signature_with_target_reference("AQ==");
let xml = base_xml
.replace(
r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#">"#,
r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:dsig11="http://www.w3.org/2009/xmldsig11#">"#,
)
.replace(
"</ds:SignatureValue>\n </ds:Signature>",
"</ds:SignatureValue>\n <ds:KeyInfo><dsig11:DEREncodedKeyValue>%%%invalid%%%</dsig11:DEREncodedKeyValue></ds:KeyInfo>\n </ds:Signature>",
);
let err = VerifyContext::new()
.key_resolver(&ConsumingKeyInfoResolver)
.verify(&xml)
.expect_err("resolver opted into KeyInfo parsing, malformed KeyInfo must fail");
assert!(matches!(
err,
SignatureVerificationPipelineError::ParseKeyInfo(_)
));
}
#[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(
"</ds:SignatureValue>\n </ds:Signature>",
r##"</ds:SignatureValue>
<ds:KeyInfo>
<ds:RetrievalMethod URI="#vendor" Type="urn:vendor:key">
<ds:Transforms><ds:Transform Algorithm="urn:vendor:transform"/></ds:Transforms>
</ds:RetrievalMethod>
<ds:KeyName>fallback</ds:KeyName>
</ds:KeyInfo>
</ds:Signature>"##,
);
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_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(
"</ds:SignatureValue>\n </ds:Signature>",
r#"</ds:SignatureValue>
<ds:KeyInfo>
<ds:KeyName>primary</ds:KeyName>
<ds:RetrievalMethod URI="missing.der" Type="http://www.w3.org/2000/09/xmldsig#rawX509Certificate"/>
</ds:KeyInfo>
</ds:Signature>"#,
);
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_does_not_eagerly_fail_unused_same_document_x509_retrieval() {
// Same-document X509Data retrieval is an ordered alternative just like
// raw-certificate retrieval; a resolved earlier source makes it unused.
let xml = signature_with_target_reference("AQ==").replace(
"</ds:SignatureValue>\n </ds:Signature>",
r##"</ds:SignatureValue>
<ds:KeyInfo>
<ds:KeyName>primary</ds:KeyName>
<ds:RetrievalMethod URI="#missing" Type="http://www.w3.org/2000/09/xmldsig#X509Data"/>
</ds:KeyInfo>
</ds:Signature>"##,
);
let result = VerifyContext::new()
.key_resolver(&EarlyKeyInfoResolver)
.verify(&xml)
.expect("an unused missing X509Data retrieval fallback must not abort verification");
assert_eq!(result.status, DsigStatus::Valid);
}
#[test]
fn verify_context_reports_missing_same_document_x509_retrieval_without_fallback() {
// Deferred failures retain their exact diagnostic when no alternative
// source resolves the verification key.
let xml = signature_with_target_reference("AQ==").replace(
"</ds:SignatureValue>\n </ds:Signature>",
r##"</ds:SignatureValue>
<ds:KeyInfo>
<ds:RetrievalMethod URI="#missing" Type="http://www.w3.org/2000/09/xmldsig#X509Data"/>
</ds:KeyInfo>
</ds:Signature>"##,
);
let error = VerifyContext::new()
.key_resolver(&ConsumingKeyInfoResolver)
.verify(&xml)
.expect_err("a sole missing X509Data retrieval must remain an explicit error");
assert!(matches!(
error,
SignatureVerificationPipelineError::InvalidStructure {
reason: "X509Data RetrievalMethod target is missing or ambiguous"
}
));
}
#[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(
"</ds:SignatureValue>\n </ds:Signature>",
r#"</ds:SignatureValue>
<ds:KeyInfo>
<ds:KeyName>primary</ds:KeyName>
<ds:RetrievalMethod URI="malformed.der" Type="http://www.w3.org/2000/09/xmldsig#rawX509Certificate"/>
</ds:KeyInfo>
</ds:Signature>"#,
);
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
// resolves, the first missing retrieval remains the pipeline failure.
let xml = signature_with_target_reference("AQ==").replace(
"</ds:SignatureValue>\n </ds:Signature>",
r#"</ds:SignatureValue>
<ds:KeyInfo>
<ds:RetrievalMethod URI="missing.der" Type="http://www.w3.org/2000/09/xmldsig#rawX509Certificate"/>
</ds:KeyInfo>
</ds:Signature>"#,
);
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_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(
"</ds:SignatureValue>\n </ds:Signature>",
r#"</ds:SignatureValue>
<ds:KeyInfo>
<ds:RetrievalMethod URI="malformed.der" Type="http://www.w3.org/2000/09/xmldsig#rawX509Certificate"/>
</ds:KeyInfo>
</ds:Signature>"#,
);
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("@@@");
let err = VerifyContext::new()
.key_resolver(&MissingKeyResolver)
.verify(&xml)
.expect_err("invalid SignatureValue must remain a decode error on resolver miss");
assert!(matches!(
err,
SignatureVerificationPipelineError::SignatureValueBase64(_)
));
}
#[test]
fn verify_context_preserves_signaturevalue_decode_errors_without_key() {
let xml = signature_with_target_reference("@@@");
let err = VerifyContext::new()
.verify(&xml)
.expect_err("invalid SignatureValue must remain a decode error");
assert!(matches!(
err,
SignatureVerificationPipelineError::SignatureValueBase64(_)
));
}
#[test]
fn enforce_reference_policies_rejects_missing_uri_before_uri_type_checks() {
let references = vec![Reference {
uri: None,
id: None,
ref_type: None,
transforms: vec![],
digest_method: DigestAlgorithm::Sha256,
digest_value: vec![0; 32],
}];
let uri_types = UriTypeSet {
allow_empty: false,
allow_same_document: true,
allow_external: false,
};
let err = enforce_reference_policies(&references, uri_types, None)
.expect_err("missing URI must fail before allow_empty policy is evaluated");
assert!(matches!(
err,
SignatureVerificationPipelineError::Reference(ReferenceProcessingError::MissingUri)
));
}
#[test]
fn enforce_reference_policies_checks_only_terminal_binary_output() {
let c14n = C14nAlgorithm::from_uri(DEFAULT_IMPLICIT_C14N_URI).unwrap();
let allowed = HashSet::from([
BASE64_TRANSFORM_URI.to_owned(),
DEFAULT_IMPLICIT_C14N_URI.to_owned(),
]);
let without_implicit_c14n = HashSet::from([BASE64_TRANSFORM_URI.to_owned()]);
for transforms in [
vec![Transform::Base64Decode, Transform::C14n(c14n)],
vec![Transform::Base64Decode, Transform::Base64Decode],
] {
let reference = make_reference("", transforms, DigestAlgorithm::Sha256, vec![0; 32]);
enforce_reference_policies(
std::slice::from_ref(&reference),
UriTypeSet::default(),
Some(&allowed),
)
.expect("terminal binary output must not require implicit C14N");
}
let terminal_base64 = make_reference(
"",
vec![Transform::Base64Decode, Transform::Base64Decode],
DigestAlgorithm::Sha256,
vec![0; 32],
);
enforce_reference_policies(
std::slice::from_ref(&terminal_base64),
UriTypeSet::default(),
Some(&without_implicit_c14n),
)
.expect("terminal Base64 output must not require implicit C14N");
let no_transforms = make_reference("", vec![], DigestAlgorithm::Sha256, vec![0; 32]);
let error = enforce_reference_policies(
std::slice::from_ref(&no_transforms),
UriTypeSet::default(),
Some(&without_implicit_c14n),
)
.expect_err("a node-set result must require allowlisted implicit C14N");
assert!(matches!(
error,
SignatureVerificationPipelineError::Policy(
crate::policy::PolicyViolation::Algorithm {
operation: "verification transform",
ref algorithm,
}
)
if algorithm == DEFAULT_IMPLICIT_C14N_URI
));
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),
)
.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::Policy(
crate::policy::PolicyViolation::Algorithm {
operation: "verification transform",
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("<ds:Signature xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\"/>")
.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::<Vec<_>>();
let resources = HashMap::from([("urn:repeated".to_owned(), payload)]);
let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources);
let transform_budget = TransformExecutionBudget::default();
let canonicalized_data_budget = CanonicalizedDataBudget::with_limit(32);
let execution = ReferenceExecutionContext {
store_pre_digest: true,
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(
&references,
&resolver,
document.root_element(),
&execution,
)
.expect_err(
"retained diagnostics must not multiply one external allocation past the aggregate cap",
);
assert!(matches!(
error,
ReferenceProcessingError::Policy(crate::policy::PolicyViolation::ResourceLimit {
resource: "canonicalized bytes",
maximum: 32,
..
})
));
}
#[test]
fn public_reference_entries_inherit_custom_id_registration() {
// Both public entry points must pass resolver registrations to XPath.
let xml = format!(
"<root xmlns:ds='{XMLDSIG_NS}'><data Token='target'>YWJj</data><ds:Signature/></root>"
);
let document = Document::parse(&xml).unwrap();
let signature = document
.descendants()
.find(|node| node.has_tag_name((XMLDSIG_NS, "Signature")))
.unwrap();
let registrations = [crate::IdAttributeRegistration::global("Token")];
let resolver = UriReferenceResolver::with_id_registrations(&document, ®istrations);
let expression = crate::xmldsig::transforms::XPathExpression::new(
"count(id('target')) = 1 and count(ancestor-or-self::node() | id('target')) = count(ancestor-or-self::node())",
);
let reference = make_reference(
"#target",
vec![Transform::XPath(expression), Transform::Base64Decode],
DigestAlgorithm::Sha256,
compute_digest(DigestAlgorithm::Sha256, b"abc"),
);
let one = process_reference(
&reference,
&resolver,
signature,
ReferenceSet::SignedInfo,
0,
true,
)
.unwrap();
assert_eq!(one.status, DsigStatus::Valid);
let all = process_all_references(&[reference], &resolver, signature, true).unwrap();
assert_eq!(all.results.len(), 1);
assert_eq!(all.results[0].status, DsigStatus::Valid);
assert!(all.first_failure.is_none());
}
#[test]
fn normalized_root_xpointers_bind_document_identity() {
// Spelling changes must not discard coverage evidence for the root.
let document = XmlDocument::parse("<root><item/></root>").unwrap();
let budgets = VerificationOperationBudgets::with_transforms(
&crate::policy::VerificationPolicy::default(),
TransformExecutionBudget::default(),
);
document.with_view(|view| {
let resolver = UriReferenceResolver::new(view.document());
for uri in ["#xpointer(/)", "#xpointer( / )", "#xpointer(%2F)"] {
let reference =
make_reference(uri, Vec::new(), DigestAlgorithm::Sha256, vec![0; 32]);
assert_eq!(
budgets.resource_identity_for_reference(&reference, 0, &resolver, view, None),
OperationResourceIdentity::DocumentNode(view.root()),
"{uri}"
);
}
});
}
#[test]
fn operation_memoizes_repeated_external_resource_identity() {
// The external resource map is immutably borrowed for the operation,
// so one content fingerprint safely identifies every repeated URI.
let document = XmlDocument::parse("<root/>").expect("fixture must parse");
let resources = HashMap::from([("urn:repeated".to_owned(), vec![b'x'; 8 * 1_024])]);
let reference = make_reference(
"urn:repeated",
Vec::new(),
DigestAlgorithm::Sha256,
vec![0; 32],
);
let budgets = VerificationOperationBudgets::with_transforms(
&crate::policy::VerificationPolicy::default(),
TransformExecutionBudget::default(),
);
document.with_view(|view| {
let resolver =
UriReferenceResolver::new(view.document()).with_external_resources(&resources);
let first =
budgets.resource_identity_for_reference(&reference, 0, &resolver, view, None);
let second =
budgets.resource_identity_for_reference(&reference, 1, &resolver, view, None);
assert_eq!(first, second);
assert_eq!(budgets.external_resource_identities.borrow().len(), 1);
});
}
#[test]
fn canonical_signed_info_obeys_policy_without_diagnostic_retention() {
// SignedInfo is always materialized for crypto verification, so its
// canonical bytes must consume the configured ceiling even when
// diagnostics do not retain reference output.
let xml = signature_with_target_reference("AQ==");
let marker = "<ds:SignatureMethod";
let padding = " ".repeat(1_025);
let xml = xml.replacen(marker, &format!("{padding}{marker}"), 1);
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("canonicalized SignedInfo must remain policy-bounded");
assert!(matches!(
error,
SignatureVerificationPipelineError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: "canonicalized bytes",
..
}
)
));
}
#[test]
fn fragmented_pq_signature_value_decodes_at_exact_wire_limit() {
// Comments split the largest supported signature into tiny text nodes;
// decoding must preserve bytes and reject an oversized aggregate.
let parameter = super::super::pq_algorithm::PqAlgorithm::SlhDsaSha2_256f;
let bytes = vec![0x5a; parameter.signature_len()];
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
let mut xml = String::from("<SignatureValue>");
for chunk in encoded.as_bytes().chunks(4) {
xml.push_str(std::str::from_utf8(chunk).unwrap());
xml.push_str("<!--split-->\n");
}
xml.push_str("</SignatureValue>");
let document = Document::parse(&xml).unwrap();
let algorithm = SignatureAlgorithm::PostQuantum(parameter);
assert_eq!(
decode_signature_value(
document.root_element(),
algorithm,
&crate::policy::ResourcePolicy::default()
)
.unwrap(),
bytes
);
let oversized = xml.replace("</SignatureValue>", "AAAA</SignatureValue>");
let document = Document::parse(&oversized).unwrap();
assert!(matches!(
decode_signature_value(
document.root_element(),
algorithm,
&crate::policy::ResourcePolicy::default()
),
Err(SignatureVerificationPipelineError::InvalidStructure {
reason: "SignatureValue exceeds maximum allowed length"
})
));
}
#[test]
fn push_normalized_signature_text_rejects_form_feed() {
let mut normalized = Vec::new();
let mut raw_text_len = 0usize;
let err = push_normalized_signature_text(
"ab\u{000C}cd",
&mut raw_text_len,
&mut normalized,
SignatureAlgorithm::RsaSha256,
&crate::policy::ResourcePolicy::default(),
)
.expect_err("form-feed must not be treated as XML base64 whitespace");
assert!(matches!(
err,
SignatureVerificationPipelineError::SignatureValueBase64(
base64::DecodeError::InvalidByte(_, 0x0C)
)
));
}
#[test]
fn push_normalized_signature_text_enforces_byte_limit_for_multibyte_chars() {
let mut normalized = vec![b'A'; MAX_SIGNATURE_VALUE_LEN - 1];
let mut raw_text_len = normalized.len();
let err = push_normalized_signature_text(
"é",
&mut raw_text_len,
&mut normalized,
SignatureAlgorithm::RsaSha256,
&crate::policy::ResourcePolicy::default(),
)
.expect_err("multibyte characters must not bypass byte-size limit");
assert!(matches!(
err,
SignatureVerificationPipelineError::InvalidStructure {
reason: "SignatureValue exceeds maximum allowed length"
}
));
}
// ── process_reference: happy path ────────────────────────────────
#[test]
fn reference_with_correct_digest_passes() {
// Create a simple document, compute its canonical form digest,
// then verify that process_reference returns Valid status.
let xml = r##"<root>
<data>hello world</data>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Id="sig1">
<ds:SignedInfo/>
</ds:Signature>
</root>"##;
let doc = Document::parse(xml).unwrap();
let resolver = UriReferenceResolver::new(&doc);
let sig_node = doc
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "Signature")
.unwrap();
// First, compute the expected digest by running the pipeline
let initial_data = resolver.dereference("").unwrap();
let transforms = vec![
Transform::Enveloped,
Transform::C14n(
crate::c14n::C14nAlgorithm::from_uri("http://www.w3.org/2001/10/xml-exc-c14n#")
.unwrap(),
),
];
let pre_digest_bytes =
crate::xmldsig::execute_transforms(sig_node, initial_data, &transforms).unwrap();
let expected_digest = compute_digest(DigestAlgorithm::Sha256, &pre_digest_bytes);
// Now build a Reference with the correct digest and verify
let reference = make_reference("", transforms, DigestAlgorithm::Sha256, expected_digest);
let result = process_reference(
&reference,
&resolver,
sig_node,
ReferenceSet::SignedInfo,
0,
false,
)
.unwrap();
assert!(
matches!(result.status, DsigStatus::Valid),
"digest should match"
);
assert!(result.pre_digest_data.is_none());
}
#[test]
fn reference_with_wrong_digest_fails() {
let xml = r##"<root>
<data>hello</data>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignedInfo/>
</ds:Signature>
</root>"##;
let doc = Document::parse(xml).unwrap();
let resolver = UriReferenceResolver::new(&doc);
let sig_node = doc
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "Signature")
.unwrap();
let transforms = vec![Transform::Enveloped];
// Wrong digest value — all zeros
let wrong_digest = vec![0u8; 32];
let reference = make_reference("", transforms, DigestAlgorithm::Sha256, wrong_digest);
let result = process_reference(
&reference,
&resolver,
sig_node,
ReferenceSet::SignedInfo,
0,
false,
)
.unwrap();
assert!(matches!(
result.status,
DsigStatus::Invalid(FailureReason::ReferenceDigestMismatch { ref_index: 0 })
));
}
#[test]
fn reference_with_wrong_digest_preserves_supplied_ref_index() {
let xml = r##"<root>
<data>hello</data>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignedInfo/>
</ds:Signature>
</root>"##;
let doc = Document::parse(xml).unwrap();
let resolver = UriReferenceResolver::new(&doc);
let sig_node = doc
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "Signature")
.unwrap();
let reference = make_reference(
"",
vec![Transform::Enveloped],
DigestAlgorithm::Sha256,
vec![0u8; 32],
);
let result = process_reference(
&reference,
&resolver,
sig_node,
ReferenceSet::SignedInfo,
7,
false,
)
.unwrap();
assert!(matches!(
result.status,
DsigStatus::Invalid(FailureReason::ReferenceDigestMismatch { ref_index: 7 })
));
}
#[test]
fn reference_stores_pre_digest_data() {
let xml = "<root><child>text</child></root>";
let doc = Document::parse(xml).unwrap();
let resolver = UriReferenceResolver::new(&doc);
// No transforms, no enveloped — just canonicalize entire document
let initial_data = resolver.dereference("").unwrap();
let pre_digest =
crate::xmldsig::execute_transforms(doc.root_element(), initial_data, &[]).unwrap();
let digest = compute_digest(DigestAlgorithm::Sha256, &pre_digest);
let reference = make_reference("", vec![], DigestAlgorithm::Sha256, digest);
let result = process_reference(
&reference,
&resolver,
doc.root_element(),
ReferenceSet::SignedInfo,
0,
true,
)
.unwrap();
assert!(matches!(result.status, DsigStatus::Valid));
assert!(result.pre_digest_data.is_some());
assert_eq!(result.pre_digest_data.unwrap(), pre_digest);
}
// ── process_reference: URI dereference ───────────────────────────
#[test]
fn reference_with_id_uri() {
let xml = r##"<root>
<item ID="target">specific content</item>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignedInfo/>
</ds:Signature>
</root>"##;
let doc = Document::parse(xml).unwrap();
let resolver = UriReferenceResolver::new(&doc);
let sig_node = doc
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "Signature")
.unwrap();
// Compute expected digest for the #target subtree
let initial_data = resolver.dereference("#target").unwrap();
let transforms = vec![Transform::C14n(
crate::c14n::C14nAlgorithm::from_uri("http://www.w3.org/2001/10/xml-exc-c14n#")
.unwrap(),
)];
let pre_digest =
crate::xmldsig::execute_transforms(sig_node, initial_data, &transforms).unwrap();
let expected_digest = compute_digest(DigestAlgorithm::Sha256, &pre_digest);
let reference = make_reference(
"#target",
transforms,
DigestAlgorithm::Sha256,
expected_digest,
);
let result = process_reference(
&reference,
&resolver,
sig_node,
ReferenceSet::SignedInfo,
0,
false,
)
.unwrap();
assert!(matches!(result.status, DsigStatus::Valid));
}
#[test]
fn reference_with_nonexistent_id_fails() {
let xml = "<root><child/></root>";
let doc = Document::parse(xml).unwrap();
let resolver = UriReferenceResolver::new(&doc);
let reference =
make_reference("#nonexistent", vec![], DigestAlgorithm::Sha256, vec![0; 32]);
let result = process_reference(
&reference,
&resolver,
doc.root_element(),
ReferenceSet::SignedInfo,
0,
false,
);
assert!(result.is_err());
}
#[test]
fn reference_with_absent_uri_fails_closed() {
let xml = "<root><child>text</child></root>";
let doc = Document::parse(xml).unwrap();
let resolver = UriReferenceResolver::new(&doc);
let reference = Reference {
uri: None, // absent URI
id: None,
ref_type: None,
transforms: vec![],
digest_method: DigestAlgorithm::Sha256,
digest_value: vec![0; 32],
};
let result = process_reference(
&reference,
&resolver,
doc.root_element(),
ReferenceSet::SignedInfo,
0,
false,
);
assert!(matches!(result, Err(ReferenceProcessingError::MissingUri)));
}
// ── process_all_references: fail-fast ────────────────────────────
#[test]
fn all_references_pass() {
let xml = "<root><child>text</child></root>";
let doc = Document::parse(xml).unwrap();
let resolver = UriReferenceResolver::new(&doc);
// Compute correct digest
let initial_data = resolver.dereference("").unwrap();
let pre_digest =
crate::xmldsig::execute_transforms(doc.root_element(), initial_data, &[]).unwrap();
let digest = compute_digest(DigestAlgorithm::Sha256, &pre_digest);
let refs = vec![
make_reference("", vec![], DigestAlgorithm::Sha256, digest.clone()),
make_reference("", vec![], DigestAlgorithm::Sha256, digest),
];
let result = process_all_references(&refs, &resolver, doc.root_element(), false).unwrap();
assert!(result.all_valid());
assert_eq!(result.results.len(), 2);
assert!(result.first_failure.is_none());
}
#[test]
fn reference_processing_shares_xpath_work_across_references() {
// A signature-wide meter must not reset when processing the next
// Reference, even though each transform chain is independently valid.
let document = Document::parse("<root/>").unwrap();
let resolver = UriReferenceResolver::new(&document);
let transform = Transform::XPath(super::super::transforms::XPathExpression::new("true()"));
let initial_data = resolver.dereference("").unwrap();
let pre_digest = crate::xmldsig::execute_transforms(
document.root_element(),
initial_data,
std::slice::from_ref(&transform),
)
.unwrap();
let digest = compute_digest(DigestAlgorithm::Sha256, &pre_digest);
let references = vec![
make_reference(
"",
vec![transform.clone()],
DigestAlgorithm::Sha256,
digest.clone(),
),
make_reference("", vec![transform], DigestAlgorithm::Sha256, digest),
];
let budget = TransformExecutionBudget::with_xpath_limit(12);
let canonicalized_data_budget = CanonicalizedDataBudget::default();
let execution = ReferenceExecutionContext {
store_pre_digest: false,
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(
&references,
&resolver,
document.root_element(),
&execution,
)
.expect_err("the second Reference must consume the first Reference's XPath work");
assert!(matches!(
error,
ReferenceProcessingError::Transform(TransformError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::XPATH_EVALUATION_WORK,
..
}
))
));
}
#[test]
fn reference_processing_shares_node_set_materialization_across_references() {
// Repeated references to the same small subtree must share one owned-
// string budget. Otherwise a large inherited namespace can be cloned
// once per Reference even when canonicalization emits little output.
let document = Document::parse(
r#"<root xmlns:n="urn:0123456789"><target Id="selected">payload</target></root>"#,
)
.unwrap();
let resolver = UriReferenceResolver::new(&document);
let initial_data = resolver.dereference("#selected").unwrap();
let pre_digest =
crate::xmldsig::execute_transforms(document.root_element(), initial_data, &[]).unwrap();
let digest = compute_digest(DigestAlgorithm::Sha256, &pre_digest);
let references = vec![
make_reference("#selected", vec![], DigestAlgorithm::Sha256, digest.clone()),
make_reference("#selected", vec![], DigestAlgorithm::Sha256, digest),
];
let budget = TransformExecutionBudget::with_node_set_materialization_limit(30);
let canonicalized_data_budget = CanonicalizedDataBudget::default();
let execution = ReferenceExecutionContext {
store_pre_digest: false,
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(
&references,
&resolver,
document.root_element(),
&execution,
)
.expect_err("the second Reference must consume the first Reference's materialization work");
assert!(matches!(
error,
ReferenceProcessingError::UriDereference(TransformError::Policy(
crate::policy::PolicyViolation::ResourceLimit {
resource: crate::policy::resource_name::NODE_SET_CUMULATIVE_OWNED_STRING_BYTES,
..
}
))
));
}
#[test]
fn fail_fast_on_first_mismatch() {
let xml = "<root><child>text</child></root>";
let doc = Document::parse(xml).unwrap();
let resolver = UriReferenceResolver::new(&doc);
let wrong_digest = vec![0u8; 32];
let refs = vec![
make_reference("", vec![], DigestAlgorithm::Sha256, wrong_digest.clone()),
// Second reference should NOT be processed
make_reference("", vec![], DigestAlgorithm::Sha256, wrong_digest),
];
let result = process_all_references(&refs, &resolver, doc.root_element(), false).unwrap();
assert!(!result.all_valid());
assert_eq!(result.first_failure, Some(0));
// Only first reference should be in results (fail-fast)
assert_eq!(result.results.len(), 1);
assert!(matches!(
result.results[0].status,
DsigStatus::Invalid(FailureReason::ReferenceDigestMismatch { ref_index: 0 })
));
}
#[test]
fn fail_fast_second_reference() {
let xml = "<root><child>text</child></root>";
let doc = Document::parse(xml).unwrap();
let resolver = UriReferenceResolver::new(&doc);
// Compute correct digest for first ref
let initial_data = resolver.dereference("").unwrap();
let pre_digest =
crate::xmldsig::execute_transforms(doc.root_element(), initial_data, &[]).unwrap();
let correct_digest = compute_digest(DigestAlgorithm::Sha256, &pre_digest);
let wrong_digest = vec![0u8; 32];
let refs = vec![
make_reference("", vec![], DigestAlgorithm::Sha256, correct_digest),
make_reference("", vec![], DigestAlgorithm::Sha256, wrong_digest),
];
let result = process_all_references(&refs, &resolver, doc.root_element(), false).unwrap();
assert!(!result.all_valid());
assert_eq!(result.first_failure, Some(1));
// Both references should be in results
assert_eq!(result.results.len(), 2);
assert!(matches!(result.results[0].status, DsigStatus::Valid));
assert!(matches!(
result.results[1].status,
DsigStatus::Invalid(FailureReason::ReferenceDigestMismatch { ref_index: 1 })
));
}
#[test]
fn empty_references_list() {
let xml = "<root/>";
let doc = Document::parse(xml).unwrap();
let resolver = UriReferenceResolver::new(&doc);
let result = process_all_references(&[], &resolver, doc.root_element(), false).unwrap();
assert!(result.all_valid());
assert!(result.results.is_empty());
}
// ── Digest algorithms ────────────────────────────────────────────
#[test]
fn reference_sha1_digest() {
let xml = "<root>content</root>";
let doc = Document::parse(xml).unwrap();
let resolver = UriReferenceResolver::new(&doc);
let initial_data = resolver.dereference("").unwrap();
let pre_digest =
crate::xmldsig::execute_transforms(doc.root_element(), initial_data, &[]).unwrap();
let digest = compute_digest(DigestAlgorithm::Sha1, &pre_digest);
let reference = make_reference("", vec![], DigestAlgorithm::Sha1, digest);
let result = process_reference(
&reference,
&resolver,
doc.root_element(),
ReferenceSet::SignedInfo,
0,
false,
)
.unwrap();
assert!(matches!(result.status, DsigStatus::Valid));
assert_eq!(result.digest_algorithm, DigestAlgorithm::Sha1);
}
#[test]
fn reference_sha512_digest() {
let xml = "<root>content</root>";
let doc = Document::parse(xml).unwrap();
let resolver = UriReferenceResolver::new(&doc);
let initial_data = resolver.dereference("").unwrap();
let pre_digest =
crate::xmldsig::execute_transforms(doc.root_element(), initial_data, &[]).unwrap();
let digest = compute_digest(DigestAlgorithm::Sha512, &pre_digest);
let reference = make_reference("", vec![], DigestAlgorithm::Sha512, digest);
let result = process_reference(
&reference,
&resolver,
doc.root_element(),
ReferenceSet::SignedInfo,
0,
false,
)
.unwrap();
assert!(matches!(result.status, DsigStatus::Valid));
assert_eq!(result.digest_algorithm, DigestAlgorithm::Sha512);
}
// ── SAML-like end-to-end ─────────────────────────────────────────
#[test]
fn saml_enveloped_reference_processing() {
// Realistic SAML Response with enveloped signature
let xml = r##"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_resp1">
<saml:Assertion ID="_assert1">
<saml:Subject>user@example.com</saml:Subject>
</saml:Assertion>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<ds:Reference URI="">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue>AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue>fakesig==</ds:SignatureValue>
</ds:Signature>
</samlp:Response>"##;
let doc = Document::parse(xml).unwrap();
let resolver = UriReferenceResolver::new(&doc);
let sig_node = doc
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "Signature")
.unwrap();
// Parse SignedInfo to get the Reference
let signed_info_node = sig_node
.children()
.find(|n| n.is_element() && n.tag_name().name() == "SignedInfo")
.unwrap();
let signed_info = parse_signed_info(signed_info_node).unwrap();
let reference = &signed_info.references[0];
// Compute the correct digest by running the actual pipeline
let initial_data = resolver.dereference("").unwrap();
let pre_digest =
crate::xmldsig::execute_transforms(sig_node, initial_data, &reference.transforms)
.unwrap();
let correct_digest = compute_digest(reference.digest_method, &pre_digest);
// Build a reference with the correct digest
let corrected_ref = make_reference(
"",
reference.transforms.clone(),
reference.digest_method,
correct_digest,
);
// Verify: should pass
let result = process_reference(
&corrected_ref,
&resolver,
sig_node,
ReferenceSet::SignedInfo,
0,
true,
)
.unwrap();
assert!(
matches!(result.status, DsigStatus::Valid),
"SAML reference should verify"
);
assert!(result.pre_digest_data.is_some());
// Verify the pre-digest data contains the canonicalized document without Signature
let pre_digest_str = String::from_utf8(result.pre_digest_data.unwrap()).unwrap();
assert!(
pre_digest_str.contains("samlp:Response"),
"pre-digest should contain Response"
);
assert!(
!pre_digest_str.contains("SignatureValue"),
"pre-digest should NOT contain Signature"
);
}
#[test]
fn pipeline_missing_signed_info_returns_missing_element() {
let xml = r#"<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"></ds:Signature>"#;
let err = verify_signature_with_pem_key(xml, "dummy-key", false)
.expect_err("missing SignedInfo must fail before crypto stage");
assert!(matches!(
err,
SignatureVerificationPipelineError::MissingElement {
element: "SignedInfo"
}
));
}
#[test]
fn pipeline_multiple_signature_elements_are_rejected() {
let xml = r#"
<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:Signature>
<ds:SignedInfo/>
</ds:Signature>
<ds:Signature/>
</root>
"#;
let err = verify_signature_with_pem_key(xml, "dummy-key", false)
.expect_err("multiple signatures must fail closed");
assert!(matches!(
err,
SignatureVerificationPipelineError::InvalidStructure {
reason: "Signature must appear exactly once in document",
}
));
}
#[test]
fn pipeline_start_node_limits_signature_cardinality_to_its_subtree() {
// A start-node selector changes the operation root, not global ID or
// reference resolution; another Signature outside the subtree is irrelevant.
let xml = r#"
<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<scope Id="selected"><ds:Signature/></scope>
<scope Id="other"><ds:Signature/></scope>
</root>
"#;
let err = VerifyContext::new()
.start_node_id("selected")
.verify(xml)
.expect_err("the selected Signature remains structurally incomplete");
assert!(matches!(
err,
SignatureVerificationPipelineError::MissingElement {
element: "SignedInfo"
}
));
}
#[test]
fn pipeline_reports_keyinfo_parse_error() {
let xml = r#"
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
xmlns:dsig11="http://www.w3.org/2009/xmldsig11#">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<ds:Reference URI="">
<ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
<ds:DigestValue>AAAAAAAAAAAAAAAAAAAAAAAAAAA=</ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue>AA==</ds:SignatureValue>
<ds:KeyInfo>
<dsig11:DEREncodedKeyValue>%%%invalid%%%</dsig11:DEREncodedKeyValue>
</ds:KeyInfo>
</ds:Signature>
"#;
let err = VerifyContext::new().verify(xml).expect_err(
"invalid KeyInfo must map to ParseKeyInfo when no explicit key is supplied",
);
assert!(matches!(
err,
SignatureVerificationPipelineError::ParseKeyInfo(_)
));
}
#[test]
fn pipeline_ignores_malformed_keyinfo_when_explicit_key_is_supplied() {
let base_xml = signature_with_target_reference("AQ==");
let xml = base_xml
.replace(
r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#">"#,
r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:dsig11="http://www.w3.org/2009/xmldsig11#">"#,
)
.replace(
"</ds:SignatureValue>\n </ds:Signature>",
"</ds:SignatureValue>\n <ds:KeyInfo><dsig11:DEREncodedKeyValue>%%%invalid%%%</dsig11:DEREncodedKeyValue></ds:KeyInfo>\n </ds:Signature>",
);
let result = VerifyContext::new()
.key(&RejectingKey)
.verify(&xml)
.expect("explicit key path should not fail on malformed KeyInfo");
assert!(matches!(
result.status,
DsigStatus::Invalid(FailureReason::SignatureMismatch)
));
}
#[test]
fn pipeline_rejects_foreign_element_children_under_signature() {
let base_xml = signature_with_target_reference("AQ==");
let xml = base_xml
.replace(
r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#">"#,
r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:foo="urn:example:foo">"#,
)
.replace(
"</ds:SignedInfo>\n <ds:SignatureValue>",
"</ds:SignedInfo>\n <foo:Bar/>\n <ds:SignatureValue>",
);
let err = VerifyContext::new()
.key(&RejectingKey)
.verify(&xml)
.expect_err("foreign element children under Signature must fail closed");
assert!(matches!(
err,
SignatureVerificationPipelineError::InvalidStructure {
reason: "Signature must contain only XMLDSIG element children",
}
));
}
#[test]
fn pipeline_rejects_non_whitespace_mixed_content_under_signature() {
let base_xml = signature_with_target_reference("AQ==");
let xml = base_xml.replace(
"</ds:SignedInfo>\n <ds:SignatureValue>",
"</ds:SignedInfo>\n oops\n <ds:SignatureValue>",
);
let err = VerifyContext::new()
.key(&RejectingKey)
.verify(&xml)
.expect_err("non-whitespace mixed content under Signature must fail closed");
assert!(matches!(
err,
SignatureVerificationPipelineError::InvalidStructure {
reason: "Signature must not contain non-whitespace mixed content",
}
));
}
#[test]
fn pipeline_rejects_keyinfo_out_of_order() {
let base_xml = signature_with_target_reference("AQ==");
let xml = base_xml.replace(
"</ds:SignatureValue>\n </ds:Signature>",
"</ds:SignatureValue>\n <ds:Object/>\n <ds:KeyInfo><ds:KeyName>late</ds:KeyName></ds:KeyInfo>\n </ds:Signature>",
);
let err = VerifyContext::new()
.key(&RejectingKey)
.verify(&xml)
.expect_err("KeyInfo after Object must be rejected by Signature child order checks");
assert!(matches!(
err,
SignatureVerificationPipelineError::InvalidStructure {
reason: "KeyInfo must be the third element child of Signature when present"
}
));
}
#[test]
fn pipeline_accepts_comments_and_processing_instructions_under_signature() {
let xml = r#"
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<?dbg keep ?>
<!-- signature metadata -->
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<ds:Reference URI="">
<ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
<ds:DigestValue>AAAAAAAAAAAAAAAAAAAAAAAAAAA=</ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<!-- between required children -->
<ds:SignatureValue>AA==</ds:SignatureValue>
</ds:Signature>
"#;
let doc = Document::parse(xml).expect("test XML must parse");
let signature_node = doc.root_element();
let parsed = parse_signature_children(signature_node)
.expect("comment/PI nodes under Signature must be ignored");
assert_eq!(parsed.signed_info_node.tag_name().name(), "SignedInfo");
assert_eq!(
parsed.signature_value_node.tag_name().name(),
"SignatureValue"
);
assert!(parsed.key_info_node.is_none());
}
}