diff --git a/src/operation.rs b/src/operation.rs index 78a9b36e..80bc7ce5 100644 --- a/src/operation.rs +++ b/src/operation.rs @@ -78,13 +78,22 @@ pub(crate) enum OperationResourceIdentity { document: usize, node: crate::NodeId, }, + /// Caller's immutable resource map, valid only while its operation borrows + /// it. Never used as a cross-operation or persistent cache identity. #[cfg(feature = "xmldsig")] - DocumentNode(NodeIdentity), - External { + BorrowedResourceSet(usize), + /// One entry of that map, identified without reading its bytes. + #[cfg(feature = "xmldsig")] + BorrowedExternal { uri: String, - fingerprint: [u8; 32], + address: usize, + length: usize, }, #[cfg(feature = "xmldsig")] + DocumentNode(NodeIdentity), + #[cfg(feature = "xmlenc")] + External { uri: String, fingerprint: [u8; 32] }, + #[cfg(feature = "xmldsig")] // Absence has no bytes to cache. The originating graph Reference retains // provenance; cloning absence must not clone an unaccounted URI string. MissingExternal, @@ -93,6 +102,7 @@ pub(crate) enum OperationResourceIdentity { } impl OperationResourceIdentity { + #[cfg(feature = "xmlenc")] pub(crate) fn external(uri: impl Into, bytes: &[u8]) -> Self { use sha2::Digest; diff --git a/src/operation/tests.rs b/src/operation/tests.rs index 17ab63e8..c953ad16 100644 --- a/src/operation/tests.rs +++ b/src/operation/tests.rs @@ -274,8 +274,8 @@ fn stage_regression_and_repeat_execution_are_rejected() { #[cfg(feature = "xmldsig")] #[test] fn resource_identity_is_checked_before_the_action_runs() { - let expected = OperationResourceIdentity::external("urn:test", b"expected"); - let observed = OperationResourceIdentity::external("urn:test", b"changed"); + let expected = OperationResourceIdentity::Generated("urn:test", 0); + let observed = OperationResourceIdentity::Generated("urn:test", 1); let mut context = OperationExecutionContext::new((), (), None); let node = context.add_node( OperationNodeKind::Digest { index: 0 }, @@ -300,7 +300,7 @@ fn resource_identity_is_checked_before_the_action_runs() { fn resource_bound_node_requires_an_observed_identity() { // A caller cannot accidentally bypass provenance validation by using the // generic execution entry point for a resource-bound operation node. - let expected = OperationResourceIdentity::external("urn:test", b"expected"); + let expected = OperationResourceIdentity::Generated("urn:test", 0); let mut context = OperationExecutionContext::new((), (), None); let node = context.add_node( OperationNodeKind::Digest { index: 0 }, diff --git a/src/xmldsig/base64_stream.rs b/src/xmldsig/base64_stream.rs new file mode 100644 index 00000000..2c5f2377 --- /dev/null +++ b/src/xmldsig/base64_stream.rs @@ -0,0 +1,152 @@ +//! Streaming decoder of the `XMLDSig` Base64 transform. + +#[cfg(test)] +mod tests; + +/// Alphabet bytes are decoded in blocks of whole quanta; the same `STANDARD` +/// engine as the resident transform enforces padding. A decoded block spans a +/// whole digest block, so the digest hashes it in place. +const BASE64_BLOCK: usize = 8192; + +/// RFC 2045 section 6.8: decoders ignore every byte outside the alphabet. +pub(crate) fn is_rfc2045_base64_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=') +} + +pub(crate) struct Base64Stream { + block: [u8; BASE64_BLOCK], + filled: usize, + /// Alphabet bytes accepted so far: the offset the resident decoder reports + /// within its filtered input. + offset: usize, + /// Offset of the padding that ended the last decoded block. + padding: Option, + failure: Option, +} + +impl Base64Stream { + pub(crate) fn new() -> Self { + Self { + block: [0; BASE64_BLOCK], + filled: 0, + offset: 0, + padding: None, + failure: None, + } + } + + /// Whether decoding still runs; a decoding error ends it. + pub(crate) fn active(&self) -> bool { + self.failure.is_none() + } + + /// Decode `chunk` and hand each decoded block to `sink`. A decoding error + /// is kept and stops this stream only. + pub(crate) fn feed( + &mut self, + chunk: &[u8], + sink: &mut impl FnMut(&[u8]) -> Result<(), E>, + ) -> Result<(), E> { + if self.failure.is_some() { + return Ok(()); + } + let mut chunk = chunk; + while !chunk.is_empty() { + if let Some(padding) = self.padding { + // Padding ended the encoded data in an earlier block; the + // resident decoder reports its first byte when more follows. + if chunk.iter().any(|byte| is_rfc2045_base64_byte(*byte)) { + self.failure = Some(base64::DecodeError::InvalidByte(padding, b'=')); + } + return Ok(()); + } + let run = chunk.len().min(BASE64_BLOCK - self.filled); + let (head, tail) = chunk.split_at(run); + chunk = tail; + let start = self.filled; + // Encoded runs hold only alphabet bytes, so they are copied whole. + if head + .iter() + .fold(true, |clean, &byte| clean & is_rfc2045_base64_byte(byte)) + { + self.block[start..start + run].copy_from_slice(head); + self.filled += run; + } else { + // `run` fits the free space, so even the last write is in bounds. + for &byte in head { + self.block[self.filled] = byte; + self.filled += usize::from(is_rfc2045_base64_byte(byte)); + } + } + // Bounded by the caller's input limit, which fits usize. + self.offset += self.filled - start; + if self.filled == BASE64_BLOCK { + self.flush(sink)?; + if self.failure.is_some() { + return Ok(()); + } + } + } + Ok(()) + } + + fn flush(&mut self, sink: &mut impl FnMut(&[u8]) -> Result<(), E>) -> Result<(), E> { + use base64::Engine as _; + + let mut decoded = [0_u8; BASE64_BLOCK / 4 * 3]; + let encoded = &self.block[..self.filled]; + // The block holds the last `filled` accepted bytes. + let base = self.offset - encoded.len(); + let padding = encoded + .iter() + .rev() + .take_while(|byte| **byte == b'=') + .count(); + self.padding = (padding > 0).then(|| self.offset - padding); + self.filled = 0; + match base64::engine::general_purpose::STANDARD.decode_slice(encoded, &mut decoded) { + Ok(written) => sink(&decoded[..written]), + Err(error) => { + self.failure = Some(match error { + base64::DecodeSliceError::DecodeError(base64::DecodeError::InvalidByte( + index, + byte, + )) => base64::DecodeError::InvalidByte(base + index, byte), + base64::DecodeSliceError::DecodeError( + base64::DecodeError::InvalidLastSymbol { + offset, + symbol, + symbol_value, + }, + ) => base64::DecodeError::InvalidLastSymbol { + offset: base + offset, + symbol, + symbol_value, + }, + base64::DecodeSliceError::DecodeError(base64::DecodeError::InvalidLength( + length, + )) => base64::DecodeError::InvalidLength(base + length), + base64::DecodeSliceError::DecodeError(error) => error, + // The output block holds every quantum of a full input + // block, so its size cannot be the failure. + base64::DecodeSliceError::OutputSliceTooSmall => { + base64::DecodeError::InvalidLength(encoded.len()) + } + }); + Ok(()) + } + } + } + + /// Decode the final partial block; an incomplete quantum is the padding + /// error the resident `STANDARD` decoder reports for the whole input. + pub(crate) fn finish( + mut self, + sink: &mut impl FnMut(&[u8]) -> Result<(), E>, + ) -> Result, E> { + if self.failure.is_none() && self.filled > 0 { + self.flush(sink)?; + } + Ok(self.failure) + } +} diff --git a/src/xmldsig/base64_stream/tests.rs b/src/xmldsig/base64_stream/tests.rs new file mode 100644 index 00000000..306408f0 --- /dev/null +++ b/src/xmldsig/base64_stream/tests.rs @@ -0,0 +1,141 @@ +use super::{Base64Stream, is_rfc2045_base64_byte}; +use alloc::{format, string::String, vec::Vec}; +use base64::Engine as _; + +/// The decoded bytes and error of one streamed input. +fn streamed(input: &[u8], chunk: usize) -> (Vec, Option) { + let mut output = Vec::new(); + let mut sink = |decoded: &[u8]| -> Result<(), ()> { + output.extend_from_slice(decoded); + Ok(()) + }; + let mut stream = Base64Stream::new(); + for piece in input.chunks(chunk) { + stream + .feed(piece, &mut sink) + .expect("the sink never refuses"); + } + let failure = stream.finish(&mut sink).expect("the sink never refuses"); + (output, failure) +} + +fn resident(input: &[u8]) -> Result, base64::DecodeError> { + let filtered: Vec = input + .iter() + .copied() + .filter(|byte| is_rfc2045_base64_byte(*byte)) + .collect(); + base64::engine::general_purpose::STANDARD.decode(filtered) +} + +/// A decoded payload long enough to cross several decoding blocks. +fn encoded(length: usize) -> String { + let payload: Vec = [0_u8, 1, 2, 250, 251, 255] + .into_iter() + .cycle() + .take(length) + .collect(); + base64::engine::general_purpose::STANDARD.encode(payload) +} + +fn assert_matches_resident(input: &[u8]) { + // The chunk boundary decides which feed path a byte takes, so it must + // never change the outcome. + for chunk in [1, 3, 4, 7, 4095, 8191, 8192, 8193, input.len().max(1)] { + let (output, failure) = streamed(input, chunk); + match resident(input) { + Ok(expected) => { + assert_eq!(failure, None, "chunk {chunk}"); + assert_eq!(output, expected, "chunk {chunk}"); + } + Err(expected) => assert_eq!(failure, Some(expected), "chunk {chunk}"), + } + } +} + +#[test] +fn valid_input_matches_the_resident_decoder_at_every_block_boundary() { + // Lengths around one block cover a final quantum on and past the boundary. + for length in [0, 1, 2, 3, 6141, 6142, 6143, 6144, 6145, 6146, 20_000] { + assert_matches_resident(encoded(length).as_bytes()); + } +} + +#[test] +fn ignored_bytes_match_the_resident_filter_wherever_they_fall() { + // RFC 2045 section 6.8: a foreign byte moves a run onto the byte filter. + let plain = encoded(20_000); + let lines = plain + .as_bytes() + .chunks(76) + .collect::>() + .join(&b"\r\n"[..]); + assert_matches_resident(&lines); + let mut late = plain.into_bytes(); + late.insert(10_000, b' '); + assert_matches_resident(&late); + assert_matches_resident(b" \n\t"); +} + +#[test] +fn padding_errors_match_the_resident_decoder_inside_and_across_blocks() { + let block = "QUJD".repeat(2048); + for input in [ + // Data after padding inside one block. + "QQ==QUJD".to_owned(), + // Padding ends a block; the data follows in the next one. + format!("{}QQ==QUJD", "QUJD".repeat(2047)), + // The same with a separator hiding the next alphabet byte. + format!("{}QQ==\nQUJD", "QUJD".repeat(2047)), + // Data after padding beyond the first block. + format!("{block}QQ==QUJD"), + // Padding alone, in excess, and in an incomplete quantum. + "====".to_owned(), + "QQ===".to_owned(), + format!("{block}Q"), + format!("{block}QQ"), + format!("{block}QQ="), + // Non-canonical trailing bits. + "QR==".to_owned(), + ] { + assert_matches_resident(input.as_bytes()); + } +} + +#[test] +fn a_sink_error_stops_the_stream_with_that_error() { + let input = encoded(20_000); + let mut calls = 0; + let mut stream = Base64Stream::new(); + let error = stream + .feed(input.as_bytes(), &mut |_: &[u8]| { + calls += 1; + Err("refused") + }) + .expect_err("a sink refusal ends the feed"); + assert_eq!(error, "refused"); + assert_eq!(calls, 1); +} + +#[test] +fn a_decoding_failure_ends_the_stream_and_ignores_later_input() { + let mut stream = Base64Stream::new(); + let mut sink = |_: &[u8]| -> Result<(), ()> { Ok(()) }; + stream + .feed(b"QQ==", &mut sink) + .expect("the sink never refuses"); + let block = "QUJD".repeat(2048); + stream + .feed(block.as_bytes(), &mut sink) + .expect("the sink never refuses"); + assert!(!stream.active()); + stream + .feed(b"anything", &mut sink) + .expect("the sink never refuses"); + assert!( + stream + .finish(&mut sink) + .expect("the sink never refuses") + .is_some() + ); +} diff --git a/src/xmldsig/mod.rs b/src/xmldsig/mod.rs index c762ebc7..1a0c4e84 100644 --- a/src/xmldsig/mod.rs +++ b/src/xmldsig/mod.rs @@ -56,6 +56,7 @@ //! ``` #![doc = include_str!("../../docs/xmldsig.md")] +mod base64_stream; #[cfg(feature = "xmldsig-sign")] pub mod builder; #[cfg(feature = "cms")] diff --git a/src/xmldsig/transforms.rs b/src/xmldsig/transforms.rs index 2f3ef704..af78a3a7 100644 --- a/src/xmldsig/transforms.rs +++ b/src/xmldsig/transforms.rs @@ -52,6 +52,7 @@ use crate::xml::dom::{Document, Node, NodeId, NodeType}; use base64::{Engine as _, engine::general_purpose::STANDARD}; use sha2::{Digest as _, Sha256}; +use super::base64_stream::is_rfc2045_base64_byte; use super::parse::XMLDSIG_NS; use super::types::projection::{Keys, NativeKeys, PaidKeys, Projection}; use super::types::{ @@ -1192,10 +1193,6 @@ fn append_normalized_base64( Ok(()) } -fn is_rfc2045_base64_byte(byte: u8) -> bool { - byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=') -} - fn decode_base64_transform( normalized: &[u8], budget: &Base64WorkBudget, @@ -1228,26 +1225,38 @@ fn base64_projection Result { let mut normalized = scratch.bytes(); + for_each_base64_input(binary, nodes, scratch, |encoded| { + append_normalized_base64(encoded, &mut normalized, &budget.base64, scratch) + })?; + decode_base64_transform(&normalized, &budget.base64, scratch) +} + +/// Visit the Base64 input in order: octets, or the selected text nodes in +/// document order (`XMLDSig` 1.1 section 6.6.2). +/// +fn for_each_base64_input( + binary: Option<&[u8]>, + nodes: Option<&Projection<'_, K, D>>, + scratch: &S, + mut visit: impl FnMut(&[u8]) -> Result<(), E>, +) -> Result<(), E> +where + E: From, +{ match (binary, nodes) { - (Some(bytes), _) => { - append_normalized_base64(bytes, &mut normalized, &budget.base64, scratch)?; - } + (Some(bytes), _) => visit(bytes), (None, Some(nodes)) => { for node in nodes.document().descendants() { - scratch.charge(1)?; - if nodes.contains_tree(node)? && node.is_text() { - append_normalized_base64( - node.text().unwrap_or_default().as_bytes(), - &mut normalized, - &budget.base64, - scratch, - )?; + scratch.charge(1).map_err(TransformError::from)?; + let selected = nodes.contains_tree(node).map_err(TransformError::from)?; + if selected && node.is_text() { + visit(node.text().unwrap_or_default().as_bytes())?; } } + Ok(()) } (None, None) => unreachable!("execution data has either nodes or bytes"), } - decode_base64_transform(&normalized, &budget.base64, scratch) } /// Execute a chain of transforms for a single ``. @@ -1772,6 +1781,23 @@ pub(crate) trait TransformTerminal { explicit: Option<(&C14nAlgorithm, Option<&str>)>, budget: &TransformExecutionBudget, ) -> Result; + + /// Finish a chain whose last transform is Base64, by default on its + /// materialized output. + fn finish_base64( + self, + data: ExecutionData<'_, S, D>, + budget: &TransformExecutionBudget, + scratch: &S, + ) -> Result + where + Self: Sized, + { + let bytes = base64_projection(data.binary(), data.nodes(), budget, scratch)?; + // The consumed input must not stay pinned while the terminal runs. + drop(data); + self.finish(ExecutionData::::Accounted(bytes), None, budget) + } } struct CollectTerminal; @@ -1825,6 +1851,17 @@ impl TransformTerminal for DiscardTerminal { ) -> Result<(), TransformError> { Ok(()) } + + // The digest pass decodes and charges the payload once; decoding here + // would spend the operation's allowance on output nobody reads. + fn finish_base64( + self, + _: ExecutionData<'_, Native, D>, + _: &TransformExecutionBudget, + _: &Native, + ) -> Result<(), TransformError> { + Ok(()) + } } /// Digest of one Reference's final transform output. @@ -1990,6 +2027,44 @@ impl TransformTerminal for DigestTerminal<'_> { .map_err(ReferenceDigestError::Provider)?; Ok(ReferenceDigest { digest, pre_digest }) } + + // The input allowance is charged for all input before a decoding failure + // is reported, as the resident transform does. + fn finish_base64( + self, + data: ExecutionData<'_, Native, D>, + budget: &TransformExecutionBudget, + scratch: &Native, + ) -> Result { + let DigestRequest { + provider, + algorithm, + retain_pre_digest, + } = self.0; + let mut sink = DigestWriter::new( + super::digest::begin_digest(provider, algorithm) + .map_err(ReferenceDigestError::Provider)?, + retain_pre_digest, + ); + let mut absorb = |decoded: &[u8]| -> Result<(), ReferenceDigestError> { + budget.base64.charge_output(decoded.len())?; + sink.absorb(decoded).map_err(ReferenceDigestError::Provider) + }; + let mut stream = super::base64_stream::Base64Stream::new(); + for_each_base64_input(data.binary(), data.nodes(), scratch, |encoded| { + budget.base64.charge_input(encoded.len())?; + stream.feed(encoded, &mut absorb) + })?; + if let Some(failure) = stream.finish(&mut absorb)? { + return Err( + TransformError::Base64(base64::DecodeSliceError::DecodeError(failure)).into(), + ); + } + let (digest, pre_digest) = sink + .finish(algorithm) + .map_err(ReferenceDigestError::Provider)?; + Ok(ReferenceDigest { digest, pre_digest }) + } } /// Execute a Reference in its actual source context and digest its output as @@ -2499,6 +2574,40 @@ fn apply_chain_step<'e, 'd, S: TransformScratch, D: DomStorage, I: Instruction>( Ok(ChainStep::untracked(enveloped_signature, data)) } +fn chain_output( + output: T, + #[cfg_attr(not(feature = "xmldsig-sign"), expect(unused_variables))] tracking: &mut Option< + DependencyTracking, + >, +) -> TransformChainOutput { + TransformChainOutput { + output, + #[cfg(feature = "xmldsig-sign")] + dependencies: dependency_indexes(tracking.take()), + } +} + +/// Binary input not produced by tracked canonicalization is a different +/// document. Keep the source signature for `XPath` `here()` semantics, but do +/// not apply its identity to Enveloped. +fn remap_enveloped_signature<'e, D: DomStorage, O: DomStorage>( + document: &'e Document<'_, D>, + provenance: SignatureProvenance, + enveloped_signature: Option>, +) -> Result>, TransformError> { + let SignatureProvenance::Canonical(Some(position)) = provenance else { + return Ok(None); + }; + document + .descendants() + .find(|node| node.is_element() && node.range().start == position) + .filter(|node| { + enveloped_signature.is_some_and(|source| node.tag_name() == source.tag_name()) + }) + .map(Some) + .ok_or(TransformError::CrossDocumentSignatureNode) +} + // Terminal strategy is monomorphized: collecting callers retain their existing // position/remapping path, while streaming consumers may intercept final C14N. fn execute_transform_chain< @@ -2532,11 +2641,7 @@ where retain_visible_dependencies(nodes, tracking)?; } let output = finalize.finish(data, None, context.budget)?; - return Ok(TransformChainOutput { - output, - #[cfg(feature = "xmldsig-sign")] - dependencies: dependency_indexes(dependency_tracking), - }); + return Ok(chain_output(output, &mut dependency_tracking)); }; let binary = if transform_requires_node_set(transform) { @@ -2571,22 +2676,7 @@ where if let Some(tracking) = &mut dependency_tracking { tracking.remap_reparsed(&document); } - // Binary input not produced by tracked canonicalization is a different - // document. Keep source_signature for XPath here() semantics, but do not - // apply its identity to Enveloped. - let remapped = match provenance { - SignatureProvenance::Canonical(Some(position)) => Some( - document - .descendants() - .find(|node| node.is_element() && node.range().start == position) - .filter(|node| { - enveloped_signature - .is_some_and(|source| node.tag_name() == source.tag_name()) - }) - .ok_or(TransformError::CrossDocumentSignatureNode)?, - ), - SignatureProvenance::Canonical(None) | SignatureProvenance::Untracked => None, - }; + let remapped = remap_enveloped_signature(&document, provenance, enveloped_signature)?; return execute_transform_chain::( source_signature, ChainStep::untracked(remapped, ExecutionData::Nodes(nodes)), @@ -2606,12 +2696,21 @@ where } if STREAM_FINAL_C14N && remaining.is_empty() { let output = finalize.finish(data, Some((algo, prefixes)), context.budget)?; - return Ok(TransformChainOutput { - output, - #[cfg(feature = "xmldsig-sign")] - dependencies: dependency_indexes(dependency_tracking), - }); + return Ok(chain_output(output, &mut dependency_tracking)); + } + } + if STREAM_FINAL_C14N + && remaining.is_empty() + && matches!(transform.view(), InstructionView::Base64) + { + if let Some(tracking) = &mut dependency_tracking { + if let Some(nodes) = data.nodes() { + retain_visible_dependencies(nodes, tracking)?; + } + tracking.discard_provenance(); } + let output = finalize.finish_base64(data, context.budget, context.scratch)?; + return Ok(chain_output(output, &mut dependency_tracking)); } // The step consumes its input, so a decoded or canonicalized predecessor is // never pinned in this frame while the rest of the chain runs. diff --git a/src/xmldsig/uri.rs b/src/xmldsig/uri.rs index 337d8dc4..2dc4670f 100644 --- a/src/xmldsig/uri.rs +++ b/src/xmldsig/uri.rs @@ -555,26 +555,33 @@ impl<'a> UriReferenceResolver<'a> { ) -> Option { self.external_resources .and_then(|resources| resources.get(uri)) - .map(|bytes| crate::operation::OperationResourceIdentity::external(uri, bytes)) + .map( + |bytes| crate::operation::OperationResourceIdentity::BorrowedExternal { + uri: uri.to_owned(), + address: bytes.as_ptr().addr(), + length: bytes.len(), + }, + ) + } + + /// SHA-256 identity of the bytes mapped to `uri`. + pub(crate) fn external_resource_fingerprint(&self, uri: &str) -> Option<[u8; 32]> { + use sha2::Digest as _; + + self.external_resources + .and_then(|resources| resources.get(uri)) + .map(|bytes| sha2::Sha256::digest(bytes).into()) } pub(crate) fn external_resource_set_identity( &self, ) -> crate::operation::OperationResourceIdentity { - use sha2::Digest; - - let mut hasher = sha2::Sha256::new(); - // The ordered map yields entries by URI, so the identity is stable. - for (uri, bytes) in self.external_resources.into_iter().flatten() { - hasher.update(uri.len().to_be_bytes()); - hasher.update(uri.as_bytes()); - hasher.update(bytes.len().to_be_bytes()); - hasher.update(bytes); - } - crate::operation::OperationResourceIdentity::External { - uri: "caller-owned-external-resource-set".to_owned(), - fingerprint: hasher.finalize().into(), - } + // The map stays immutably borrowed for the whole operation, so its + // address identifies its bytes without reading them. + crate::operation::OperationResourceIdentity::BorrowedResourceSet( + self.external_resources + .map_or(0, |resources| core::ptr::from_ref(resources).addr()), + ) } /// Dereference a URI string to a [`TransformData`]. diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index 4154b7f0..633d8448 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -1224,6 +1224,28 @@ fn reference_origin_node<'a, 'input>( } } +fn bind_external_evidence( + result: &mut ReferenceResult, + reference: &Reference, + observed: &OperationResourceIdentity, + budgets: &VerificationOperationBudgets<'_>, + resolver: &UriReferenceResolver<'_>, +) { + let OperationResourceIdentity::BorrowedExternal { uri, .. } = observed else { + return; + }; + // Without transforms a valid SHA-256 DigestValue was just compared against + // the digest of exactly these bytes, so it is their fingerprint. + let digested = (result.status == DsigStatus::Valid + && reference.transforms.is_empty() + && reference.digest_method == DigestAlgorithm::Sha256) + .then(|| <[u8; 32]>::try_from(reference.digest_value.as_slice()).ok()) + .flatten(); + result.external_resource_fingerprint = + budgets.external_resource_fingerprint(uri, resolver, digested); + result.external_payload = Some(ExternalPayload::Resident); +} + fn bind_reference_evidence( result: &mut ReferenceResult, reference: &Reference, @@ -1232,10 +1254,6 @@ fn bind_reference_evidence( observed: &OperationResourceIdentity, ) { result.reference_identity = origin.map(|node| view.node_identity(node)); - if let OperationResourceIdentity::External { fingerprint, .. } = observed { - result.external_resource_fingerprint = Some(*fingerprint); - result.external_payload = Some(ExternalPayload::Resident); - } if let OperationResourceIdentity::DocumentNode(target) = observed { result.target_identity = Some(*target); // A later node-set transform can reparse C14N output, so arbitrary @@ -1281,9 +1299,8 @@ struct VerificationOperationBudgets<'a> { canonicalized: CanonicalizedDataBudget, xpath_parse: RefCell, key_info_materialization: RefCell, - external_resource_identities: RefCell>, + external_resource_fingerprints: RefCell>, resolved_reference_resources: RefCell>, - external_resource_set_identity: RefCell>, } impl<'a> VerificationOperationBudgets<'a> { @@ -1309,22 +1326,27 @@ impl<'a> VerificationOperationBudgets<'a> { ), xpath_parse: RefCell::new(xpath_parse), key_info_materialization: RefCell::new(KeyInfoMaterializationState::default()), - external_resource_identities: RefCell::new(Map::new()), + external_resource_fingerprints: RefCell::new(Map::new()), resolved_reference_resources: RefCell::new(Map::new()), - external_resource_set_identity: RefCell::new(None), } } - fn external_resource_set_identity( + /// One resolver borrows the same immutable map for the entire request, so + /// each URI is hashed at most once, and not at all when `digested` is given. + fn external_resource_fingerprint( &self, + uri: &str, 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() + digested: Option<[u8; 32]>, + ) -> Option<[u8; 32]> { + if let Some(fingerprint) = self.external_resource_fingerprints.borrow().get(uri) { + return Some(*fingerprint); + } + let fingerprint = digested.or_else(|| resolver.external_resource_fingerprint(uri))?; + self.external_resource_fingerprints + .borrow_mut() + .insert(uri.to_owned(), fingerprint); + Some(fingerprint) } fn resource_identity_for_reference( @@ -1376,22 +1398,9 @@ impl<'a> VerificationOperationBudgets<'a> { } 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::MissingExternal); - 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() @@ -1432,7 +1441,7 @@ fn compile_verification_operation_plan( view.node_identity(signature_node), )), ); - let key_materialization_resource = operation.budgets().external_resource_set_identity(resolver); + let key_materialization_resource = resolver.external_resource_set_identity(); let key_materialization = operation.add_node( OperationNodeKind::Key { index: 0 }, OperationStage::Resolve, @@ -2794,13 +2803,10 @@ fn process_signed_info_references( .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 origin = reference_origin_node(signature_node, ReferenceSet::SignedInfo, index); + let observed = operation + .budgets() + .resource_identity_for_reference(reference, index, resolver, view, origin); let mut result = operation.run_with_resource(node, &observed, || { let budgets = operation.budgets(); let execution = ReferenceExecutionContext { @@ -2817,7 +2823,7 @@ fn process_signed_info_references( signature_node, ReferenceSet::SignedInfo, index, - reference_origin_node(signature_node, ReferenceSet::SignedInfo, index), + origin, &execution, ) .or_else(|error| { @@ -2831,12 +2837,13 @@ fn process_signed_info_references( }) .map_err(SignatureVerificationPipelineError::from) })?; - bind_reference_evidence( + bind_reference_evidence(&mut result, reference, view, origin, &observed); + bind_external_evidence( &mut result, reference, - view, - reference_origin_node(signature_node, ReferenceSet::SignedInfo, index), &observed, + operation.budgets(), + resolver, ); let accepted = result.status == DsigStatus::Valid; operation.set_outcome( @@ -3589,7 +3596,7 @@ fn verify_signature_node<'a, C: VerificationCapture>( .validate_document_view(view) .map_err(map_verification_plan_error) })?; - let observed_key_resources = operation.budgets().external_resource_set_identity(resolver); + let observed_key_resources = resolver.external_resource_set_identity(); let retrieval_materialization = operation.run_with_resource( plan_nodes.key_materialization, &observed_key_resources, @@ -5658,6 +5665,7 @@ fn execute_manifest_reference( resolver.node_for_node_id(compiled.reference_node_id), &observed, ); + bind_external_evidence(&mut result, reference, &observed, budgets, resolver); if result.status == DsigStatus::Valid && reference .transforms diff --git a/src/xmldsig/verify/session.rs b/src/xmldsig/verify/session.rs index 7e646c1f..df898fe6 100644 --- a/src/xmldsig/verify/session.rs +++ b/src/xmldsig/verify/session.rs @@ -624,8 +624,14 @@ impl<'scope, 'inputs, W: Work, Q: Work> SelectedSignature<'_, '_, 'scope, 'input .borrow() .get(&node.id()) { - Some(OperationResourceIdentity::External { fingerprint, .. }) => { - ReferenceLocation::Detached(Some(*fingerprint)) + Some(OperationResourceIdentity::BorrowedExternal { uri, .. }) => { + ReferenceLocation::Detached( + self.session.budgets.external_resource_fingerprint( + uri, + &self.session.resolver, + None, + ), + ) } Some(OperationResourceIdentity::MissingExternal) => { ReferenceLocation::Detached(None) diff --git a/src/xmldsig/verify/session/detached.rs b/src/xmldsig/verify/session/detached.rs index d4e36bcb..59bafb61 100644 --- a/src/xmldsig/verify/session/detached.rs +++ b/src/xmldsig/verify/session/detached.rs @@ -11,8 +11,8 @@ use super::super::{ SignatureVerificationPipelineError, Transform, VerifiedSession, }; use crate::NodeIdentity; +use crate::xmldsig::base64_stream::Base64Stream; use alloc::{boxed::Box, vec::Vec}; -use base64::Engine as _; use structured_asn1::memory::{MemoryError, OwnedText, OwnedVec, Work}; /// How detached completion treats a failed Reference. @@ -106,129 +106,6 @@ struct StreamState<'p> { state: Box, } -/// RFC 2045 alphabet bytes are decoded in blocks of whole quanta; the same -/// `STANDARD` engine as the resident Base64 transform enforces padding. -const BASE64_BLOCK: usize = 4096; - -struct Base64Stream { - block: [u8; BASE64_BLOCK], - filled: usize, - /// Alphabet bytes accepted so far: the offset the resident decoder reports - /// within its filtered input. - offset: usize, - padded: bool, - failure: Option, -} - -impl Base64Stream { - fn new() -> Self { - Self { - block: [0; BASE64_BLOCK], - filled: 0, - offset: 0, - padded: false, - failure: None, - } - } - - /// Whether decoding still runs; a decoding error ends it. - fn active(&self) -> bool { - self.failure.is_none() - } - - /// Decode `chunk` and hand each decoded block to `sink`. A decoding error - /// is kept and stops this stream only; octet digests continue. - fn feed( - &mut self, - chunk: &[u8], - sink: &mut impl FnMut(&[u8]) -> Result<(), DsigError>, - ) -> Result<(), DsigError> { - if self.failure.is_some() { - return Ok(()); - } - for &byte in chunk { - // RFC 2045 ยง6.8: bytes outside the alphabet are ignored, exactly as - // the resident transform filters them before decoding. - if !(byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=')) { - continue; - } - if self.padded { - // Padding ends the encoded data; anything after it is the - // invalid byte the resident decoder reports. - self.failure = Some(base64::DecodeError::InvalidByte(self.offset, byte)); - return Ok(()); - } - self.block[self.filled] = byte; - self.filled += 1; - // Bounded by the policy's streamed byte limit, which fits usize. - self.offset += 1; - if self.filled == BASE64_BLOCK { - self.flush(sink)?; - if self.failure.is_some() { - return Ok(()); - } - } - } - Ok(()) - } - - fn flush( - &mut self, - sink: &mut impl FnMut(&[u8]) -> Result<(), DsigError>, - ) -> Result<(), DsigError> { - let mut decoded = [0_u8; BASE64_BLOCK / 4 * 3]; - let encoded = &self.block[..self.filled]; - // The block holds the last `filled` accepted bytes. - let base = self.offset - encoded.len(); - self.padded = encoded.last() == Some(&b'='); - self.filled = 0; - match base64::engine::general_purpose::STANDARD.decode_slice(encoded, &mut decoded) { - Ok(written) => sink(&decoded[..written]), - Err(error) => { - self.failure = Some(match error { - base64::DecodeSliceError::DecodeError(base64::DecodeError::InvalidByte( - index, - byte, - )) => base64::DecodeError::InvalidByte(base + index, byte), - base64::DecodeSliceError::DecodeError( - base64::DecodeError::InvalidLastSymbol { - offset, - symbol, - symbol_value, - }, - ) => base64::DecodeError::InvalidLastSymbol { - offset: base + offset, - symbol, - symbol_value, - }, - base64::DecodeSliceError::DecodeError(base64::DecodeError::InvalidLength( - length, - )) => base64::DecodeError::InvalidLength(base + length), - base64::DecodeSliceError::DecodeError(error) => error, - // The output block holds every quantum of a full input - // block, so its size cannot be the failure. - base64::DecodeSliceError::OutputSliceTooSmall => { - base64::DecodeError::InvalidLength(encoded.len()) - } - }); - Ok(()) - } - } - } - - /// Decode the final partial block; an incomplete quantum is the padding - /// error the resident `STANDARD` decoder reports for the whole input. - fn finish( - mut self, - sink: &mut impl FnMut(&[u8]) -> Result<(), DsigError>, - ) -> Result, DsigError> { - if self.failure.is_none() && self.filled > 0 { - self.flush(sink)?; - } - Ok(self.failure) - } -} - /// A detached payload being streamed into one session. Feed every chunk once /// with [`Self::update`], then [`Self::finish`]. A refusal is sticky: the /// payload yields no result and its References stay unavailable. diff --git a/src/xmldsig/verify/tests.rs b/src/xmldsig/verify/tests.rs index 97aeb496..0a236302 100644 --- a/src/xmldsig/verify/tests.rs +++ b/src/xmldsig/verify/tests.rs @@ -5184,14 +5184,19 @@ fn missing_external_identity_does_not_retain_uri_cache_copies() { document.with_view(|view| { let resolver = UriReferenceResolver::new(view.document()); let _ = budgets.resource_identity_for_reference(&reference, 0, &resolver, view, None); - assert!(budgets.external_resource_identities.borrow().is_empty()); + assert_eq!( + budgets.external_resource_fingerprint(&uri, &resolver, None), + None + ); + assert!(budgets.external_resource_fingerprints.borrow().is_empty()); }); } #[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. + // so one content fingerprint safely identifies every repeated URI, and + // planning an identity does not read the bytes at all. let document = XmlDocument::parse("").expect("fixture must parse"); let resources = BTreeMap::from([("urn:repeated".to_owned(), vec![b'x'; 8 * 1_024])]); let reference = make_reference( @@ -5213,7 +5218,17 @@ fn operation_memoizes_repeated_external_resource_identity() { 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); + assert!(budgets.external_resource_fingerprints.borrow().is_empty()); + + let fingerprint = budgets + .external_resource_fingerprint("urn:repeated", &resolver, None) + .unwrap(); + assert_eq!( + budgets.external_resource_fingerprint("urn:repeated", &resolver, Some([0; 32])), + Some(fingerprint), + "the first fingerprint of a URI stands for the operation" + ); + assert_eq!(budgets.external_resource_fingerprints.borrow().len(), 1); }); } diff --git a/tests/base64_transform_integration.rs b/tests/base64_transform_integration.rs index b0196542..20fa753d 100644 --- a/tests/base64_transform_integration.rs +++ b/tests/base64_transform_integration.rs @@ -7,6 +7,7 @@ mod cryptographic; use base64::Engine; use xml_sec::c14n::{C14nAlgorithm, C14nMode}; +use xml_sec::policy::{PolicyViolation, SigningPolicy}; use xml_sec::xmldsig::{ BASE64_TRANSFORM_URI, DefaultKeyResolver, DigestAlgorithm, DsigStatus, FailureReason, ReferenceBuilder, RsaSigningKey, SignContext, SignatureAlgorithm, SignatureBuilder, @@ -333,3 +334,63 @@ fn decoded_xml_rejects_here_from_the_signature_document() { "error must state the normative cross-document here() requirement: {message}" ); } + +fn sign_with_base64_limits(xml: &str, input: usize, output: usize) -> Result { + let (key, key_info) = signing_material(); + let mut policy = SigningPolicy::default(); + policy.resources.max_base64_transform_input_bytes = input; + policy.resources.max_base64_transform_output_bytes = output; + SignContext::new(&key) + .policy(policy) + .key_info_writer(&key_info) + .sign_with_builder(xml, &base64_signature_builder()) +} + +#[test] +fn signing_charges_a_final_base64_transform_once() { + // A payload exactly at both limits signs: dependency analysis must not + // spend the same allowance before the digest pass reads the payload. + let encoded = "QUJD".repeat(64); + let xml = format!(r#"{encoded}"#); + let signed = sign_with_base64_limits(&xml, encoded.len(), encoded.len() / 4 * 3) + .expect("a payload within both Base64 limits must sign"); + assert!(signed.contains("DigestValue")); +} + +#[test] +fn signing_refuses_a_final_base64_transform_one_byte_over_each_limit() { + let encoded = "QUJD".repeat(64); + let xml = format!(r#"{encoded}"#); + let decoded = encoded.len() / 4 * 3; + for (input, output, resource) in [ + (encoded.len() - 1, decoded, "Base64 transform input bytes"), + (encoded.len(), decoded - 1, "Base64 transform output bytes"), + ] { + let error = sign_with_base64_limits(&xml, input, output) + .expect_err("a payload over a Base64 limit must be refused"); + let SigningError::Policy(PolicyViolation::ResourceLimit { + resource: refused, .. + }) = error + else { + panic!("expected a Base64 resource refusal: {error:?}"); + }; + assert_eq!(refused, resource); + } +} + +#[test] +fn malformed_final_base64_is_refused_across_a_decoding_block() { + // The invalid quantum lies past the first streamed block, so a decoder + // that validates only the head of the payload would accept it. + let padded = format!("{}QQ==QUJD", "QUJD".repeat(4096)); + let xml = format!(r#"{padded}"#); + let (key, key_info) = signing_material(); + let error = SignContext::new(&key) + .key_info_writer(&key_info) + .sign_with_builder(&xml, &base64_signature_builder()) + .expect_err("data after padding must fail closed"); + assert!(matches!( + error, + SigningError::Digest(SigningDigestError::Transform(TransformError::Base64(_))) + )); +} diff --git a/tests/verification_evidence.rs b/tests/verification_evidence.rs index 4f462617..f336fe28 100644 --- a/tests/verification_evidence.rs +++ b/tests/verification_evidence.rs @@ -490,6 +490,94 @@ fn external_evidence_identifies_resolved_bytes_not_the_lexical_uri() { assert_eq!(reference.target_identity, None); } +/// Sign `signed` as `urn:payload` with `transforms`, then verify against `verified`. +fn external_reference_fingerprint( + signed: &[u8], + verified: &[u8], + digest: DigestAlgorithm, + transforms: &[xml_sec::xmldsig::Transform], +) -> (DsigStatus, Option<[u8; 32]>) { + use xml_sec::policy::{SigningPolicy, VerificationPolicy}; + use xml_sec::xmldsig::UriTypeSet; + let secret = vec![0x31; 32]; + let signing_key = HmacSigningKey::new(secret.clone()).unwrap(); + let key = HmacVerificationKey::new(secret).unwrap(); + let mut item = ReferenceBuilder::new(digest).uri("urn:payload"); + for transform in transforms { + item = item.transform(transform.clone()); + } + let mut signing_policy = SigningPolicy::default(); + signing_policy.uris.references = UriTypeSet::ALL; + let builder = SignatureBuilder::new( + C14nAlgorithm::new(C14nMode::Exclusive1_0, false), + SignatureAlgorithm::HmacSha256, + ) + .add_reference(item); + let resources = BTreeMap::from([("urn:payload".to_owned(), signed.to_vec())]); + let xml = SignContext::new(&signing_key) + .policy(signing_policy) + .external_resources(&resources) + .sign_with_builder("", &builder) + .unwrap(); + let document = XmlDocument::parse(xml).unwrap(); + let mut policy = VerificationPolicy::default(); + policy.uris.references = UriTypeSet::ALL; + let resources = BTreeMap::from([("urn:payload".to_owned(), verified.to_vec())]); + let request = VerificationRequest { + external_resources: Some(&resources), + ..VerificationRequest::default() + }; + let evidence = VerifyContext::new() + .policy(policy) + .key(&key) + .verify_request(&document, &request) + .unwrap(); + let reference = &evidence.signatures()[0] + .result() + .as_ref() + .unwrap() + .signed_info_references[0]; + (reference.status, reference.external_resource_fingerprint) +} + +#[test] +fn external_fingerprint_is_of_the_verified_bytes_never_the_signed_digest() { + // A failed Reference carries a DigestValue of other bytes, so that value + // must not stand in for the fingerprint of the bytes actually supplied. + use sha2::{Digest, Sha256}; + let (status, fingerprint) = + external_reference_fingerprint(b"signed", b"tampered", DigestAlgorithm::Sha256, &[]); + assert!(matches!(status, DsigStatus::Invalid(_))); + assert_eq!(fingerprint, Some(Sha256::digest(b"tampered").into())); +} + +#[test] +fn external_fingerprint_is_sha256_whatever_the_reference_digests() { + // The digest of another algorithm, or of transformed output, is not the + // SHA-256 identity of the resolved bytes. + use sha2::{Digest, Sha256}; + use xml_sec::xmldsig::Transform; + let expected: [u8; 32] = Sha256::digest(b"QUJD").into(); + for (digest, transforms) in [ + (DigestAlgorithm::Sha384, Vec::new()), + (DigestAlgorithm::Sha256, vec![Transform::Base64Decode]), + ] { + let (status, fingerprint) = + external_reference_fingerprint(b"QUJD", b"QUJD", digest, &transforms); + assert_eq!(status, DsigStatus::Valid); + assert_eq!(fingerprint, Some(expected)); + } +} + +#[test] +fn external_fingerprint_of_a_valid_sha256_reference_is_its_digest() { + use sha2::{Digest, Sha256}; + let (status, fingerprint) = + external_reference_fingerprint(b"bytes", b"bytes", DigestAlgorithm::Sha256, &[]); + assert_eq!(status, DsigStatus::Valid); + assert_eq!(fingerprint, Some(Sha256::digest(b"bytes").into())); +} + #[test] fn mathematical_embedded_key_success_is_not_authorized_coverage() { // XMLDSig core validation and application trust are distinct. The exact