diff --git a/.gitattributes b/.gitattributes index c08178c5..f52693ee 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ tests/fixtures/xmlenc/aleksey-xmlenc-01/*.tmpl -text whitespace=-trailing-space,-space-before-tab tests/fixtures/xmlenc/01-phaos-xmlenc-3/** -text whitespace=-trailing-space,-space-before-tab +tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/** -text whitespace=-blank-at-eof diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 246bd5ed..935e2d1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,11 +5,15 @@ on: pull_request: branches: [main] +permissions: + contents: read + env: CARGO_TERM_COLOR: always RUSTFLAGS: -Dwarnings - XMLSEC1_VERSION: 1.3.12 - XMLSEC1_SHA256: 24045199af12d93fe5fdbbbf7e386e823e4842071e9432e2b90ac108b889a923 + XMLSEC1_PREFIX: ${{ github.workspace }}/.tools/xmlsec1-1.3.13-5fdd47dc3575 + XMLSEC1_BIN: ${{ github.workspace }}/.tools/xmlsec1-1.3.13-5fdd47dc3575/bin/xmlsec1 + LD_LIBRARY_PATH: ${{ github.workspace }}/.tools/xmlsec1-1.3.13-5fdd47dc3575/lib jobs: build-matrix: @@ -21,6 +25,8 @@ jobs: rust: [stable, "1.92.0"] steps: - uses: actions/checkout@v7 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@stable with: toolchain: ${{ matrix.rust }} @@ -43,6 +49,8 @@ jobs: rust: [stable, "1.92.0"] steps: - uses: actions/checkout@v7 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@stable with: toolchain: ${{ matrix.rust }} @@ -51,17 +59,9 @@ jobs: run: sudo apt-get update - name: Build pinned xmlsec1 for XMLDSig interop tests run: | - sudo apt-get install --yes build-essential libltdl-dev libssl-dev libxml2-dev pkg-config - curl --fail --location --retry 3 --output xmlsec1.tar.gz "https://github.com/lsh123/xmlsec/releases/download/${XMLSEC1_VERSION}/xmlsec1-${XMLSEC1_VERSION}.tar.gz" - echo "${XMLSEC1_SHA256} xmlsec1.tar.gz" | sha256sum --check --strict - tar --extract --file xmlsec1.tar.gz - pushd "xmlsec1-${XMLSEC1_VERSION}" - ./configure --disable-static --with-openssl - make --jobs "$(nproc)" - sudo make install - popd - sudo ldconfig - xmlsec1 --version + sudo apt-get install --yes autoconf automake build-essential libltdl-dev libssl-dev libtool libxml2-dev pkg-config + scripts/install-xmlsec1.sh + "$XMLSEC1_BIN" --version - uses: Swatinem/rust-cache@v2 - run: cargo nextest run --all-features - run: cargo test --doc --all-features @@ -77,6 +77,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@stable with: components: clippy @@ -87,7 +89,24 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@stable with: components: rustfmt - run: cargo fmt --all -- --check + - run: cargo fmt --manifest-path fuzz/Cargo.toml -- --check + + fuzz-smoke: + timeout-minutes: 20 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@nightly + # cargo-fuzz 0.13.1's published lockfile pins rustix 0.36.5, which no + # longer compiles on current nightly. Keep the tool version pinned while + # allowing compatible patch-level transitive dependencies. + - run: cargo +nightly install cargo-fuzz --version 0.13.1 + - run: cargo +nightly fuzz run xmldsig_verify -- -runs=256 -max_len=65536 diff --git a/.gitignore b/.gitignore index 500b0060..26bbf6f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ /target +/.tools +/fuzz/artifacts +/fuzz/corpus/*/* +!/fuzz/corpus/xmldsig_verify/signature.xml +/fuzz/target Cargo.lock *.swp *.swo diff --git a/Cargo.toml b/Cargo.toml index a9a9ca60..7cfe8722 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,23 +28,27 @@ sha2 = { version = "0.11", features = ["oid"], optional = true } p256 = { version = "0.14", features = ["ecdsa"], optional = true } p384 = { version = "0.14", features = ["ecdsa"], optional = true } p521 = { version = "0.14", features = ["ecdsa"], optional = true } +dsa = { version = "0.7", optional = true } +hmac = { version = "0.13", optional = true } signature = { version = "3", optional = true } subtle = { version = "2", optional = true } getrandom = { version = "0.4", features = ["sys_rng"], optional = true } sxd-document-no-unsafe = { version = "0.4.1", default-features = false, features = ["no-unsafe"], optional = true } sxd-xpath-no-unsafe = { version = "0.5.1", default-features = false, features = ["no-unsafe"], optional = true } -aes = { version = "0.9.1", optional = true } +aes = { version = "0.9.2", optional = true } aes-gcm = { version = "0.11.0", optional = true } aes-kw = { version = "0.3.1", optional = true } cbc = { version = "0.2.1", optional = true } # X.509 certificates x509-parser = { version = "0.18", features = ["verify"], optional = true } +x509-cert = { version = "0.3", default-features = false, optional = true } +x520-stringprep = { version = "1", features = ["alloc"], optional = true } der = { version = "0.8", optional = true } crypto-bigint = { version = "0.7", optional = true } # Base64 encoding/decoding -base64 = "0.22" +base64 = "0.23" # Error handling thiserror = "2" @@ -52,14 +56,16 @@ thiserror = "2" [dev-dependencies] rcgen = "0.14.6" rand_chacha = "0.10" -time = "0.3.53" +time = "0.3.55" [features] default = ["xmldsig", "c14n"] xmldsig = [ # XML Digital Signatures (sign + verify) "dep:der", "dep:crypto-bigint", + "dep:dsa", "dep:getrandom", + "dep:hmac", "dep:p256", "dep:p384", "dep:p521", @@ -71,6 +77,8 @@ xmldsig = [ # XML Digital Signatures (sign + verify) "dep:sxd-document-no-unsafe", "dep:sxd-xpath-no-unsafe", "dep:x509-parser", + "dep:x509-cert", + "dep:x520-stringprep", ] xmlenc = [ # XML Encryption (encrypt + decrypt) "dep:aes", diff --git a/README.md b/README.md index dd14c3cf..8872d589 100644 --- a/README.md +++ b/README.md @@ -43,16 +43,18 @@ Currently implemented (core paths): - Built-in verification-key resolution from embedded X.509/DER/`KeyValue` sources and configured `KeyName`, X.509 subject, issuer/serial, SKI, or digest selectors - RSA PKCS#1 v1.5 verification helpers for SHA-1 / SHA-256 / SHA-384 / SHA-512 - ECDSA verification helpers for P-256/SHA-256 and P-384/SHA-384 +- Legacy DSA-SHA1 and HMAC-SHA1 verification, including truncated HMAC output - RSA PKCS#1 v1.5 and ECDSA P-256/P-384 signing from PKCS#8 private keys - Opt-in X.509 certificate-chain validation with explicit trust anchors, validity checks, CA constraints, and CRLs +- Caller-supplied, bounded external references and X.509 `RetrievalMethod` resolution without implicit I/O - XMLEnc AES-128/256-CBC and AES-128/256-GCM encryption/decryption with direct keys, RSA-OAEP key transport, AES-128/256-KW, multiple recipients, and Element/Content document replacement Still in progress: -- XMLDSig DSA, HMAC, and RSA-PSS signature algorithms +- XMLDSig DSA-SHA256, broader HMAC verification/signing, and RSA-PSS algorithms - Complete XMLDSig and XMLEnc conformance-suite classification -- Production hardening, fuzzing, benchmarks, and API stabilization +- Expanded fuzz coverage, benchmarks, production hardening, and API stabilization ## XMLDSig Usage @@ -100,7 +102,7 @@ Current MSRV: Rust 1.92. | [Canonical XML 1.0](https://www.w3.org/TR/xml-c14n/) | Implemented; full-document and document-subset vectors | | [Canonical XML 1.1](https://www.w3.org/TR/xml-c14n11/) | Implemented; `xml:id` and `xml:base` subset rules | | [Exclusive C14N](https://www.w3.org/TR/xml-exc-c14n/) | Implemented; `InclusiveNamespaces PrefixList` support | -| [XMLDSig](https://www.w3.org/TR/xmldsig-core1/) | Core sign/verify pipelines implemented; additional algorithms and conformance coverage in progress | +| [XMLDSig](https://www.w3.org/TR/xmldsig-core1/) | Core sign/verify pipelines and the complete Merlin corpus implemented; additional algorithms and conformance suites in progress | | [XMLEnc](https://www.w3.org/TR/xmlenc-core1/) | Core AES-CBC/GCM encrypt/decrypt with RSA-OAEP and AES-KW implemented; broader conformance coverage in progress | ## License diff --git a/docs/xmldsig.md b/docs/xmldsig.md index 20fb0703..30dd56e5 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -1,9 +1,11 @@ # XML Digital Signatures The `xmldsig` feature provides signing and verification pipelines for same-document XML -signatures. It supports inclusive and exclusive canonicalization, enveloped signatures, +signatures and detached references whose payloads the caller supplies. It supports inclusive and +exclusive canonicalization, enveloped signatures, Base64, XPath 1.0, and XPath Filter 2.0 transforms, RSA PKCS#1 v1.5, ECDSA P-256/P-384, -embedded X.509 certificates, and configured key resolution. +DSA-SHA1 and HMAC-SHA1 verification, embedded X.509 certificates, and configured key +resolution. ## Examples @@ -22,9 +24,13 @@ interoperating with legacy libxmlsec1 `here()` behavior can explicitly select ## Verification Policy -For production verification, configure `KeyResolverConfig` with explicit trust anchors when -certificate-chain validation is required. Embedded certificates provide key material; they do -not become trusted merely because they appear in ``. +For production verification, configure `KeyResolverConfig::lookup_certs` with untrusted +certificates that selector-only `X509Data` may address or use as path intermediates, and configure +`KeyResolverConfig::trusted_certs` only with explicit trust anchors. With chain validation +enabled, a selected lookup certificate may chain through other lookup certificates but must end at +a trusted anchor. A trusted certificate selected directly remains an anchor, while embedded +certificates provide key material and do not become trusted merely because they appear in +``. `VerifyResult::status` reports core validation: `Valid` means the cryptographic signature and every `` reference succeeded. `Invalid(reason)` means core validation completed but @@ -45,9 +51,31 @@ inconsistent `KeyInfo` metadata are processing errors rather than validity statu `Invalid(reason)` and an API error as a rejected document; never continue an authentication flow after either outcome. +External references are disabled by default. Callers must both allow their URI class with +`UriTypeSet` and provide every payload through `VerifyContext::external_resources`; verification +never performs network or filesystem I/O. Individual resources are limited to 8 MiB and the +complete map to 32 MiB. External key retrieval has an independent policy boundary: callers must +also opt in with `VerifyContext::allowed_retrieval_method_uri_types`. Allowing external signed +payloads never implicitly allows external key material. `RetrievalMethod` currently accepts +untransformed external `rawX509Certificate` data, untransformed direct same-document `X509Data`, +and the Merlin same-document `X509Data` XPath selection. Relative external `Reference` and +`RetrievalMethod` URIs are resolved against the owning element's effective `xml:base` using RFC +3986 before lookup, so resource-map keys must use that resolved URI. Other retrieval transform +chains fail closed instead of being ignored. + +Internal DTD declarations are disabled by default and require +`VerifyContext::allow_internal_dtd(true)`. The policy applies consistently to the signed document +and caller-supplied detached XML parsed by node-set transforms. Direct transform callers can set +the same policy with `TransformOptions::allow_internal_dtd(true)`. External entity resolution +remains disabled. XSLT is intentionally not executed because transforms operate on +attacker-controlled documents; an authenticated Manifest reference using unsupported XSLT is +reported as an invalid per-reference result without changing core `SignedInfo` validity. + ## Current Scope Implemented algorithms include RSA PKCS#1 v1.5 with SHA-1/SHA-256/SHA-384/SHA-512 for verification, SHA-256/SHA-384/SHA-512 for signing, and ECDSA P-256/SHA-256 and P-384/SHA-384. -DSA, HMAC signatures, RSA-PSS, and unauthenticated external reference loading are not currently -supported. +DSA-SHA1 and HMAC-SHA1 (including XMLDSig's byte-aligned 80-160-bit truncation range) are +verify-only legacy algorithms. +DSA-SHA256, broader HMAC verification/signing, RSA-PSS, and implicit external resource loading are +not currently supported. diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 00000000..763e43d6 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "xml-sec-fuzz" +version = "0.0.0" +publish = false +edition = "2024" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4.13" +xml-sec = { path = "..", features = ["xmldsig"] } + +[[bin]] +name = "xmldsig_verify" +path = "fuzz_targets/xmldsig_verify.rs" +test = false +doc = false +bench = false + +[workspace] +members = ["."] diff --git a/fuzz/corpus/xmldsig_verify/signature.xml b/fuzz/corpus/xmldsig_verify/signature.xml new file mode 100644 index 00000000..797a6034 --- /dev/null +++ b/fuzz/corpus/xmldsig_verify/signature.xml @@ -0,0 +1 @@ + diff --git a/fuzz/fuzz_targets/xmldsig_verify.rs b/fuzz/fuzz_targets/xmldsig_verify.rs new file mode 100644 index 00000000..076de9c8 --- /dev/null +++ b/fuzz/fuzz_targets/xmldsig_verify.rs @@ -0,0 +1,34 @@ +#![no_main] + +use std::sync::OnceLock; + +use libfuzzer_sys::fuzz_target; +use xml_sec::xmldsig::{DefaultKeyResolver, KeyResolverConfig, UriTypeSet, VerifyContext}; + +const TRUSTED_CERTIFICATE: &[u8] = + include_bytes!("../../tests/fixtures/xmldsig/phaos-xmldsig-three/certs/rsa-cert.der"); + +fn resolver() -> &'static DefaultKeyResolver { + static RESOLVER: OnceLock = OnceLock::new(); + RESOLVER.get_or_init(|| { + DefaultKeyResolver::new(KeyResolverConfig { + lookup_certs: vec![TRUSTED_CERTIFICATE.to_vec()], + ..KeyResolverConfig::default() + }) + }) +} + +fuzz_target!(|data: &[u8]| { + let Ok(xml) = std::str::from_utf8(data) else { + return; + }; + + // Match the upstream 1.3.13 verification harness: exercise parsing, + // transforms, digesting, signature verification, and X.509 lookup while + // keeping every reference and key retrieval strictly in-document. + let _ = VerifyContext::new() + .key_resolver(resolver()) + .allowed_uri_types(UriTypeSet::SAME_DOCUMENT) + .allowed_retrieval_method_uri_types(UriTypeSet::SAME_DOCUMENT) + .verify(xml); +}); diff --git a/scripts/import-donor-fixtures.sh b/scripts/import-donor-fixtures.sh index d62f639b..d591b2d9 100755 --- a/scripts/import-donor-fixtures.sh +++ b/scripts/import-donor-fixtures.sh @@ -35,10 +35,43 @@ replace_target() { return 1 } +normalize_imported_snapshot() { + local relative_path="$1" + local staging="$2" + local donor + + if [[ "$relative_path" == "xmldsig/merlin-xmldsig-twenty-three" ]]; then + # The donor README contains unresolved placeholders and is not executable + # fixture data. Keep the imported corpus curated rather than publishing + # upstream prose as project documentation. + rm -f "$staging/Readme.txt" + + # xmlsec 1.3.13's historical "-40" filenames contain an 80-bit HMAC, + # matching XMLDSig 1.1's security floor. Normalize only the local names; + # file contents remain byte-for-byte donor data. + for extension in tmpl xml; do + donor="$staging/signature-enveloping-hmac-sha1-40.$extension" + if [[ ! -f "$donor" ]]; then + printf 'donor snapshot no longer provides %s; update normalize_imported_snapshot\n' \ + "${donor##*/}" >&2 + return 1 + fi + if ! mv "$donor" "$staging/signature-enveloping-hmac-sha1-80.$extension"; then + printf 'failed to normalize donor fixture: %s\n' "${donor##*/}" >&2 + return 1 + fi + done + fi +} + fixture_paths=("$@") if (( ${#fixture_paths[@]} == 0 )); then fixture_paths=( "xmldsig/aleksey-xmldsig-01/enveloping-rsa-x509chain.xml" + "xmldsig/aleksey-xmldsig-01/enveloped-x509-digest-sha256.xml" + "xmldsig/merlin-xmldsig-twenty-three" + "xmldsig/external-data/xml-stylesheet-2005" + "xmldsig/external-data/xml-stylesheet-2005.b64" "xmlenc/aleksey-xmlenc-01/enc-aes128cbc-keyname.tmpl" "xmlenc/aleksey-xmlenc-01/enc-aes128gcm-keyname.tmpl" "xmlenc/aleksey-xmlenc-01/enc-aes256cbc-keyname.tmpl" @@ -97,6 +130,10 @@ for relative_path in "${fixture_paths[@]}"; do rm -rf "$staging" exit 1 fi + if ! normalize_imported_snapshot "$relative_path" "$staging"; then + rm -rf "$staging" + exit 1 + fi replace_target "$staging" "$target" else target_parent="$(dirname "$target")" diff --git a/scripts/install-xmlsec1.sh b/scripts/install-xmlsec1.sh new file mode 100755 index 00000000..6e02fa13 --- /dev/null +++ b/scripts/install-xmlsec1.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly XMLSEC1_VERSION="1.3.13" +readonly XMLSEC1_COMMIT="5fdd47dc35753438bdc38b6e96c1a3805c67a483" +readonly XMLSEC1_REPOSITORY="https://github.com/lsh123/xmlsec.git" + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +prefix="${XMLSEC1_PREFIX:-$repo_root/.tools/xmlsec1-${XMLSEC1_VERSION}-${XMLSEC1_COMMIT:0:12}}" +marker="$prefix/.xmlsec-source-commit" + +if [[ "$prefix" != /* ]]; then + printf 'XMLSEC1_PREFIX must be an absolute path: %s\n' "$prefix" >&2 + exit 1 +fi + +if [[ -x "$prefix/bin/xmlsec1" && -f "$marker" ]] \ + && [[ "$(<"$marker")" == "$XMLSEC1_COMMIT" ]]; then + printf 'xmlsec1 %s is already installed at %s\n' "$XMLSEC1_VERSION" "$prefix" + exit 0 +fi + +work_dir="$(mktemp -d "${TMPDIR:-/tmp}/xmlsec1-${XMLSEC1_VERSION}.XXXXXX")" +previous_install="$work_dir/previous-install" +previous_install_staged=false +promoted_install=false + +cleanup() { + local status=$? + local remove_work_dir=true + trap - EXIT + + # Keep replacement transactional through the version smoke test. The + # staged move is not a commit if installation or validation fails. + if (( status != 0 )); then + if [[ "$promoted_install" == true ]]; then + rm -rf "$prefix" + fi + if [[ "$previous_install_staged" == true ]]; then + if ! mv "$previous_install" "$prefix"; then + printf 'failed to restore previous xmlsec1 installation at %s; backup remains at %s\n' \ + "$prefix" "$previous_install" >&2 + status=1 + remove_work_dir=false + fi + fi + fi + if [[ "$remove_work_dir" == true ]]; then + rm -rf "$work_dir" + fi + exit "$status" +} +trap cleanup EXIT +source_dir="$work_dir/xmlsec" +build_dir="$work_dir/build" +stage_dir="$work_dir/stage" + +git init "$source_dir" +git -C "$source_dir" remote add origin "$XMLSEC1_REPOSITORY" +git -C "$source_dir" fetch --depth=1 origin "$XMLSEC1_COMMIT" +fetched_commit="$(git -C "$source_dir" rev-parse FETCH_HEAD)" +if [[ "$fetched_commit" != "$XMLSEC1_COMMIT" ]]; then + printf 'xmlsec1 source revision mismatch: expected %s, got %s\n' \ + "$XMLSEC1_COMMIT" "$fetched_commit" >&2 + exit 1 +fi +git -C "$source_dir" checkout --detach "$XMLSEC1_COMMIT" + +mkdir -p "$build_dir" "$stage_dir" +OBJ_DIR="$build_dir" "$source_dir/autogen.sh" \ + --prefix="$prefix" \ + --disable-static \ + --without-gnutls \ + --without-nss \ + --with-openssl + +if command -v nproc >/dev/null 2>&1; then + build_jobs="$(nproc)" +else + build_jobs="$(sysctl -n hw.ncpu)" +fi +make --directory "$build_dir" --jobs "$build_jobs" +make --directory "$build_dir" install DESTDIR="$stage_dir" + +staged_prefix="$stage_dir$prefix" +mkdir -p "$(dirname "$prefix")" +if [[ -e "$prefix" ]]; then + mv "$prefix" "$previous_install" + previous_install_staged=true +fi +mv "$staged_prefix" "$prefix" +promoted_install=true + +if [[ "$(uname -s)" == "Darwin" ]]; then + version_output="$( + DYLD_LIBRARY_PATH="$prefix/lib${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" \ + "$prefix/bin/xmlsec1" --version + )" +else + version_output="$( + LD_LIBRARY_PATH="$prefix/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ + "$prefix/bin/xmlsec1" --version + )" +fi +version_program="" +version_number="" +read -r version_program version_number _ <<< "$version_output" || true +if [[ "$version_program" != "xmlsec1" || "$version_number" != "$XMLSEC1_VERSION" ]]; then + printf 'xmlsec1 version mismatch: expected xmlsec1 %s, got %s\n' \ + "$XMLSEC1_VERSION" "${version_output:-}" >&2 + exit 1 +fi +printf '%s\n' "$version_output" +printf '%s\n' "$XMLSEC1_COMMIT" > "$marker" + +rm -rf "$previous_install" +previous_install_staged=false + +printf 'installed xmlsec1 %s snapshot %s at %s\n' \ + "$XMLSEC1_VERSION" "${XMLSEC1_COMMIT:0:12}" "$prefix" diff --git a/src/c14n/mod.rs b/src/c14n/mod.rs index 633ddd4c..871e014b 100644 --- a/src/c14n/mod.rs +++ b/src/c14n/mod.rs @@ -28,7 +28,7 @@ pub(crate) mod ns_exclusive; pub(crate) mod ns_inclusive; pub(crate) mod prefix; pub(crate) mod serialize; -mod xml_base; +pub(crate) mod xml_base; use std::collections::HashSet; @@ -240,6 +240,30 @@ pub fn canonicalize( ) } +#[cfg(any(feature = "xmldsig", test))] +/// Canonicalize through the closure visibility API while refusing to append +/// beyond `max_output_bytes`; the serializer stops before the excess write. +pub(crate) fn canonicalize_bounded( + doc: &Document, + node_set: Option<&dyn Fn(Node) -> bool>, + algo: &C14nAlgorithm, + max_output_bytes: usize, + output: &mut Vec, +) -> Result<(), C14nError> { + let visibility = node_set.map(|predicate| ClosureVisibility { predicate }); + canonicalize_with_visibility_and_position_bounded( + doc, + visibility + .as_ref() + .map(|visibility| visibility as &dyn NodeVisibility), + algo, + None, + max_output_bytes, + output, + )?; + Ok(()) +} + pub(crate) fn canonicalize_with_visibility( doc: &Document, visibility: Option<&dyn NodeVisibility>, diff --git a/src/c14n/xml_base.rs b/src/c14n/xml_base.rs index 7c49a031..c0c2206f 100644 --- a/src/c14n/xml_base.rs +++ b/src/c14n/xml_base.rs @@ -118,45 +118,63 @@ pub(crate) fn resolve_uri(base: &str, reference: &str) -> String { return base.to_string(); } - // Reference with scheme → use as-is (already absolute) + // A scheme-bearing reference supplies every target component, but RFC 3986 + // section 5.2.2 still requires dot-segment removal from its path. if has_scheme(reference) { - return reference.to_string(); + let (absolute, suffix) = split_path_suffix(reference); + let parts = parse_base(absolute).expect("has_scheme accepted the absolute reference"); + let path = remove_dot_segments_from_absolute_reference(parts.path); + let mut result = recompose(parts.scheme, parts.authority, &path); + result.push_str(suffix); + return result; + } + + // Query- and fragment-only references preserve the complete base path for + // both absolute and relative bases (RFC 3986 section 5.2.2). + if reference.starts_with('?') { + return format!("{}{reference}", strip_query_fragment(base)); + } + if reference.starts_with('#') { + return format!("{}{reference}", base.split('#').next().unwrap_or(base)); } // Parse base URI components let base_parts = match parse_base(base) { Some(parts) => parts, None => { - // Schemeless/relative base. Still perform path-merge and - // dot-segment removal so that a chain of relative xml:base - // values is correctly collapsed (e.g. "a/b/" + "c/" → "a/b/c/"). - if reference.starts_with("//") || reference.starts_with('/') { - return reference.to_string(); - } + // Schemeless bases include both ordinary relative paths and + // network-path references. Preserve the latter's authority while + // applying the same RFC 3986 path merge and normalization rules. let (ref_path, ref_suffix) = split_path_suffix(reference); - let base_path_only = strip_query_fragment(base); - let merged = merge_paths(base_path_only, ref_path); + if let Some((authority, path)) = parse_network_path(ref_path) { + let path = remove_dot_segments(path); + return format!("//{authority}{path}{ref_suffix}"); + } + let (base_path_with_authority, _) = split_path_suffix(base); + let network_base = parse_network_path(base_path_with_authority); + if ref_path.starts_with('/') { + let path = remove_dot_segments(ref_path); + return match network_base { + Some((authority, _)) => format!("//{authority}{path}{ref_suffix}"), + None => format!("{path}{ref_suffix}"), + }; + } + let (base_path, authority) = match network_base { + Some((authority, path)) => (path, Some(authority)), + None => (base_path_with_authority, None), + }; + let merged = merge_paths(base_path, ref_path, authority.is_some()); let cleaned = remove_dot_segments(&merged); - return format!("{cleaned}{ref_suffix}"); + return match authority { + Some(authority) => format!("//{authority}{cleaned}{ref_suffix}"), + None => format!("{cleaned}{ref_suffix}"), + }; } }; let scheme = base_parts.scheme; let authority = base_parts.authority; let base_path = base_parts.path; - // Reference starts with ? → query-only: keep base scheme+authority+path. - // Reference starts with # → fragment-only: keep base scheme+authority+path+query. - // Per RFC 3986 §5.2.2, these replace only the query/fragment components. - if reference.starts_with('?') || reference.starts_with('#') { - let base_no_qf = strip_query_fragment(base); - if reference.starts_with('?') { - return format!("{base_no_qf}{reference}"); - } - // Fragment-only: keep query too - let base_no_frag = base.split('#').next().unwrap_or(base); - return format!("{base_no_frag}{reference}"); - } - // Split reference into path and query/fragment suffix. We apply // remove_dot_segments only to the path portion, then reattach the // query/fragment to the result. @@ -191,7 +209,7 @@ pub(crate) fn resolve_uri(base: &str, reference: &str) -> String { // Relative path — merge with base path (strip query/fragment from // base_path first, since merge operates on the path component only). let clean_base_path = strip_query_fragment(base_path); - let merged = merge_paths(clean_base_path, ref_path); + let merged = merge_paths(clean_base_path, ref_path, authority.is_some()); let cleaned = remove_dot_segments(&merged); let mut result = recompose(scheme, authority, &cleaned); result.push_str(ref_suffix); @@ -262,6 +280,14 @@ fn parse_base(base: &str) -> Option> { }) } +/// Split a schemeless network-path reference into authority and path. +/// Query and fragment components must already have been removed. +fn parse_network_path(reference: &str) -> Option<(&str, &str)> { + let rest = reference.strip_prefix("//")?; + let authority_end = rest.find('/').unwrap_or(rest.len()); + Some((&rest[..authority_end], &rest[authority_end..])) +} + /// Recompose a URI from scheme, optional authority, and path per RFC 3986 §5.3. /// /// `authority = Some("")` → `scheme:///path` (empty authority, e.g. `file:///`). @@ -278,16 +304,14 @@ fn recompose(scheme: &str, authority: Option<&str>, path: &str) -> String { /// first character to preserve leading `?`/`#` semantics (those are handled /// separately as query-only / fragment-only references). fn split_path_suffix(reference: &str) -> (&str, &str) { - // Find the earliest '?' or '#' after position 0 - let mut split_at = reference.len(); - for ch in ['?', '#'] { - if let Some(pos) = reference[1..].find(ch) { - let abs_pos = pos + 1; - if abs_pos < split_at { - split_at = abs_pos; - } - } - } + // Character indices remain valid UTF-8 slice boundaries for untrusted XML + // attribute values. The first scalar is intentionally skipped because + // leading query/fragment references are handled before this helper. + let split_at = reference + .char_indices() + .skip(1) + .find_map(|(index, ch)| matches!(ch, '?' | '#').then_some(index)) + .unwrap_or(reference.len()); (&reference[..split_at], &reference[split_at..]) } @@ -301,8 +325,12 @@ fn strip_query_fragment(s: &str) -> &str { } /// Merge a relative reference with a base path per RFC 3986 §5.2.3. -fn merge_paths(base_path: &str, reference: &str) -> String { - if base_path.is_empty() { +/// +/// An authority with an empty path contributes the leading `/`; an empty +/// schemeless base does not. Keeping that distinction explicit prevents a +/// relative XML Base from changing the reference kind. +fn merge_paths(base_path: &str, reference: &str, base_has_authority: bool) -> String { + if base_has_authority && base_path.is_empty() { format!("/{reference}") } else { // Remove everything after the last segment of base path. @@ -323,7 +351,7 @@ mod merge_tests { /// Non-hierarchical base path (no '/') should return reference as-is. #[test] fn non_hierarchical_base_does_not_add_slash() { - assert_eq!(merge_paths("foo:bar", "baz"), "baz"); + assert_eq!(merge_paths("foo:bar", "baz", false), "baz"); } } @@ -332,6 +360,20 @@ mod merge_tests { /// For absolute paths (starting with `/`), `..` at the root is a no-op. /// For relative paths, unresolved leading `..` segments are preserved. fn remove_dot_segments(path: &str) -> String { + remove_dot_segments_with_unmatched_parents(path, true) +} + +/// Apply RFC 3986 section 5.2.4 to a reference that already supplied a scheme. +/// Such a reference is the final target, so unresolved leading parents are +/// discarded rather than retained for a later base-path merge. +fn remove_dot_segments_from_absolute_reference(path: &str) -> String { + remove_dot_segments_with_unmatched_parents(path, false) +} + +fn remove_dot_segments_with_unmatched_parents( + path: &str, + preserve_unmatched_parents: bool, +) -> String { let is_absolute = path.starts_with('/'); let mut segments: Vec<&str> = Vec::new(); @@ -345,15 +387,12 @@ fn remove_dot_segments(path: &str) -> String { // - For absolute paths, do not traverse above root (the // leading "" segment from the initial '/' is preserved). // - For relative paths, preserve unmatched ".." segments. - let can_pop = match segments.last() { - Some(&"") => false, // root segment of absolute path - Some(&"..") => false, // already an unmatched ".." - Some(_) => true, - None => false, - }; + let root_segments = usize::from(is_absolute); + let can_pop = + segments.len() > root_segments && !matches!(segments.last(), Some(&"..")); if can_pop { segments.pop(); - } else if !is_absolute { + } else if !is_absolute && preserve_unmatched_parents { segments.push(".."); } } @@ -387,6 +426,33 @@ mod tests { ); } + #[test] + fn resolve_absolute_reference_removes_dot_segments() { + // RFC 3986 applies dot-segment removal to an absolute reference too; + // its existing scheme only prevents inheritance from the base URI. + assert_eq!( + resolve_uri( + "https://base.example/ignored/", + "https://example.test/a/../data.bin?version=1#payload" + ), + "https://example.test/data.bin?version=1#payload" + ); + } + + #[test] + fn resolve_absolute_reference_consumes_interior_empty_segment() { + // RFC 3986 treats the empty segment introduced by the second slash as + // an ordinary path segment. The following parent segment removes it; + // only the leading empty segment represents the absolute-path root. + assert_eq!( + resolve_uri( + "https://base.example/ignored/", + "https://example.test/a//../b" + ), + "https://example.test/a/b" + ); + } + #[test] fn resolve_empty_reference() { assert_eq!(resolve_uri("http://a.com/b/c", ""), "http://a.com/b/c"); @@ -463,6 +529,60 @@ mod tests { assert_eq!(resolve_uri("a/b", "c"), "a/c"); } + #[test] + fn resolve_absolute_path_normalizes_against_schemeless_base() { + assert_eq!(resolve_uri("a/b", "/x/../data.bin"), "/data.bin"); + } + + #[test] + fn resolve_network_path_normalizes_against_schemeless_base() { + assert_eq!( + resolve_uri("a/b", "//cdn.example/x/../data.bin?version=1"), + "//cdn.example/data.bin?version=1" + ); + } + + #[test] + fn resolve_against_network_path_base_preserves_authority() { + // A network-path base has an authority even without a scheme. RFC 3986 + // resolution must not collapse it into an ordinary absolute path. + assert_eq!( + resolve_uri("//cdn.example/a/b/", "/x/../data.bin?version=1"), + "//cdn.example/data.bin?version=1" + ); + assert_eq!( + resolve_uri("//cdn.example/a/b/", "../data.bin"), + "//cdn.example/a/data.bin" + ); + assert_eq!( + resolve_uri("//cdn.example/a/b?old#fragment", "?new"), + "//cdn.example/a/b?new" + ); + assert_eq!( + resolve_uri("//cdn.example/a/b?old#fragment", "#new"), + "//cdn.example/a/b?old#new" + ); + assert_eq!( + resolve_uri("//cdn.example/a/b/", "//other.example/x/../data.bin"), + "//other.example/data.bin" + ); + } + + #[test] + fn resolve_pathless_schemeless_base_preserves_relative_reference() { + // A query-only relative base has no authority. RFC 3986 therefore + // preserves a relative reference instead of introducing a root slash. + assert_eq!(resolve_uri("?old", "data.bin"), "data.bin"); + } + + #[test] + fn resolve_query_and_fragment_against_schemeless_base() { + // RFC 3986 replaces only the query or fragment even when the effective + // XML Base is itself relative rather than scheme-bearing. + assert_eq!(resolve_uri("a/b?old#frag", "?new"), "a/b?new"); + assert_eq!(resolve_uri("a/b?old#frag", "#new"), "a/b?old#new"); + } + #[test] fn resolve_urn_reference() { // URN has a scheme, should be returned as-is @@ -472,6 +592,20 @@ mod tests { ); } + #[test] + fn resolve_rootless_absolute_uri_removes_leading_dot_segments() { + // Once a reference supplies its own scheme, RFC 3986 section 5.2.4 + // discards unresolved leading dot segments from the final target path. + assert_eq!( + resolve_uri("https://example.test/base", "urn:../payload?version=1"), + "urn:payload?version=1" + ); + assert_eq!( + resolve_uri("https://example.test/base", "urn:./payload"), + "urn:payload" + ); + } + #[test] fn resolve_parent_beyond_root() { // Going past root with .. should stop at root @@ -508,6 +642,16 @@ mod tests { ); } + #[test] + fn resolve_unicode_reference_with_query_uses_utf8_boundaries() { + // XML attributes are Unicode strings. URI component splitting must not + // index through the first multibyte scalar as if it were one byte. + assert_eq!( + resolve_uri("https://example.test/base/", "é?x"), + "https://example.test/base/é?x" + ); + } + #[test] fn resolve_reference_with_fragment() { // Reference contains fragment — must be preserved in output diff --git a/src/hard_limits.rs b/src/hard_limits.rs new file mode 100644 index 00000000..2d1879ce --- /dev/null +++ b/src/hard_limits.rs @@ -0,0 +1,20 @@ +//! Non-configurable implementation safety ceilings. +//! +//! These caps bound allocations even when a future compiled deployment policy +//! permits larger inputs. Deployment policy may only select stricter values. + +/// Maximum XML nodes allocated while parsing one verification or transform document. +pub(crate) const XML_DOCUMENT_NODE_CEILING: u32 = 100_000; + +/// Maximum canonicalized SignedInfo plus retained diagnostics for one signature. +pub(crate) const CANONICALIZED_SIGNATURE_DATA_BYTE_CEILING: usize = 32 * 1024 * 1024; + +pub(crate) const EXTERNAL_RESOURCE_BYTE_CEILING: usize = 8 * 1024 * 1024; +pub(crate) const EXTERNAL_RESOURCE_TOTAL_BYTE_CEILING: usize = 32 * 1024 * 1024; +pub(crate) const ENCRYPTION_CIPHER_VALUE_BASE64_BYTE_CEILING: usize = 16 * 1024 * 1024; +pub(crate) const ENCRYPTION_PLAINTEXT_BYTE_CEILING: usize = + (ENCRYPTION_CIPHER_VALUE_BASE64_BYTE_CEILING / 4 * 3) - 32; +pub(crate) const ENCRYPTION_DOCUMENT_BYTE_CEILING: usize = + ENCRYPTION_CIPHER_VALUE_BASE64_BYTE_CEILING; +pub(crate) const ENCRYPTION_RECIPIENT_CEILING: usize = 64; +pub(crate) const ENCRYPTION_METADATA_BYTE_CEILING: usize = 4 * 1024; diff --git a/src/lib.rs b/src/lib.rs index 61acd7cf..324c35b6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,6 +33,12 @@ pub mod c14n; pub mod error; #[cfg(any(feature = "xmldsig", feature = "xmlenc"))] +mod hard_limits; +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] +pub mod policy; +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] +pub mod provider; +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] mod xml; #[cfg(feature = "xmldsig")] diff --git a/src/policy.rs b/src/policy.rs new file mode 100644 index 00000000..c626388c --- /dev/null +++ b/src/policy.rs @@ -0,0 +1,376 @@ +//! Immutable security policy snapshots shared by XML Security operations. +//! +//! Policy contains trusted, reusable decisions. Caller-owned keys, selected +//! document targets, tenant identity, and external resource bytes remain in +//! operation request contexts and are deliberately not stored here. + +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] +use std::collections::HashSet; +#[cfg(feature = "xmldsig")] +use std::time::SystemTime; + +#[cfg(feature = "xmldsig")] +use crate::xmldsig::{DigestAlgorithm, SignatureAlgorithm, UriTypeSet, XPathHereSemantics}; +#[cfg(feature = "xmlenc")] +use crate::xmlenc::{ + DataEncryptionAlgorithm, KeyTransportAlgorithm, KeyWrapAlgorithm, OaepDigestAlgorithm, +}; + +/// A typed rejection produced by an operation policy. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum PolicyViolation { + /// An algorithm is outside the operation allowlist. + #[error("{operation} policy rejects algorithm {algorithm}")] + Algorithm { + /// Operation evaluating the algorithm. + operation: &'static str, + /// Stable algorithm URI or diagnostic name. + algorithm: String, + }, + /// An input exceeds a configured resource ceiling. + #[error("{resource} exceeds policy maximum {maximum}: got {actual}")] + ResourceLimit { + /// Resource whose consumption was rejected. + resource: &'static str, + /// Effective policy ceiling. + maximum: usize, + /// Observed consumption. + actual: usize, + }, + /// The selected key source or trust mode is disallowed. + #[error("key/trust policy rejected the operation: {reason}")] + KeyTrust { + /// Non-secret reason suitable for diagnostics. + reason: &'static str, + }, + /// XML parser behavior is disallowed. + #[error("XML input policy rejected the operation: {reason}")] + XmlInput { + /// Non-secret reason suitable for diagnostics. + reason: &'static str, + }, +} + +/// Resource ceilings shared by parsing, transforms, and cryptographic output. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResourcePolicy { + /// Maximum XML nodes in one parsed document. + pub max_xml_nodes: usize, + /// Maximum references in one signature or manifest. + pub max_references: usize, + /// Maximum transforms in one reference. + pub max_transforms_per_reference: usize, + /// Maximum canonical bytes retained across one signature operation. + pub max_canonicalized_bytes: usize, + /// Maximum decoded external resource bytes. + pub max_external_resource_bytes: usize, + /// Maximum aggregate external resource bytes. + pub max_external_resource_total_bytes: usize, + /// Maximum XMLEnc plaintext bytes. + pub max_encryption_plaintext_bytes: usize, + /// Maximum caller-owned XML bytes accepted by XMLEnc document operations. + pub max_encryption_document_bytes: usize, + /// Maximum independently wrapped recipients. + pub max_encryption_recipients: usize, + /// Maximum caller-controlled XMLEnc metadata bytes per field. + pub max_encryption_metadata_bytes: usize, +} + +impl Default for ResourcePolicy { + fn default() -> Self { + Self { + max_xml_nodes: crate::hard_limits::XML_DOCUMENT_NODE_CEILING as usize, + max_references: 64, + max_transforms_per_reference: 64, + max_canonicalized_bytes: crate::hard_limits::CANONICALIZED_SIGNATURE_DATA_BYTE_CEILING, + max_external_resource_bytes: crate::hard_limits::EXTERNAL_RESOURCE_BYTE_CEILING, + max_external_resource_total_bytes: + crate::hard_limits::EXTERNAL_RESOURCE_TOTAL_BYTE_CEILING, + max_encryption_plaintext_bytes: crate::hard_limits::ENCRYPTION_PLAINTEXT_BYTE_CEILING, + max_encryption_document_bytes: crate::hard_limits::ENCRYPTION_DOCUMENT_BYTE_CEILING, + max_encryption_recipients: crate::hard_limits::ENCRYPTION_RECIPIENT_CEILING, + max_encryption_metadata_bytes: crate::hard_limits::ENCRYPTION_METADATA_BYTE_CEILING, + } + } +} + +impl ResourcePolicy { + /// Validate policy values against non-configurable implementation ceilings. + pub fn validate(&self) -> Result<(), PolicyViolation> { + Self::within( + "XML nodes", + self.max_xml_nodes, + crate::hard_limits::XML_DOCUMENT_NODE_CEILING as usize, + )?; + Self::within( + "canonicalized bytes", + self.max_canonicalized_bytes, + crate::hard_limits::CANONICALIZED_SIGNATURE_DATA_BYTE_CEILING, + )?; + Self::within("signature references", self.max_references, 64)?; + Self::within( + "reference transforms", + self.max_transforms_per_reference, + 64, + )?; + Self::within( + "encryption document", + self.max_encryption_document_bytes, + crate::hard_limits::ENCRYPTION_DOCUMENT_BYTE_CEILING, + )?; + Self::within( + "external resource bytes", + self.max_external_resource_bytes, + crate::hard_limits::EXTERNAL_RESOURCE_BYTE_CEILING, + )?; + Self::within( + "aggregate external resource bytes", + self.max_external_resource_total_bytes, + crate::hard_limits::EXTERNAL_RESOURCE_TOTAL_BYTE_CEILING, + )?; + Self::within( + "encryption plaintext bytes", + self.max_encryption_plaintext_bytes, + crate::hard_limits::ENCRYPTION_PLAINTEXT_BYTE_CEILING, + )?; + Self::within( + "encryption recipients", + self.max_encryption_recipients, + crate::hard_limits::ENCRYPTION_RECIPIENT_CEILING, + )?; + Self::within( + "encryption metadata bytes", + self.max_encryption_metadata_bytes, + crate::hard_limits::ENCRYPTION_METADATA_BYTE_CEILING, + ) + } + + fn within( + resource: &'static str, + selected: usize, + ceiling: usize, + ) -> Result<(), PolicyViolation> { + if selected == 0 || selected > ceiling { + return Err(PolicyViolation::ResourceLimit { + resource, + maximum: ceiling, + actual: selected, + }); + } + Ok(()) + } +} + +/// XML parsing decisions shared by all operation policies. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct XmlInputPolicy { + /// Permit bounded internal DTD declarations. External resolution stays off. + pub allow_internal_dtd: bool, +} + +/// X.509 and key-resolution decisions for verification. +#[cfg(feature = "xmldsig")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeyTrustPolicy { + /// Require embedded or selected certificates to chain to a configured anchor. + pub verify_x509_chains: bool, + /// Maximum validated path depth. + pub max_x509_chain_depth: usize, + /// Maximum signature-valid candidate paths considered before validation. + pub max_x509_candidate_paths: usize, + /// Permit legacy RSA-SHA1 verification after key resolution. + pub allow_legacy_rsa_sha1: bool, + /// Authenticate and enforce embedded CRLs during path validation. + pub check_crls: bool, + /// Verification time override; `None` selects the system clock. + pub verification_time: Option, +} + +#[cfg(feature = "xmldsig")] +impl Default for KeyTrustPolicy { + fn default() -> Self { + Self { + verify_x509_chains: false, + max_x509_chain_depth: 9, + max_x509_candidate_paths: 64, + allow_legacy_rsa_sha1: false, + check_crls: false, + verification_time: None, + } + } +} + +#[cfg(feature = "xmldsig")] +impl KeyTrustPolicy { + fn validate(&self) -> Result<(), PolicyViolation> { + ResourcePolicy::within("X.509 chain depth", self.max_x509_chain_depth, 9)?; + ResourcePolicy::within("X.509 candidate paths", self.max_x509_candidate_paths, 64) + } +} + +/// Immutable policy snapshot for XMLDSig verification. +#[cfg(feature = "xmldsig")] +#[derive(Debug, Clone, Default)] +pub struct VerificationPolicy { + /// Allowed signature methods; `None` accepts every implemented method. + pub signature_algorithms: Option>, + /// Allowed reference digest methods; `None` accepts every implemented method. + pub digest_algorithms: Option>, + /// Key and certificate trust rules. + pub key_trust: KeyTrustPolicy, + /// Allowed Reference URI classes. + pub reference_uri_types: UriTypeSet, + /// Allowed RetrievalMethod URI classes. + pub retrieval_uri_types: UriTypeSet, + /// Allowed transform URIs; `None` accepts every implemented transform. + pub transforms: Option>, + /// Whether authenticated Manifest references are processed. + pub process_manifests: bool, + /// XML parser rules. + pub xml: XmlInputPolicy, + /// Node selected for the XPath `here()` extension function. + pub xpath_here_semantics: XPathHereSemantics, + /// Resource ceilings. + pub resources: ResourcePolicy, +} + +#[cfg(feature = "xmldsig")] +impl VerificationPolicy { + /// Validate the complete snapshot against implementation hard ceilings. + pub fn validate(&self) -> Result<(), PolicyViolation> { + self.resources.validate()?; + self.key_trust.validate() + } + + /// Enforce the signature algorithm after key resolution. + pub fn check_signature_algorithm( + &self, + algorithm: SignatureAlgorithm, + ) -> Result<(), PolicyViolation> { + if algorithm == SignatureAlgorithm::RsaSha1 && !self.key_trust.allow_legacy_rsa_sha1 { + return Err(PolicyViolation::Algorithm { + operation: "verification", + algorithm: algorithm.uri().to_string(), + }); + } + if self + .signature_algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(&algorithm)) + { + return Err(PolicyViolation::Algorithm { + operation: "verification", + algorithm: algorithm.uri().to_string(), + }); + } + Ok(()) + } +} + +/// Immutable policy snapshot for XMLDSig signing. +#[cfg(feature = "xmldsig")] +#[derive(Debug, Clone, Default)] +pub struct SigningPolicy { + /// Allowed signing methods; `None` uses the implemented secure defaults. + pub signature_algorithms: Option>, + /// Allowed reference digest methods; `None` uses the implemented secure defaults. + pub digest_algorithms: Option>, + /// Allowed transform URIs; `None` accepts every implemented transform. + pub transforms: Option>, + /// XML parser rules. + pub xml: XmlInputPolicy, + /// Node selected for the XPath `here()` extension function. + pub xpath_here_semantics: XPathHereSemantics, + /// Resource ceilings. + pub resources: ResourcePolicy, +} + +/// Immutable policy snapshot for XMLEnc encryption. +#[cfg(feature = "xmlenc")] +#[derive(Debug, Clone, Default)] +pub struct EncryptionPolicy { + /// Allowed content-encryption algorithms. + pub data_algorithms: Option>, + /// Allowed RSA key-transport algorithms. + pub key_transport_algorithms: Option>, + /// Allowed symmetric key-wrap algorithms. + pub key_wrap_algorithms: Option>, + /// Allowed OAEP digest algorithms. + pub oaep_digests: Option>, + /// XML parser rules. + pub xml: XmlInputPolicy, + /// Resource ceilings. + pub resources: ResourcePolicy, +} + +/// Immutable policy snapshot for XMLEnc decryption. +#[cfg(feature = "xmlenc")] +pub type DecryptionPolicy = EncryptionPolicy; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resource_policy_cannot_exceed_implementation_ceiling() { + let policy = ResourcePolicy { + max_xml_nodes: 100_001, + ..ResourcePolicy::default() + }; + assert!(matches!( + policy.validate(), + Err(PolicyViolation::ResourceLimit { + resource: "XML nodes", + maximum: 100_000, + actual: 100_001, + }) + )); + } + + #[test] + fn every_resource_policy_field_obeys_its_hard_ceiling() { + // Each public tuning knob is only a stricter operational limit; none + // may raise the implementation's allocation ceiling. + let mut policies = Vec::new(); + let mut external = ResourcePolicy::default(); + external.max_external_resource_bytes += 1; + policies.push(external); + let mut aggregate = ResourcePolicy::default(); + aggregate.max_external_resource_total_bytes += 1; + policies.push(aggregate); + let mut plaintext = ResourcePolicy::default(); + plaintext.max_encryption_plaintext_bytes += 1; + policies.push(plaintext); + let mut recipients = ResourcePolicy::default(); + recipients.max_encryption_recipients += 1; + policies.push(recipients); + let mut metadata = ResourcePolicy::default(); + metadata.max_encryption_metadata_bytes += 1; + policies.push(metadata); + + for policy in policies { + assert!(matches!( + policy.validate(), + Err(PolicyViolation::ResourceLimit { .. }) + )); + } + } + + #[cfg(feature = "xmldsig")] + #[test] + fn rsa_sha1_requires_legacy_verification_policy() { + let mut policy = VerificationPolicy::default(); + assert!( + policy + .check_signature_algorithm(SignatureAlgorithm::RsaSha1) + .is_err() + ); + policy.key_trust.allow_legacy_rsa_sha1 = true; + assert!( + policy + .check_signature_algorithm(SignatureAlgorithm::RsaSha1) + .is_ok() + ); + } +} diff --git a/src/provider.rs b/src/provider.rs new file mode 100644 index 00000000..9b118aea --- /dev/null +++ b/src/provider.rs @@ -0,0 +1,903 @@ +//! Provider-neutral cryptographic operations. +//! +//! XML parsing and protocol orchestration depend on this contract rather than +//! concrete cryptographic crates. Secret-bearing signing/decryption keys remain +//! opaque behind the operation-specific key traits exposed by `xmldsig` and +//! `xmlenc`; this provider owns stateless primitives and randomness. + +#[cfg(feature = "xmlenc")] +use getrandom::rand_core::TryCryptoRng; +use getrandom::{SysRng, rand_core::TryRng}; + +#[cfg(feature = "xmldsig")] +use crate::xmldsig::DigestAlgorithm; +#[cfg(feature = "xmlenc")] +use crate::xmlenc::RsaOaepParameters; +#[cfg(feature = "xmlenc")] +use crate::xmlenc::{DataEncryptionAlgorithm, KeyWrapAlgorithm}; + +/// A cryptographic operation advertised by a provider. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum ProviderOperation { + /// Message digest computation. + Digest, + /// Public-key signature generation. + Sign, + /// Public-key signature verification. + Verify, + /// Authenticated or padded symmetric encryption. + Encrypt, + /// Authenticated or padded symmetric decryption. + Decrypt, + /// Symmetric key wrapping. + KeyWrap, + /// Symmetric key unwrapping. + KeyUnwrap, + /// Public-key key transport. + KeyTransport, + /// Key agreement. + KeyAgreement, + /// Key derivation. + Kdf, + /// Cryptographically secure random bytes. + Random, +} + +/// Provider capability query, including optional algorithm granularity. +#[derive(Debug, Clone, Copy)] +pub struct CapabilityQuery<'a> { + /// Operation the caller intends to execute. + pub operation: ProviderOperation, + /// Standard algorithm URI when one exists. + pub algorithm: Option<&'a str>, +} + +/// Structured invalid-input reasons returned by cryptographic providers. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum ProviderInputError { + /// A primitive rejected a key or IV after its public preconditions were checked. + #[error("failed to initialize {0}")] + PrimitiveInitialization(&'static str), + /// AES-CBC input does not contain an IV followed by complete blocks. + #[error("invalid AES-CBC framing")] + AesCbcFraming, + /// AES-CBC block decryption failed. + #[error("invalid AES-CBC ciphertext")] + AesCbcCiphertext, + /// AES-CBC produced no plaintext block. + #[error("empty AES-CBC plaintext")] + AesCbcPlaintext, + /// XMLEnc CBC padding length is outside the valid block range. + #[error("invalid XMLEnc CBC padding length {pad_len}")] + XmlEncCbcPadding { + /// Last plaintext octet interpreted as the padding length. + pad_len: u8, + }, + /// AES-GCM input does not contain a nonce and authentication tag. + #[error("invalid AES-GCM framing")] + AesGcmFraming, + /// AES key-wrap input or output framing is invalid. + #[error("invalid AES key-wrap framing")] + AesKeyWrapFraming, + /// The legacy RSA-OAEP URI requires MGF1-SHA1. + #[error("legacy RSA-OAEP requires MGF1-SHA1")] + LegacyRsaOaepMgf, +} + +/// Failure returned by a cryptographic provider. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum ProviderError { + /// The selected provider does not implement the operation/parameters. + #[error("provider does not support {operation:?} with algorithm {algorithm:?}")] + Unsupported { + /// Requested operation. + operation: ProviderOperation, + /// Requested algorithm URI or name. + algorithm: Option, + }, + /// A key has the wrong size for the selected algorithm. + #[error("invalid key size: expected {expected} bytes, got {actual}")] + InvalidKeySize { + /// Required key length. + expected: usize, + /// Supplied key length. + actual: usize, + }, + /// Input framing, padding, or primitive initialization is invalid. + #[error("invalid cryptographic input: {0}")] + InvalidInput(ProviderInputError), + /// Authenticated decryption or key-wrap integrity validation failed. + #[error("cryptographic authentication failed")] + AuthenticationFailed, + /// Operating-system randomness was unavailable. + #[error("operating-system random number generation failed: {0}")] + Random(String), +} + +/// Stateless provider operations used by the XML Security pipelines. +pub trait CryptoProvider: Send + Sync { + /// Stable provider name for diagnostics and capability reporting. + fn name(&self) -> &'static str; + + /// Return whether this build supports the requested operation and parameters. + fn supports(&self, query: CapabilityQuery<'_>) -> bool; + + /// Fill caller-owned output with cryptographically secure random bytes. + fn fill_random(&self, output: &mut [u8]) -> Result<(), ProviderError>; + + /// Compute a message digest. + #[cfg(feature = "xmldsig")] + fn digest(&self, algorithm: DigestAlgorithm, data: &[u8]) -> Result, ProviderError>; + + /// Sign bytes with an opaque key handle. + #[cfg(feature = "xmldsig")] + fn sign( + &self, + key: &dyn crate::xmldsig::SigningKey, + algorithm: crate::xmldsig::SignatureAlgorithm, + data: &[u8], + ) -> Result, crate::xmldsig::SigningKeyError>; + + /// Verify bytes with an opaque key handle. + #[cfg(feature = "xmldsig")] + fn verify( + &self, + key: &dyn crate::xmldsig::VerifyingKey, + algorithm: crate::xmldsig::SignatureAlgorithm, + data: &[u8], + signature: &[u8], + ) -> Result; + + /// Encrypt XMLEnc content bytes, including standard framing. + #[cfg(feature = "xmlenc")] + fn encrypt_data( + &self, + algorithm: DataEncryptionAlgorithm, + key: &[u8], + plaintext: &[u8], + ) -> Result, ProviderError>; + + /// Decrypt XMLEnc content bytes, including framing validation. + #[cfg(feature = "xmlenc")] + fn decrypt_data( + &self, + algorithm: DataEncryptionAlgorithm, + key: &[u8], + ciphertext: &[u8], + ) -> Result, ProviderError>; + + /// Wrap a content key with RFC 3394 AES Key Wrap. + #[cfg(feature = "xmlenc")] + fn wrap_key( + &self, + algorithm: KeyWrapAlgorithm, + kek: &[u8], + key: &[u8], + ) -> Result, ProviderError>; + + /// Unwrap a content key with RFC 3394 AES Key Wrap. + #[cfg(feature = "xmlenc")] + fn unwrap_key( + &self, + algorithm: KeyWrapAlgorithm, + kek: &[u8], + wrapped: &[u8], + ) -> Result, ProviderError>; + + /// Wrap key bytes using an opaque RSA public-key operation. + #[cfg(feature = "xmlenc")] + fn transport_key( + &self, + key: &rsa::RsaPublicKey, + parameters: &RsaOaepParameters, + plaintext: &[u8], + ) -> Result, ProviderError>; + + /// Recover key bytes using an opaque RSA private-key operation. + #[cfg(feature = "xmlenc")] + fn recover_key( + &self, + key: &rsa::RsaPrivateKey, + parameters: &RsaOaepParameters, + ciphertext: &[u8], + ) -> Result, ProviderError>; +} + +/// Pure-Rust provider backed by RustCrypto crates. +#[derive(Debug, Clone, Copy, Default)] +pub struct RustCryptoProvider; + +/// Process-wide immutable default provider. It contains no mutable state or keys. +pub static RUST_CRYPTO_PROVIDER: RustCryptoProvider = RustCryptoProvider; + +/// Borrow the pure-Rust default provider. +#[must_use] +pub fn default_provider() -> &'static dyn CryptoProvider { + &RUST_CRYPTO_PROVIDER +} + +/// Adapter used when a RustCrypto primitive requires a fallible RNG object. +#[cfg(feature = "xmlenc")] +pub(crate) struct ProviderRng<'a>(pub(crate) &'a dyn CryptoProvider); + +#[cfg(feature = "xmlenc")] +impl TryRng for ProviderRng<'_> { + type Error = ProviderError; + + fn try_next_u32(&mut self) -> Result { + let mut bytes = [0_u8; 4]; + self.try_fill_bytes(&mut bytes)?; + Ok(u32::from_le_bytes(bytes)) + } + + fn try_next_u64(&mut self) -> Result { + let mut bytes = [0_u8; 8]; + self.try_fill_bytes(&mut bytes)?; + Ok(u64::from_le_bytes(bytes)) + } + + fn try_fill_bytes(&mut self, output: &mut [u8]) -> Result<(), Self::Error> { + self.0.fill_random(output) + } +} + +#[cfg(feature = "xmlenc")] +impl TryCryptoRng for ProviderRng<'_> {} + +impl CryptoProvider for RustCryptoProvider { + fn name(&self) -> &'static str { + "rustcrypto" + } + + fn supports(&self, query: CapabilityQuery<'_>) -> bool { + match query.operation { + ProviderOperation::Digest => query.algorithm.is_none_or(|algorithm| { + matches!( + algorithm, + "http://www.w3.org/2000/09/xmldsig#sha1" + | "http://www.w3.org/2001/04/xmlenc#sha256" + | "http://www.w3.org/2001/04/xmldsig-more#sha384" + | "http://www.w3.org/2001/04/xmlenc#sha512" + ) + }), + ProviderOperation::Sign => query.algorithm.is_none_or(is_supported_signing_uri), + ProviderOperation::Verify => query.algorithm.is_none_or(is_supported_signature_uri), + ProviderOperation::Encrypt | ProviderOperation::Decrypt => { + query.algorithm.is_none_or(is_supported_data_encryption_uri) + } + ProviderOperation::KeyWrap | ProviderOperation::KeyUnwrap => { + query.algorithm.is_none_or(is_supported_key_wrap_uri) + } + ProviderOperation::KeyTransport => query.algorithm.is_none_or(|algorithm| { + matches!( + algorithm, + "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p" + | "http://www.w3.org/2009/xmlenc11#rsa-oaep" + ) + }), + ProviderOperation::Random => true, + ProviderOperation::KeyAgreement | ProviderOperation::Kdf => false, + } + } + + fn fill_random(&self, output: &mut [u8]) -> Result<(), ProviderError> { + SysRng + .try_fill_bytes(output) + .map_err(|error| ProviderError::Random(error.to_string())) + } + + #[cfg(feature = "xmldsig")] + fn digest(&self, algorithm: DigestAlgorithm, data: &[u8]) -> Result, ProviderError> { + use sha1::Sha1; + use sha2::{Digest, Sha256, Sha384, Sha512}; + Ok(match algorithm { + DigestAlgorithm::Sha1 => Sha1::digest(data).to_vec(), + DigestAlgorithm::Sha256 => Sha256::digest(data).to_vec(), + DigestAlgorithm::Sha384 => Sha384::digest(data).to_vec(), + DigestAlgorithm::Sha512 => Sha512::digest(data).to_vec(), + }) + } + + #[cfg(feature = "xmldsig")] + fn sign( + &self, + key: &dyn crate::xmldsig::SigningKey, + algorithm: crate::xmldsig::SignatureAlgorithm, + data: &[u8], + ) -> Result, crate::xmldsig::SigningKeyError> { + self.require(ProviderOperation::Sign, Some(algorithm.uri()))?; + key.sign(algorithm, data) + } + + #[cfg(feature = "xmldsig")] + fn verify( + &self, + key: &dyn crate::xmldsig::VerifyingKey, + algorithm: crate::xmldsig::SignatureAlgorithm, + data: &[u8], + signature: &[u8], + ) -> Result { + self.require(ProviderOperation::Verify, Some(algorithm.uri()))?; + key.verify(algorithm, data, signature) + } + + #[cfg(feature = "xmlenc")] + fn encrypt_data( + &self, + algorithm: DataEncryptionAlgorithm, + key: &[u8], + plaintext: &[u8], + ) -> Result, ProviderError> { + rustcrypto::encrypt_data(self, algorithm, key, plaintext) + } + + #[cfg(feature = "xmlenc")] + fn decrypt_data( + &self, + algorithm: DataEncryptionAlgorithm, + key: &[u8], + ciphertext: &[u8], + ) -> Result, ProviderError> { + rustcrypto::decrypt_data(algorithm, key, ciphertext) + } + + #[cfg(feature = "xmlenc")] + fn wrap_key( + &self, + algorithm: KeyWrapAlgorithm, + kek: &[u8], + key: &[u8], + ) -> Result, ProviderError> { + rustcrypto::wrap_key(algorithm, kek, key) + } + + #[cfg(feature = "xmlenc")] + fn unwrap_key( + &self, + algorithm: KeyWrapAlgorithm, + kek: &[u8], + wrapped: &[u8], + ) -> Result, ProviderError> { + rustcrypto::unwrap_key(algorithm, kek, wrapped) + } + + #[cfg(feature = "xmlenc")] + fn transport_key( + &self, + key: &rsa::RsaPublicKey, + parameters: &RsaOaepParameters, + plaintext: &[u8], + ) -> Result, ProviderError> { + self.require( + ProviderOperation::KeyTransport, + Some(parameters.algorithm.uri()), + )?; + rustcrypto::transport_key(self, key, parameters, plaintext) + } + + #[cfg(feature = "xmlenc")] + fn recover_key( + &self, + key: &rsa::RsaPrivateKey, + parameters: &RsaOaepParameters, + ciphertext: &[u8], + ) -> Result, ProviderError> { + self.require( + ProviderOperation::KeyTransport, + Some(parameters.algorithm.uri()), + )?; + rustcrypto::recover_key(self, key, parameters, ciphertext) + } +} + +impl RustCryptoProvider { + fn require( + &self, + operation: ProviderOperation, + algorithm: Option<&str>, + ) -> Result<(), ProviderError> { + if self.supports(CapabilityQuery { + operation, + algorithm, + }) { + Ok(()) + } else { + Err(ProviderError::Unsupported { + operation, + algorithm: algorithm.map(str::to_owned), + }) + } + } +} + +fn is_supported_signature_uri(algorithm: &str) -> bool { + matches!( + algorithm, + "http://www.w3.org/2000/09/xmldsig#dsa-sha1" + | "http://www.w3.org/2000/09/xmldsig#hmac-sha1" + | "http://www.w3.org/2000/09/xmldsig#rsa-sha1" + | "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" + | "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384" + | "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512" + | "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256" + | "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384" + ) +} + +fn is_supported_signing_uri(algorithm: &str) -> bool { + matches!( + algorithm, + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" + | "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384" + | "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512" + | "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256" + | "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384" + ) +} + +fn is_supported_data_encryption_uri(algorithm: &str) -> bool { + matches!( + algorithm, + "http://www.w3.org/2001/04/xmlenc#aes128-cbc" + | "http://www.w3.org/2001/04/xmlenc#aes256-cbc" + | "http://www.w3.org/2009/xmlenc11#aes128-gcm" + | "http://www.w3.org/2009/xmlenc11#aes256-gcm" + ) +} + +fn is_supported_key_wrap_uri(algorithm: &str) -> bool { + matches!( + algorithm, + "http://www.w3.org/2001/04/xmlenc#kw-aes128" | "http://www.w3.org/2001/04/xmlenc#kw-aes256" + ) +} + +#[cfg(feature = "xmlenc")] +mod rustcrypto { + use aes::{ + Aes128, Aes256, + cipher::{BlockModeDecrypt, BlockModeEncrypt, KeyIvInit, block_padding::NoPadding}, + }; + use aes_gcm::{ + Aes128Gcm, Aes256Gcm, Nonce, + aead::{AeadInOut, KeyInit}, + }; + use aes_kw::{KwAes128, KwAes256}; + use cbc::{Decryptor, Encryptor}; + use rsa::{Oaep, traits::PaddingScheme}; + use sha1::Sha1; + use sha2::{Sha256, Sha384, Sha512}; + + use super::{CryptoProvider, ProviderError, ProviderInputError}; + use crate::xmlenc::{ + DataEncryptionAlgorithm, KeyTransportAlgorithm, KeyWrapAlgorithm, OaepDigestAlgorithm, + RsaOaepParameters, + }; + + pub(super) fn encrypt_data( + provider: &dyn CryptoProvider, + algorithm: DataEncryptionAlgorithm, + key: &[u8], + plaintext: &[u8], + ) -> Result, ProviderError> { + check_key(algorithm.key_len(), key)?; + match algorithm { + DataEncryptionAlgorithm::Aes128Cbc => encrypt_cbc::(provider, key, plaintext), + DataEncryptionAlgorithm::Aes256Cbc => encrypt_cbc::(provider, key, plaintext), + DataEncryptionAlgorithm::Aes128Gcm => { + encrypt_gcm::(provider, key, plaintext) + } + DataEncryptionAlgorithm::Aes256Gcm => { + encrypt_gcm::(provider, key, plaintext) + } + } + } + + pub(super) fn decrypt_data( + algorithm: DataEncryptionAlgorithm, + key: &[u8], + ciphertext: &[u8], + ) -> Result, ProviderError> { + check_key(algorithm.key_len(), key)?; + match algorithm { + DataEncryptionAlgorithm::Aes128Cbc => decrypt_cbc::(key, ciphertext), + DataEncryptionAlgorithm::Aes256Cbc => decrypt_cbc::(key, ciphertext), + DataEncryptionAlgorithm::Aes128Gcm => decrypt_gcm::(key, ciphertext), + DataEncryptionAlgorithm::Aes256Gcm => decrypt_gcm::(key, ciphertext), + } + } + + fn check_key(expected: usize, key: &[u8]) -> Result<(), ProviderError> { + if key.len() == expected { + Ok(()) + } else { + Err(ProviderError::InvalidKeySize { + expected, + actual: key.len(), + }) + } + } + + fn encrypt_cbc( + provider: &dyn CryptoProvider, + key: &[u8], + plaintext: &[u8], + ) -> Result, ProviderError> + where + C: aes::cipher::BlockCipherEncrypt + aes::cipher::KeyInit, + { + let mut iv = [0_u8; 16]; + provider.fill_random(&mut iv)?; + let pad_len = 16 - (plaintext.len() % 16); + let mut padded = vec![0_u8; plaintext.len() + pad_len]; + padded[..plaintext.len()].copy_from_slice(plaintext); + if pad_len > 1 { + let last = padded.len() - 1; + provider.fill_random(&mut padded[plaintext.len()..last])?; + } + *padded.last_mut().expect("padding is non-empty") = pad_len as u8; + Encryptor::::new_from_slices(key, &iv) + .map_err(|_| { + ProviderError::InvalidInput(ProviderInputError::PrimitiveInitialization("AES-CBC")) + })? + .encrypt_padded::(&mut padded, plaintext.len() + pad_len) + .map_err(|_| { + ProviderError::InvalidInput(ProviderInputError::PrimitiveInitialization( + "AES-CBC padding", + )) + })?; + let mut output = Vec::with_capacity(16 + padded.len()); + output.extend_from_slice(&iv); + output.extend_from_slice(&padded); + Ok(output) + } + + fn decrypt_cbc(key: &[u8], ciphertext: &[u8]) -> Result, ProviderError> + where + C: aes::cipher::BlockCipherDecrypt + aes::cipher::KeyInit, + { + if ciphertext.len() < 32 || !(ciphertext.len() - 16).is_multiple_of(16) { + return Err(ProviderError::InvalidInput( + ProviderInputError::AesCbcFraming, + )); + } + let (iv, body) = ciphertext.split_at(16); + let mut plaintext = body.to_vec(); + Decryptor::::new_from_slices(key, iv) + .map_err(|_| { + ProviderError::InvalidInput(ProviderInputError::PrimitiveInitialization("AES-CBC")) + })? + .decrypt_padded::(&mut plaintext) + .map_err(|_| ProviderError::InvalidInput(ProviderInputError::AesCbcCiphertext))?; + let pad_len = *plaintext.last().ok_or(ProviderError::InvalidInput( + ProviderInputError::AesCbcPlaintext, + ))?; + let padding_bytes = usize::from(pad_len); + if !(1..=16).contains(&padding_bytes) || padding_bytes > plaintext.len() { + return Err(ProviderError::InvalidInput( + ProviderInputError::XmlEncCbcPadding { pad_len }, + )); + } + plaintext.truncate(plaintext.len() - padding_bytes); + Ok(plaintext) + } + + fn encrypt_gcm( + provider: &dyn CryptoProvider, + key: &[u8], + plaintext: &[u8], + ) -> Result, ProviderError> + where + C: AeadInOut + KeyInit, + { + let mut nonce = [0_u8; 12]; + provider.fill_random(&mut nonce)?; + let cipher = C::new_from_slice(key).map_err(|_| { + ProviderError::InvalidInput(ProviderInputError::PrimitiveInitialization("AES-GCM")) + })?; + let mut output = plaintext.to_vec(); + let nonce = Nonce::try_from(nonce.as_slice()).map_err(|_| { + ProviderError::InvalidInput(ProviderInputError::PrimitiveInitialization( + "AES-GCM nonce", + )) + })?; + cipher + .encrypt_in_place(&nonce, &[], &mut output) + .map_err(|_| ProviderError::AuthenticationFailed)?; + let mut framed = Vec::with_capacity(12 + output.len()); + framed.extend_from_slice(&nonce); + framed.extend_from_slice(&output); + Ok(framed) + } + + fn decrypt_gcm(key: &[u8], ciphertext: &[u8]) -> Result, ProviderError> + where + C: AeadInOut + KeyInit, + { + if ciphertext.len() < 28 { + return Err(ProviderError::InvalidInput( + ProviderInputError::AesGcmFraming, + )); + } + let (nonce, body) = ciphertext.split_at(12); + let cipher = C::new_from_slice(key).map_err(|_| { + ProviderError::InvalidInput(ProviderInputError::PrimitiveInitialization("AES-GCM")) + })?; + let mut plaintext = body.to_vec(); + let nonce = Nonce::try_from(nonce).map_err(|_| { + ProviderError::InvalidInput(ProviderInputError::PrimitiveInitialization( + "AES-GCM nonce", + )) + })?; + cipher + .decrypt_in_place(&nonce, &[], &mut plaintext) + .map_err(|_| ProviderError::AuthenticationFailed)?; + Ok(plaintext) + } + + pub(super) fn wrap_key( + algorithm: KeyWrapAlgorithm, + kek: &[u8], + key: &[u8], + ) -> Result, ProviderError> { + check_key(algorithm.key_len(), kek)?; + let mut output = vec![0_u8; key.len() + 8]; + match algorithm { + KeyWrapAlgorithm::AesKw128 => KwAes128::new_from_slice(kek) + .map_err(|_| ProviderError::InvalidKeySize { + expected: 16, + actual: kek.len(), + })? + .wrap_key(key, &mut output), + KeyWrapAlgorithm::AesKw256 => KwAes256::new_from_slice(kek) + .map_err(|_| ProviderError::InvalidKeySize { + expected: 32, + actual: kek.len(), + })? + .wrap_key(key, &mut output), + } + .map_err(|_| ProviderError::InvalidInput(ProviderInputError::AesKeyWrapFraming))?; + Ok(output) + } + + pub(super) fn unwrap_key( + algorithm: KeyWrapAlgorithm, + kek: &[u8], + wrapped: &[u8], + ) -> Result, ProviderError> { + check_key(algorithm.key_len(), kek)?; + if wrapped.len() < 16 || !wrapped.len().is_multiple_of(8) { + return Err(ProviderError::InvalidInput( + ProviderInputError::AesKeyWrapFraming, + )); + } + let mut output = vec![0_u8; wrapped.len() - 8]; + let key = match algorithm { + KeyWrapAlgorithm::AesKw128 => KwAes128::new_from_slice(kek) + .map_err(|_| ProviderError::InvalidKeySize { + expected: 16, + actual: kek.len(), + })? + .unwrap_key(wrapped, &mut output), + KeyWrapAlgorithm::AesKw256 => KwAes256::new_from_slice(kek) + .map_err(|_| ProviderError::InvalidKeySize { + expected: 32, + actual: kek.len(), + })? + .unwrap_key(wrapped, &mut output), + } + .map_err(|_| ProviderError::AuthenticationFailed)?; + Ok(key.to_vec()) + } + + pub(super) fn transport_key( + provider: &dyn CryptoProvider, + key: &rsa::RsaPublicKey, + parameters: &RsaOaepParameters, + plaintext: &[u8], + ) -> Result, ProviderError> { + if parameters.algorithm == KeyTransportAlgorithm::RsaOaepMgf1p + && parameters.mgf_digest != OaepDigestAlgorithm::Sha1 + { + return Err(ProviderError::InvalidInput( + ProviderInputError::LegacyRsaOaepMgf, + )); + } + let mut rng = super::ProviderRng(provider); + macro_rules! encrypt_with { + ($digest:ty, $mgf:ty) => { + Oaep::<$digest, $mgf>::new_with_mgf_hash_and_label(parameters.label.clone()) + .encrypt(&mut rng, key, plaintext) + }; + } + let result = match (parameters.digest, parameters.mgf_digest) { + (OaepDigestAlgorithm::Sha1, OaepDigestAlgorithm::Sha1) => { + encrypt_with!(Sha1, Sha1) + } + (OaepDigestAlgorithm::Sha1, OaepDigestAlgorithm::Sha256) => { + encrypt_with!(Sha1, Sha256) + } + (OaepDigestAlgorithm::Sha1, OaepDigestAlgorithm::Sha384) => { + encrypt_with!(Sha1, Sha384) + } + (OaepDigestAlgorithm::Sha1, OaepDigestAlgorithm::Sha512) => { + encrypt_with!(Sha1, Sha512) + } + (OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha1) => { + encrypt_with!(Sha256, Sha1) + } + (OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha256) => { + encrypt_with!(Sha256, Sha256) + } + (OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha384) => { + encrypt_with!(Sha256, Sha384) + } + (OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha512) => { + encrypt_with!(Sha256, Sha512) + } + (OaepDigestAlgorithm::Sha384, OaepDigestAlgorithm::Sha1) => { + encrypt_with!(Sha384, Sha1) + } + (OaepDigestAlgorithm::Sha384, OaepDigestAlgorithm::Sha256) => { + encrypt_with!(Sha384, Sha256) + } + (OaepDigestAlgorithm::Sha384, OaepDigestAlgorithm::Sha384) => { + encrypt_with!(Sha384, Sha384) + } + (OaepDigestAlgorithm::Sha384, OaepDigestAlgorithm::Sha512) => { + encrypt_with!(Sha384, Sha512) + } + (OaepDigestAlgorithm::Sha512, OaepDigestAlgorithm::Sha1) => { + encrypt_with!(Sha512, Sha1) + } + (OaepDigestAlgorithm::Sha512, OaepDigestAlgorithm::Sha256) => { + encrypt_with!(Sha512, Sha256) + } + (OaepDigestAlgorithm::Sha512, OaepDigestAlgorithm::Sha384) => { + encrypt_with!(Sha512, Sha384) + } + (OaepDigestAlgorithm::Sha512, OaepDigestAlgorithm::Sha512) => { + encrypt_with!(Sha512, Sha512) + } + }; + result.map_err(map_rsa_error) + } + + pub(super) fn recover_key( + provider: &dyn CryptoProvider, + key: &rsa::RsaPrivateKey, + parameters: &RsaOaepParameters, + ciphertext: &[u8], + ) -> Result, ProviderError> { + if parameters.algorithm == KeyTransportAlgorithm::RsaOaepMgf1p + && parameters.mgf_digest != OaepDigestAlgorithm::Sha1 + { + return Err(ProviderError::InvalidInput( + ProviderInputError::LegacyRsaOaepMgf, + )); + } + let mut rng = super::ProviderRng(provider); + macro_rules! decrypt_with { + ($digest:ty, $mgf:ty) => { + Oaep::<$digest, $mgf>::new_with_mgf_hash_and_label(parameters.label.clone()) + .decrypt(Some(&mut rng), key, ciphertext) + }; + } + let result = match (parameters.digest, parameters.mgf_digest) { + (OaepDigestAlgorithm::Sha1, OaepDigestAlgorithm::Sha1) => { + decrypt_with!(Sha1, Sha1) + } + (OaepDigestAlgorithm::Sha1, OaepDigestAlgorithm::Sha256) => { + decrypt_with!(Sha1, Sha256) + } + (OaepDigestAlgorithm::Sha1, OaepDigestAlgorithm::Sha384) => { + decrypt_with!(Sha1, Sha384) + } + (OaepDigestAlgorithm::Sha1, OaepDigestAlgorithm::Sha512) => { + decrypt_with!(Sha1, Sha512) + } + (OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha1) => { + decrypt_with!(Sha256, Sha1) + } + (OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha256) => { + decrypt_with!(Sha256, Sha256) + } + (OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha384) => { + decrypt_with!(Sha256, Sha384) + } + (OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha512) => { + decrypt_with!(Sha256, Sha512) + } + (OaepDigestAlgorithm::Sha384, OaepDigestAlgorithm::Sha1) => { + decrypt_with!(Sha384, Sha1) + } + (OaepDigestAlgorithm::Sha384, OaepDigestAlgorithm::Sha256) => { + decrypt_with!(Sha384, Sha256) + } + (OaepDigestAlgorithm::Sha384, OaepDigestAlgorithm::Sha384) => { + decrypt_with!(Sha384, Sha384) + } + (OaepDigestAlgorithm::Sha384, OaepDigestAlgorithm::Sha512) => { + decrypt_with!(Sha384, Sha512) + } + (OaepDigestAlgorithm::Sha512, OaepDigestAlgorithm::Sha1) => { + decrypt_with!(Sha512, Sha1) + } + (OaepDigestAlgorithm::Sha512, OaepDigestAlgorithm::Sha256) => { + decrypt_with!(Sha512, Sha256) + } + (OaepDigestAlgorithm::Sha512, OaepDigestAlgorithm::Sha384) => { + decrypt_with!(Sha512, Sha384) + } + (OaepDigestAlgorithm::Sha512, OaepDigestAlgorithm::Sha512) => { + decrypt_with!(Sha512, Sha512) + } + }; + result.map_err(map_rsa_error) + } + + fn map_rsa_error(error: rsa::Error) -> ProviderError { + match error { + rsa::Error::Rng => ProviderError::Random("RSA-OAEP randomness failed".into()), + _ => ProviderError::AuthenticationFailed, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn capability_query_is_explicit_about_unimplemented_operations() { + assert!(RUST_CRYPTO_PROVIDER.supports(CapabilityQuery { + operation: ProviderOperation::Digest, + algorithm: None + })); + assert!(!RUST_CRYPTO_PROVIDER.supports(CapabilityQuery { + operation: ProviderOperation::KeyAgreement, + algorithm: None + })); + assert!(!RUST_CRYPTO_PROVIDER.supports(CapabilityQuery { + operation: ProviderOperation::Sign, + algorithm: Some("http://www.w3.org/2000/09/xmldsig#rsa-sha1") + })); + assert!(RUST_CRYPTO_PROVIDER.supports(CapabilityQuery { + operation: ProviderOperation::Verify, + algorithm: Some("http://www.w3.org/2000/09/xmldsig#rsa-sha1") + })); + assert!(!RUST_CRYPTO_PROVIDER.supports(CapabilityQuery { + operation: ProviderOperation::Verify, + algorithm: Some("urn:unsupported:signature"), + })); + } + + #[cfg(feature = "xmlenc")] + #[test] + fn legacy_oaep_mgf_constraint_is_symmetric() { + use rsa::pkcs8::DecodePrivateKey; + + // The legacy URI fixes MGF1 to SHA-1 for both directions; rejecting + // before RSA processing keeps transport and recovery capabilities equal. + let key = rsa::RsaPrivateKey::from_pkcs8_pem(include_str!( + "../tests/fixtures/keys/rsa/rsa-2048-key.pem" + )) + .expect("RSA fixture must parse"); + let parameters = crate::xmlenc::RsaOaepParameters { + algorithm: crate::xmlenc::KeyTransportAlgorithm::RsaOaepMgf1p, + digest: crate::xmlenc::OaepDigestAlgorithm::Sha256, + mgf_digest: crate::xmlenc::OaepDigestAlgorithm::Sha256, + label: Vec::new(), + }; + + assert!(matches!( + RUST_CRYPTO_PROVIDER.recover_key(&key, ¶meters, &[0_u8; 256]), + Err(ProviderError::InvalidInput( + ProviderInputError::LegacyRsaOaepMgf + )) + )); + } +} diff --git a/src/xmldsig/digest.rs b/src/xmldsig/digest.rs index a6021428..8d1a4560 100644 --- a/src/xmldsig/digest.rs +++ b/src/xmldsig/digest.rs @@ -5,8 +5,6 @@ //! //! All digest computation uses RustCrypto hash implementations. -use sha1::Sha1; -use sha2::{Digest, Sha256, Sha384, Sha512}; use subtle::ConstantTimeEq; /// Digest algorithms supported by XMLDSig. @@ -81,12 +79,17 @@ impl DigestAlgorithm { /// /// Returns the raw digest bytes (not base64-encoded). pub fn compute_digest(algorithm: DigestAlgorithm, data: &[u8]) -> Vec { - match algorithm { - DigestAlgorithm::Sha1 => Sha1::digest(data).to_vec(), - DigestAlgorithm::Sha256 => Sha256::digest(data).to_vec(), - DigestAlgorithm::Sha384 => Sha384::digest(data).to_vec(), - DigestAlgorithm::Sha512 => Sha512::digest(data).to_vec(), - } + compute_digest_with_provider(crate::provider::default_provider(), algorithm, data) + .expect("default provider advertises every XMLDSig digest") +} + +/// Compute a digest with an explicitly selected provider. +pub fn compute_digest_with_provider( + provider: &dyn crate::provider::CryptoProvider, + algorithm: DigestAlgorithm, + data: &[u8], +) -> Result, crate::provider::ProviderError> { + provider.digest(algorithm, data) } /// Constant-time comparison of two byte slices. @@ -105,6 +108,122 @@ pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { mod tests { use super::*; + struct RejectingDigestProvider; + + impl crate::provider::CryptoProvider for RejectingDigestProvider { + fn name(&self) -> &'static str { + "rejecting-digest" + } + + fn supports(&self, query: crate::provider::CapabilityQuery<'_>) -> bool { + crate::provider::default_provider().supports(query) + } + + fn fill_random(&self, output: &mut [u8]) -> Result<(), crate::provider::ProviderError> { + crate::provider::default_provider().fill_random(output) + } + + fn digest( + &self, + algorithm: DigestAlgorithm, + _data: &[u8], + ) -> Result, crate::provider::ProviderError> { + Err(crate::provider::ProviderError::Unsupported { + operation: crate::provider::ProviderOperation::Digest, + algorithm: Some(algorithm.uri().to_owned()), + }) + } + + fn sign( + &self, + key: &dyn super::super::SigningKey, + algorithm: super::super::SignatureAlgorithm, + data: &[u8], + ) -> Result, super::super::SigningKeyError> { + crate::provider::default_provider().sign(key, algorithm, data) + } + + fn verify( + &self, + key: &dyn super::super::VerifyingKey, + algorithm: super::super::SignatureAlgorithm, + data: &[u8], + signature: &[u8], + ) -> Result { + crate::provider::default_provider().verify(key, algorithm, data, signature) + } + + #[cfg(feature = "xmlenc")] + fn encrypt_data( + &self, + algorithm: crate::xmlenc::DataEncryptionAlgorithm, + key: &[u8], + plaintext: &[u8], + ) -> Result, crate::provider::ProviderError> { + crate::provider::default_provider().encrypt_data(algorithm, key, plaintext) + } + + #[cfg(feature = "xmlenc")] + fn decrypt_data( + &self, + algorithm: crate::xmlenc::DataEncryptionAlgorithm, + key: &[u8], + ciphertext: &[u8], + ) -> Result, crate::provider::ProviderError> { + crate::provider::default_provider().decrypt_data(algorithm, key, ciphertext) + } + + #[cfg(feature = "xmlenc")] + fn wrap_key( + &self, + algorithm: crate::xmlenc::KeyWrapAlgorithm, + kek: &[u8], + key: &[u8], + ) -> Result, crate::provider::ProviderError> { + crate::provider::default_provider().wrap_key(algorithm, kek, key) + } + + #[cfg(feature = "xmlenc")] + fn unwrap_key( + &self, + algorithm: crate::xmlenc::KeyWrapAlgorithm, + kek: &[u8], + wrapped: &[u8], + ) -> Result, crate::provider::ProviderError> { + crate::provider::default_provider().unwrap_key(algorithm, kek, wrapped) + } + + #[cfg(feature = "xmlenc")] + fn transport_key( + &self, + key: &rsa::RsaPublicKey, + parameters: &crate::xmlenc::RsaOaepParameters, + plaintext: &[u8], + ) -> Result, crate::provider::ProviderError> { + crate::provider::default_provider().transport_key(key, parameters, plaintext) + } + + #[cfg(feature = "xmlenc")] + fn recover_key( + &self, + key: &rsa::RsaPrivateKey, + parameters: &crate::xmlenc::RsaOaepParameters, + ciphertext: &[u8], + ) -> Result, crate::provider::ProviderError> { + crate::provider::default_provider().recover_key(key, parameters, ciphertext) + } + } + + #[test] + fn explicit_provider_digest_failures_are_returned() { + // A restricted provider is caller-controlled and must never turn an + // unsupported document-selected digest into a process panic. + assert!(matches!( + compute_digest_with_provider(&RejectingDigestProvider, DigestAlgorithm::Sha256, b"x"), + Err(crate::provider::ProviderError::Unsupported { .. }) + )); + } + // ── from_uri / uri round-trip ──────────────────────────────────── #[test] diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 494b16d9..43c0a909 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -1,26 +1,93 @@ //! Configuration and key material for XMLDSig key resolution. -use std::{collections::HashMap, time::SystemTime}; +use std::{collections::HashMap, fmt, time::SystemTime}; use crypto_bigint::BoxedUint; -use p256::pkcs8::EncodePublicKey as P256EncodePublicKey; +use dsa::pkcs8::{DecodePublicKey as DsaDecodePublicKey, EncodePublicKey as DsaEncodePublicKey}; +use hmac::{KeyInit, Mac}; use x509_parser::{ prelude::{FromDer, X509Certificate}, public_key::PublicKey, x509::SubjectPublicKeyInfo, }; +use super::signature::verify_rsa_signature_spki_with_minimum; use super::{ DsigError, KeyInfo, KeyInfoSource, KeyResolver, KeyValueInfo, SignatureAlgorithm, VerifyingKey, X509ChainOptions, X509DataInfo, parse::{ - EC_P256_OID, EC_P384_OID, ParseError, parse_x509_certificate, - x509_certificate_matches_any_selector, x509_data_has_lookup_identifiers, - x509_selector_categories_match_chain, + EC_P256_OID, EC_P384_OID, ParseError, X509ChainBuildError, + build_x509_certificate_paths_to_trusted_prefix, distinguished_names_equal, + parse_x509_certificate, x509_certificate_matches_any_selector, + x509_data_has_lookup_identifiers, x509_selector_categories_match_chain, }, - verify_ecdsa_signature_spki, verify_rsa_signature_spki, verify_x509_certificate_chain, + verify_dsa_signature_spki, verify_ecdsa_signature_spki, verify_rsa_signature_spki, + verify_x509_certificate_chain, }; +/// Caller-owned HMAC-SHA1 verification key. +#[derive(Clone)] +pub struct HmacSha1VerificationKey { + secret: Vec, + output_len: usize, +} + +impl fmt::Debug for HmacSha1VerificationKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("HmacSha1VerificationKey") + .field("output_length_bits", &(self.output_len * 8)) + .finish_non_exhaustive() + } +} + +impl HmacSha1VerificationKey { + /// Construct a key from non-empty secret bytes. + pub fn new(secret: impl Into>) -> Result { + let secret = secret.into(); + if secret.is_empty() { + return Err(KeyResolutionError::InvalidPublicKey); + } + Ok(Self { + secret, + output_len: 20, + }) + } + + /// Bind this key to an XMLDSig HMAC output length in bits. + pub fn with_output_length_bits( + mut self, + output_length_bits: u16, + ) -> Result { + if !(80..=160).contains(&output_length_bits) || !output_length_bits.is_multiple_of(8) { + return Err(KeyResolutionError::InvalidHmacOutputLength); + } + self.output_len = usize::from(output_length_bits / 8); + Ok(self) + } +} + +impl VerifyingKey for HmacSha1VerificationKey { + fn verify( + &self, + algorithm: SignatureAlgorithm, + signed_data: &[u8], + signature_value: &[u8], + ) -> Result { + if algorithm != SignatureAlgorithm::HmacSha1 { + return Err(KeyResolutionError::AlgorithmMismatch.into()); + } + if signature_value.len() != self.output_len { + return Ok(false); + } + let mut mac = hmac::Hmac::::new_from_slice(&self.secret) + .map_err(|_| KeyResolutionError::InvalidPublicKey)?; + mac.update(signed_data); + let expected = mac.finalize().into_bytes(); + Ok(subtle::ConstantTimeEq::ct_eq(&expected[..self.output_len], signature_value).into()) + } +} + /// A public verification key available to key resolvers. #[derive(Debug, Clone, PartialEq, Eq)] pub struct VerificationKey { @@ -45,8 +112,23 @@ impl VerifyingKey for VerificationKey { return Err(KeyResolutionError::AlgorithmMismatch.into()); } let result = match algorithm { - SignatureAlgorithm::RsaSha1 - | SignatureAlgorithm::RsaSha256 + SignatureAlgorithm::DsaSha1 => verify_dsa_signature_spki( + algorithm, + &self.public_key_bytes, + signed_data, + signature_value, + ), + SignatureAlgorithm::HmacSha1 => { + return Err(KeyResolutionError::AlgorithmMismatch.into()); + } + SignatureAlgorithm::RsaSha1 => verify_rsa_signature_spki_with_minimum( + algorithm, + &self.public_key_bytes, + signed_data, + signature_value, + 1024, + ), + SignatureAlgorithm::RsaSha256 | SignatureAlgorithm::RsaSha384 | SignatureAlgorithm::RsaSha512 => verify_rsa_signature_spki( algorithm, @@ -80,6 +162,9 @@ pub enum KeyResolutionError { /// Configured or embedded public key DER could not be parsed completely. #[error("invalid public key DER")] InvalidPublicKey, + /// HMAC-SHA1 output length is outside XMLDSig's byte-aligned 80-160 bit range. + #[error("HMAC-SHA1 output length must be byte-aligned and between 80 and 160 bits")] + InvalidHmacOutputLength, /// More than one configured certificate satisfies all X.509 selectors. #[error("X.509 lookup selectors match multiple configured certificates")] AmbiguousCertificate, @@ -99,30 +184,22 @@ pub enum KeyResolutionError { /// The configuration owns all key material and has no global registry. Chain /// verification is opt-in so callers that pin an embedded certificate can use /// the documented TOFU model without constructing a certificate path. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct KeyResolverConfig { + /// DER-encoded certificates available to X.509 selectors and as untrusted + /// path intermediates. They establish trust only by chaining to an entry in + /// [`Self::trusted_certs`]. + pub lookup_certs: Vec>, /// DER-encoded certificates accepted as trust anchors. pub trusted_certs: Vec>, /// Verification keys addressable by `` content. pub named_keys: HashMap, - /// Whether embedded X.509 certificate chains must terminate at a trust anchor. - pub verify_chains: bool, - /// Certificate verification time override; `None` selects the system clock. - pub verification_time: Option, - /// Maximum certificates in a validated path, including the trust anchor. - pub max_chain_depth: usize, -} - -impl Default for KeyResolverConfig { - fn default() -> Self { - Self { - trusted_certs: Vec::new(), - named_keys: HashMap::new(), - verify_chains: false, - verification_time: None, - max_chain_depth: 9, - } - } + /// Trust defaults used only by direct [`KeyResolver::resolve`] calls. + /// + /// [`super::VerifyContext`] composes these defaults fail-closed with its + /// operation policy through `resolve_with_policy`; resolver-local defaults + /// cannot weaken a verification pipeline policy. + pub trust: crate::policy::KeyTrustPolicy, } /// Configuration-driven resolver for embedded certificates, DER keys, and key names. @@ -148,38 +225,31 @@ impl DefaultKeyResolver { &self, info: &X509DataInfo, algorithm: SignatureAlgorithm, + trust: &crate::policy::KeyTrustPolicy, ) -> Result, KeyResolutionError> { let certificate_der = if let Some(&signing_index) = info.certificate_chain.first() { let certificate_der = info .certificates .get(signing_index) - .ok_or(KeyResolutionError::InvalidCertificate)?; - if self.config.verify_chains { - self.verify_x509_policy(info, None)?; + .ok_or(KeyResolutionError::InvalidCertificate)? + .clone(); + if trust.verify_x509_chains { + self.prepare_embedded_x509(info, signing_index, trust)?; } certificate_der } else { - let Some(certificate) = self.resolve_configured_x509(info)? else { + let Some(selected) = self.resolve_configured_x509(info, trust)? else { return Ok(None); }; - if self.config.verify_chains { - let parsed = parse_x509_certificate(certificate) - .map_err(|_| KeyResolutionError::InvalidCertificate)?; - let selected = X509DataInfo { - certificates: vec![certificate.clone()], - parsed_certificates: vec![parsed], - certificate_chain: vec![0], - ..X509DataInfo::default() - }; - // Validate the selected certificate's own policy before - // requiring a distinct configured certificate as its anchor. - self.verify_x509_policy(&selected, None)?; - self.verify_x509_policy(&selected, Some(certificate))?; - } - certificate + selected + .certificate_chain + .first() + .and_then(|index| selected.certificates.get(*index)) + .ok_or(KeyResolutionError::InvalidCertificate)? + .clone() }; - let (rest, certificate) = X509Certificate::from_der(certificate_der) + let (rest, certificate) = X509Certificate::from_der(&certificate_der) .map_err(|_| KeyResolutionError::InvalidCertificate)?; if !rest.is_empty() { return Err(KeyResolutionError::InvalidCertificate); @@ -189,7 +259,7 @@ impl DefaultKeyResolver { Ok(Some(VerificationKey { algorithm, public_key_bytes, - certificate_der: Some(certificate_der.clone()), + certificate_der: Some(certificate_der), name: None, })) } @@ -197,41 +267,117 @@ impl DefaultKeyResolver { fn verify_x509_policy( &self, info: &X509DataInfo, - selected_lookup_certificate: Option<&[u8]>, + trust: &crate::policy::KeyTrustPolicy, ) -> Result<(), KeyResolutionError> { - let trusted_certs = self - .config - .trusted_certs - .iter() - .filter(|certificate| { - selected_lookup_certificate - .is_none_or(|selected| certificate.as_slice() != selected) - }) - .cloned() - .collect::>(); let options = X509ChainOptions { - trusted_certs: &trusted_certs, - verification_time: self - .config - .verification_time - .unwrap_or_else(SystemTime::now), - max_chain_depth: self.config.max_chain_depth, - check_crls: false, + trusted_certs: &self.config.trusted_certs, + verification_time: trust.verification_time.unwrap_or_else(SystemTime::now), + max_chain_depth: trust.max_x509_chain_depth, + check_crls: trust.check_crls, }; verify_x509_certificate_chain(info, &options)?; Ok(()) } - fn resolve_configured_x509<'a>( - &'a self, + fn prepare_embedded_x509( + &self, + info: &X509DataInfo, + signing_index: usize, + trust: &crate::policy::KeyTrustPolicy, + ) -> Result { + let signing_der = info + .certificates + .get(signing_index) + .ok_or(KeyResolutionError::InvalidCertificate)?; + let mut available = X509DataInfo { + crls: info.crls.clone(), + ..X509DataInfo::default() + }; + for certificate in self + .config + .trusted_certs + .iter() + .chain(&self.config.lookup_certs) + .chain(&info.certificates) + { + if available + .certificates + .iter() + .any(|known| known == certificate) + { + continue; + } + available.parsed_certificates.push( + parse_x509_certificate(certificate) + .map_err(|_| KeyResolutionError::InvalidCertificate)?, + ); + available.certificates.push(certificate.clone()); + } + let signing_index = available + .certificates + .iter() + .position(|certificate| certificate == signing_der) + .ok_or(KeyResolutionError::InvalidCertificate)?; + self.select_valid_x509_path(&mut available, signing_index, trust)?; + Ok(available) + } + + fn select_valid_x509_path( + &self, + available: &mut X509DataInfo, + signing_index: usize, + trust: &crate::policy::KeyTrustPolicy, + ) -> Result<(), KeyResolutionError> { + let candidates = build_x509_certificate_paths_to_trusted_prefix( + available, + signing_index, + self.config.trusted_certs.len(), + trust.max_x509_chain_depth, + trust.max_x509_candidate_paths, + ) + .map_err(|error| match error { + X509ChainBuildError::AmbiguousIssuer => KeyResolutionError::AmbiguousCertificate, + _ => KeyResolutionError::InvalidCertificate, + })?; + let mut first_error = None; + for candidate in candidates { + available.certificate_chain = candidate; + match self.verify_x509_policy(available, trust) { + Ok(()) => return Ok(()), + Err(error) => { + first_error.get_or_insert(error); + } + } + } + Err(first_error.unwrap_or(KeyResolutionError::Chain( + super::X509ChainError::UntrustedRoot, + ))) + } + + fn resolve_configured_x509( + &self, info: &X509DataInfo, - ) -> Result>, KeyResolutionError> { + trust: &crate::policy::KeyTrustPolicy, + ) -> Result, KeyResolutionError> { if !x509_data_has_lookup_identifiers(info) { return Ok(None); } + let mut available = X509DataInfo { + subject_names: info.subject_names.clone(), + issuer_serials: info.issuer_serials.clone(), + skis: info.skis.clone(), + crls: info.crls.clone(), + digests: info.digests.clone(), + ..X509DataInfo::default() + }; let mut matches = Vec::new(); - for certificate_der in &self.config.trusted_certs { + for certificate_der in self + .config + .trusted_certs + .iter() + .chain(&self.config.lookup_certs) + { let parsed = parse_x509_certificate(certificate_der) .map_err(|_| KeyResolutionError::InvalidCertificate)?; let is_match = x509_certificate_matches_any_selector(info, &parsed, certificate_der) @@ -242,14 +388,16 @@ impl DefaultKeyResolver { _ => KeyResolutionError::InvalidCertificate, })?; if is_match { - matches.push((certificate_der, parsed)); + matches.push((available.certificates.len(), parsed.clone())); } + available.certificates.push(certificate_der.clone()); + available.parsed_certificates.push(parsed); } let matched_chain = X509DataInfo { certificates: matches .iter() - .map(|(certificate, _)| (*certificate).clone()) + .map(|(index, _)| available.certificates[*index].clone()) .collect(), parsed_certificates: matches.iter().map(|(_, parsed)| parsed.clone()).collect(), ..X509DataInfo::default() @@ -270,25 +418,39 @@ impl DefaultKeyResolver { return Ok(None); } - match matches.as_slice() { - [] => Ok(None), - [(certificate, _)] => Ok(Some(certificate)), + let signing_index = match matches.as_slice() { + [] => return Ok(None), + [(index, _)] => *index, _ => { let leaves = matches .iter() .filter(|(_, candidate)| { - candidate.subject_dn != candidate.issuer_dn - && !matches - .iter() - .any(|(_, other)| other.issuer_dn == candidate.subject_dn) + !distinguished_names_equal(&candidate.subject_dn, &candidate.issuer_dn) + && !matches.iter().any(|(_, other)| { + distinguished_names_equal(&other.issuer_dn, &candidate.subject_dn) + }) }) .collect::>(); match leaves.as_slice() { - [(certificate, _)] => Ok(Some(certificate)), - _ => Err(KeyResolutionError::AmbiguousCertificate), + [(index, _)] => *index, + _ => return Err(KeyResolutionError::AmbiguousCertificate), } } + }; + // `available` preserves trusted certificates as a prefix. Selecting + // one of those exact certificates is already a terminal trust + // decision, even when the certificate is not self-signed. + available.certificate_chain = + if signing_index < self.config.trusted_certs.len() || !trust.verify_x509_chains { + vec![signing_index] + } else { + self.select_valid_x509_path(&mut available, signing_index, trust)?; + available.certificate_chain.clone() + }; + if trust.verify_x509_chains && signing_index < self.config.trusted_certs.len() { + self.verify_x509_policy(&available, trust)?; } + Ok(Some(available)) } fn resolve_key_value( @@ -296,6 +458,15 @@ impl DefaultKeyResolver { algorithm: SignatureAlgorithm, ) -> Result, KeyResolutionError> { let public_key_bytes = match key_value { + KeyValueInfo::Dsa { p, q, g, y } => { + if algorithm != SignatureAlgorithm::DsaSha1 { + return Err(KeyResolutionError::AlgorithmMismatch); + } + let (Some(p), Some(q), Some(g)) = (p.as_deref(), q.as_deref(), g.as_deref()) else { + return Err(KeyResolutionError::InvalidPublicKey); + }; + dsa_key_value_to_spki_der(p, q, g, y)? + } KeyValueInfo::Rsa { modulus, exponent } => { if !matches!( algorithm, @@ -332,13 +503,12 @@ impl DefaultKeyResolver { name: None, })) } -} -impl KeyResolver for DefaultKeyResolver { - fn resolve<'a>( + fn resolve_with_trust<'a>( &'a self, key_info: Option<&KeyInfo>, algorithm: SignatureAlgorithm, + trust: &crate::policy::KeyTrustPolicy, ) -> Result>, DsigError> { let Some(key_info) = key_info else { return Ok(None); @@ -346,7 +516,7 @@ impl KeyResolver for DefaultKeyResolver { let mut deferred_key_value_error = None; for source in &key_info.sources { let resolved = match source { - KeyInfoSource::X509Data(info) => self.resolve_x509(info, algorithm)?, + KeyInfoSource::X509Data(info) => self.resolve_x509(info, algorithm, trust)?, KeyInfoSource::DerEncodedKeyValue(public_key_bytes) => { validate_spki_algorithm(public_key_bytes, algorithm)?; Some(VerificationKey { @@ -371,13 +541,14 @@ impl KeyResolver for DefaultKeyResolver { KeyInfoSource::KeyValue(key_value) => { match Self::resolve_key_value(key_value, algorithm) { Ok(resolved) => resolved, - Err(error) if ec_key_value_error_allows_fallback(key_value, &error) => { + Err(error) if key_value_error_allows_fallback(key_value, &error) => { deferred_key_value_error.get_or_insert(error); None } Err(error) => return Err(error.into()), } } + KeyInfoSource::RetrievalMethod { .. } => None, }; if let Some(key) = resolved { return Ok(Some(Box::new(key))); @@ -388,6 +559,47 @@ impl KeyResolver for DefaultKeyResolver { } Ok(None) } +} + +impl KeyResolver for DefaultKeyResolver { + fn resolve<'a>( + &'a self, + key_info: Option<&KeyInfo>, + algorithm: SignatureAlgorithm, + ) -> Result>, DsigError> { + self.resolve_with_trust(key_info, algorithm, &self.config.trust) + } + + fn resolve_with_policy<'a>( + &'a self, + key_info: Option<&KeyInfo>, + algorithm: SignatureAlgorithm, + policy: &crate::policy::VerificationPolicy, + ) -> Result>, DsigError> { + // Resolver defaults and operation policy compose fail-closed. X.509 + // validation requirements can only become stricter, while the legacy + // algorithm opt-in remains exclusively context-owned and is enforced + // before key resolution. + let trust = crate::policy::KeyTrustPolicy { + verify_x509_chains: policy.key_trust.verify_x509_chains + || self.config.trust.verify_x509_chains, + max_x509_chain_depth: policy + .key_trust + .max_x509_chain_depth + .min(self.config.trust.max_x509_chain_depth), + max_x509_candidate_paths: policy + .key_trust + .max_x509_candidate_paths + .min(self.config.trust.max_x509_candidate_paths), + allow_legacy_rsa_sha1: policy.key_trust.allow_legacy_rsa_sha1, + check_crls: policy.key_trust.check_crls || self.config.trust.check_crls, + verification_time: policy + .key_trust + .verification_time + .or(self.config.trust.verification_time), + }; + self.resolve_with_trust(key_info, algorithm, &trust) + } fn consumes_document_key_info(&self) -> bool { true @@ -408,6 +620,25 @@ fn rsa_key_value_to_spki_der( .map(|der| der.as_bytes().to_vec()) } +fn dsa_key_value_to_spki_der( + p: &[u8], + q: &[u8], + g: &[u8], + y: &[u8], +) -> Result, KeyResolutionError> { + let components = dsa::Components::from_components( + BoxedUint::from_be_slice_vartime(p), + BoxedUint::from_be_slice_vartime(q), + BoxedUint::from_be_slice_vartime(g), + ) + .map_err(|_| KeyResolutionError::InvalidPublicKey)?; + dsa::VerifyingKey::from_components(components, BoxedUint::from_be_slice_vartime(y)) + .map_err(|_| KeyResolutionError::InvalidPublicKey)? + .to_public_key_der() + .map_err(|_| KeyResolutionError::InvalidPublicKey) + .map(|der| der.as_bytes().to_vec()) +} + fn ec_key_value_to_spki_der( curve_oid: &str, public_key: &[u8], @@ -427,13 +658,10 @@ fn ec_key_value_to_spki_der( } } -fn ec_key_value_error_allows_fallback( - key_value: &KeyValueInfo, - error: &KeyResolutionError, -) -> bool { +fn key_value_error_allows_fallback(key_value: &KeyValueInfo, error: &KeyResolutionError) -> bool { matches!( key_value, - KeyValueInfo::Ec { .. } | KeyValueInfo::InvalidEcKeyValue + KeyValueInfo::Dsa { .. } | KeyValueInfo::Ec { .. } | KeyValueInfo::InvalidEcKeyValue ) && matches!( error, KeyResolutionError::InvalidPublicKey | KeyResolutionError::AlgorithmMismatch @@ -459,6 +687,11 @@ fn validate_spki_algorithm( .and_then(|value| value.as_oid().ok()) .map(|oid| oid.to_id_string()); match (algorithm, parsed) { + (SignatureAlgorithm::DsaSha1, PublicKey::DSA(_)) => { + let _ = dsa::VerifyingKey::from_public_key_der(public_key_bytes) + .map_err(|_| KeyResolutionError::AlgorithmMismatch)?; + Ok(()) + } ( SignatureAlgorithm::RsaSha1 | SignatureAlgorithm::RsaSha256 @@ -490,6 +723,20 @@ mod tests { use super::*; + fn chain_policy() -> crate::policy::KeyTrustPolicy { + crate::policy::KeyTrustPolicy { + verify_x509_chains: true, + ..crate::policy::KeyTrustPolicy::default() + } + } + + fn chain_policy_at(verification_time: SystemTime) -> crate::policy::KeyTrustPolicy { + crate::policy::KeyTrustPolicy { + verification_time: Some(verification_time), + ..chain_policy() + } + } + const SIGNED_SAML: &str = include_str!("../../tests/fixtures/saml/response_signed_by_idp_ecdsa.xml"); const SAML_PUBLIC_KEY: &str = @@ -500,6 +747,9 @@ mod tests { const X509_DIGEST_SIGNATURE: &str = include_str!( "../../tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloped-x509-digest-sha512.xml" ); + const X509_DIGEST_SHA256_SIGNATURE: &str = include_str!( + "../../tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloped-x509-digest-sha256.xml" + ); const RSA_KEY_VALUE_SIGNATURE: &str = include_str!( "../../tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.xml" ); @@ -538,7 +788,7 @@ mod tests { fn x509_signature_with_leaf_subject() -> String { replace_unprefixed_key_info( X509_DIGEST_SIGNATURE, - "C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), CN=Test Key rsa-4096", + "CN=Test Key rsa-4096,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US", ) } @@ -563,16 +813,128 @@ mod tests { pem.contents } + fn crl_der(pem_text: &str) -> Vec { + let (rest, pem) = + x509_parser::pem::parse_x509_pem(pem_text.as_bytes()).expect("fixture CRL is PEM"); + assert!(rest.iter().all(|byte| byte.is_ascii_whitespace())); + assert_eq!(pem.label, "X509 CRL"); + pem.contents + } + + fn generated_certificate_params(common_name: &str, is_ca: bool) -> rcgen::CertificateParams { + let mut params = rcgen::CertificateParams::new(Vec::new()) + .expect("empty SAN list should produce valid certificate parameters"); + params + .distinguished_name + .push(rcgen::DnType::CommonName, common_name); + if is_ca { + params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign]; + } + params + } + + fn x509_info(certificates: Vec>, signing_index: usize) -> X509DataInfo { + let parsed_certificates = certificates + .iter() + .map(|certificate| { + parse_x509_certificate(certificate) + .expect("generated certificate should have supported metadata") + }) + .collect(); + X509DataInfo { + certificates, + parsed_certificates, + certificate_chain: vec![signing_index], + ..X509DataInfo::default() + } + } + #[test] fn defaults_match_key_resolution_policy() { // Defaults must remain compatible with xmlsec1's depth and opt-in trust policy. let config = KeyResolverConfig::default(); assert!(config.trusted_certs.is_empty()); + assert!(config.lookup_certs.is_empty()); assert!(config.named_keys.is_empty()); - assert!(!config.verify_chains); - assert_eq!(config.verification_time, None); - assert_eq!(config.max_chain_depth, 9); + assert!(!config.trust.verify_x509_chains); + assert!(!config.trust.check_crls); + assert_eq!(config.trust.verification_time, None); + assert_eq!(config.trust.max_x509_chain_depth, 9); + } + + #[test] + fn hmac_key_rejects_empty_secret_and_wrong_algorithm() { + // HMAC secrets are caller-owned and cannot be reused as asymmetric keys. + assert!(matches!( + HmacSha1VerificationKey::new(Vec::new()), + Err(KeyResolutionError::InvalidPublicKey) + )); + let key = HmacSha1VerificationKey::new(b"secret".to_vec()) + .expect("non-empty HMAC secret must be accepted"); + assert!(matches!( + key.verify(SignatureAlgorithm::RsaSha256, b"data", b"signature"), + Err(DsigError::KeyResolution( + KeyResolutionError::AlgorithmMismatch + )) + )); + } + + #[test] + fn hmac_key_enforces_its_bound_output_length() { + let full = HmacSha1VerificationKey::new(b"secret".to_vec()) + .expect("the fixture HMAC secret is non-empty"); + let truncated = HmacSha1VerificationKey::new(b"secret".to_vec()) + .expect("the fixture HMAC secret is non-empty") + .with_output_length_bits(80) + .expect("80 bits is a valid HMAC-SHA1 output length"); + let mut mac = hmac::Hmac::::new_from_slice(b"secret") + .expect("HMAC accepts an arbitrary non-empty secret"); + mac.update(b"data"); + let expected = mac.finalize().into_bytes(); + + assert!( + !full + .verify(SignatureAlgorithm::HmacSha1, b"data", &expected[..10]) + .expect("the key and algorithm match") + ); + assert!( + truncated + .verify(SignatureAlgorithm::HmacSha1, b"data", &expected[..10]) + .expect("the key and algorithm match") + ); + assert!(matches!( + HmacSha1VerificationKey::new(b"secret".to_vec()) + .expect("the fixture HMAC secret is non-empty") + .with_output_length_bits(79), + Err(KeyResolutionError::InvalidHmacOutputLength) + )); + assert!(matches!( + HmacSha1VerificationKey::new(b"secret".to_vec()) + .expect("the fixture HMAC secret is non-empty") + .with_output_length_bits(81), + Err(KeyResolutionError::InvalidHmacOutputLength) + )); + } + + #[test] + fn hmac_key_debug_redacts_secret_material() { + // Debug output may expose public verification parameters, never caller secrets. + let secret = b"unique-debug-secret-marker"; + let key = HmacSha1VerificationKey::new(secret.to_vec()) + .expect("the fixture HMAC secret is non-empty") + .with_output_length_bits(80) + .expect("80 bits is a valid HMAC-SHA1 output length"); + + let debug = format!("{key:?}"); + assert!( + !debug + .contains(std::str::from_utf8(secret).expect("the debug marker is literal ASCII")) + ); + assert!(!debug.contains(&format!("{secret:?}"))); + assert!(debug.contains("output_length_bits")); + assert!(debug.contains("80")); } #[test] @@ -608,30 +970,35 @@ mod tests { // embedding key material or supplying a preset verification key. let leaf_certificate_der = certificate_der(RSA_4096_CERTIFICATE); let resolver = DefaultKeyResolver::new(KeyResolverConfig { + lookup_certs: vec![leaf_certificate_der], trusted_certs: vec![ - leaf_certificate_der, certificate_der(include_str!("../../tests/fixtures/keys/ca2cert.pem")), certificate_der(include_str!("../../tests/fixtures/keys/cacert.pem")), ], ..KeyResolverConfig::default() }); - let result = super::super::VerifyContext::new() - .key_resolver(&resolver) - .verify(X509_DIGEST_SIGNATURE) - .expect("X509Digest should resolve a configured certificate"); + for signature in [X509_DIGEST_SHA256_SIGNATURE, X509_DIGEST_SIGNATURE] { + let result = super::super::VerifyContext::new() + .key_resolver(&resolver) + .verify(signature) + .expect("X509Digest should resolve a configured certificate"); - assert_eq!(result.status, super::super::DsigStatus::Valid); + assert_eq!(result.status, super::super::DsigStatus::Valid); + } } #[test] fn selector_resolved_certificate_obeys_chain_policy() { // Enabling chain verification must apply validity policy even when // X509Data contains only selectors and the matching cert is configured. - let certificate_der = certificate_der(RSA_4096_CERTIFICATE); + let leaf_certificate_der = certificate_der(RSA_4096_CERTIFICATE); let resolver = DefaultKeyResolver::new(KeyResolverConfig { - trusted_certs: vec![certificate_der], - verify_chains: true, - verification_time: Some(SystemTime::UNIX_EPOCH), + lookup_certs: vec![leaf_certificate_der], + trusted_certs: vec![ + certificate_der(include_str!("../../tests/fixtures/keys/ca2cert.pem")), + certificate_der(include_str!("../../tests/fixtures/keys/cacert.pem")), + ], + trust: chain_policy_at(SystemTime::UNIX_EPOCH), ..KeyResolverConfig::default() }); let error = super::super::VerifyContext::new() @@ -639,12 +1006,147 @@ mod tests { .verify(&x509_signature_with_leaf_subject()) .expect_err("selector-resolved certificate must satisfy chain policy"); - assert!(matches!( - error, - DsigError::KeyResolution(KeyResolutionError::Chain( - super::super::X509ChainError::CertificateNotValid(_) - )) - )); + assert!( + matches!( + &error, + DsigError::KeyResolution(KeyResolutionError::Chain( + super::super::X509ChainError::CertificateNotValid(_) + )) + ), + "unexpected selector policy error: {error:?}" + ); + } + + #[test] + fn selector_resolved_configured_root_remains_a_trust_anchor() { + // A certificate explicitly configured in trusted_certs remains an + // anchor when X509Data selects it by subject instead of embedding it. + let mut params = rcgen::CertificateParams::new(Vec::new()) + .expect("empty SAN list should produce valid certificate parameters"); + params + .distinguished_name + .push(rcgen::DnType::CommonName, "configured root"); + params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + let key_pair = rcgen::KeyPair::generate().expect("test key generation should succeed"); + let certificate = params + .self_signed(&key_pair) + .expect("test root should be self-signable"); + let certificate_der = certificate.der().to_vec(); + let key_info_xml = concat!( + "", + "CN=configured root", + "" + ); + let document = roxmltree::Document::parse(key_info_xml) + .expect("static selector KeyInfo should parse as XML"); + let key_info = super::super::parse_key_info(document.root_element()) + .expect("static selector KeyInfo should satisfy XMLDSig structure"); + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + trusted_certs: vec![certificate_der], + trust: chain_policy(), + ..KeyResolverConfig::default() + }); + + let resolved = resolver + .resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) + .expect("configured self-signed certificate should validate as its own anchor"); + + assert!(resolved.is_some()); + } + + #[test] + fn selector_resolved_non_self_signed_trust_anchor_terminates_the_path() { + // Trust is assigned to the exact configured certificate, not inferred + // from self-signing. A lookup-only issuer must not extend that anchor + // into a new path that requires another trust decision. + let mut issuer_params = rcgen::CertificateParams::new(Vec::new()) + .expect("empty issuer SAN list should be valid"); + issuer_params + .distinguished_name + .push(rcgen::DnType::CommonName, "lookup-only issuer"); + issuer_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + issuer_params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign]; + let issuer = rcgen::CertifiedIssuer::self_signed( + issuer_params, + rcgen::KeyPair::generate().expect("issuer key generation should succeed"), + ) + .expect("issuer certificate should be self-signable"); + + let mut anchor_params = rcgen::CertificateParams::new(Vec::new()) + .expect("empty anchor SAN list should be valid"); + anchor_params + .distinguished_name + .push(rcgen::DnType::CommonName, "direct trust anchor"); + let anchor = anchor_params + .signed_by( + &rcgen::KeyPair::generate().expect("anchor key generation should succeed"), + &issuer, + ) + .expect("issuer should sign the directly trusted certificate"); + let key_info_xml = concat!( + "", + "CN=direct trust anchor", + "" + ); + let document = roxmltree::Document::parse(key_info_xml) + .expect("static selector KeyInfo should parse as XML"); + let key_info = super::super::parse_key_info(document.root_element()) + .expect("static selector KeyInfo should satisfy XMLDSig structure"); + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + trusted_certs: vec![anchor.der().to_vec()], + lookup_certs: vec![issuer.der().to_vec()], + trust: chain_policy(), + ..KeyResolverConfig::default() + }); + + let resolved = resolver + .resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) + .expect("an explicitly trusted selected certificate must terminate its path"); + + assert!(resolved.is_some()); + } + + #[test] + fn selector_resolved_leaf_stops_at_non_self_signed_trust_anchor() { + // A configured anchor terminates trust even when a lookup certificate + // could continue the issuer-name chain beyond it. + let external_issuer = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("external issuer", true), + rcgen::KeyPair::generate().expect("external issuer key generation should succeed"), + ) + .expect("external issuer should be self-signable"); + let anchor = rcgen::CertifiedIssuer::signed_by( + generated_certificate_params("non-self-signed anchor", true), + rcgen::KeyPair::generate().expect("anchor key generation should succeed"), + &external_issuer, + ) + .expect("external issuer should sign the anchor"); + let leaf = generated_certificate_params("anchor leaf", false) + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &anchor, + ) + .expect("anchor should sign the leaf"); + let leaf_metadata = parse_x509_certificate(leaf.der()) + .expect("generated leaf should have supported metadata"); + let key_info = KeyInfo { + sources: vec![KeyInfoSource::X509Data(X509DataInfo { + subject_names: vec![leaf_metadata.subject_dn], + ..X509DataInfo::default() + })], + }; + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + trusted_certs: vec![anchor.der().to_vec()], + lookup_certs: vec![leaf.der().to_vec(), external_issuer.der().to_vec()], + trust: chain_policy(), + ..KeyResolverConfig::default() + }); + + let resolved = resolver + .resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) + .expect("path construction must stop at the configured anchor"); + + assert!(resolved.is_some()); } #[test] @@ -653,9 +1155,8 @@ mod tests { // trust anchor; chain verification still requires a separate issuer. let certificate_der = certificate_der(RSA_4096_CERTIFICATE); let resolver = DefaultKeyResolver::new(KeyResolverConfig { - trusted_certs: vec![certificate_der], - verify_chains: true, - verification_time: Some(fixture_certificate_time()), + lookup_certs: vec![certificate_der], + trust: chain_policy_at(fixture_certificate_time()), ..KeyResolverConfig::default() }); let error = super::super::VerifyContext::new() @@ -678,9 +1179,9 @@ mod tests { let leaf = certificate_der(RSA_4096_CERTIFICATE); let issuer = certificate_der(include_str!("../../tests/fixtures/keys/ca2cert.pem")); let resolver = DefaultKeyResolver::new(KeyResolverConfig { - trusted_certs: vec![leaf, issuer], - verify_chains: true, - verification_time: Some(fixture_certificate_time()), + lookup_certs: vec![leaf], + trusted_certs: vec![issuer], + trust: chain_policy_at(fixture_certificate_time()), ..KeyResolverConfig::default() }); let result = super::super::VerifyContext::new() @@ -691,13 +1192,292 @@ mod tests { assert_eq!(result.status, super::super::DsigStatus::Valid); } + #[test] + fn selector_resolved_leaf_uses_lookup_intermediate() { + // Lookup certificates may complete an untrusted path, but only the + // separately configured root is allowed to establish trust. + let mut root_params = + rcgen::CertificateParams::new(Vec::new()).expect("empty root SAN list should be valid"); + root_params + .distinguished_name + .push(rcgen::DnType::CommonName, "lookup root"); + root_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + root_params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign]; + let root = rcgen::CertifiedIssuer::self_signed( + root_params, + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("root certificate should be self-signable"); + + let mut intermediate_params = rcgen::CertificateParams::new(Vec::new()) + .expect("empty intermediate SAN list should be valid"); + intermediate_params + .distinguished_name + .push(rcgen::DnType::CommonName, "lookup intermediate"); + intermediate_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + intermediate_params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign]; + let intermediate = rcgen::CertifiedIssuer::signed_by( + intermediate_params, + rcgen::KeyPair::generate().expect("intermediate key generation should succeed"), + &root, + ) + .expect("root should sign the intermediate certificate"); + + let mut leaf_params = + rcgen::CertificateParams::new(Vec::new()).expect("empty leaf SAN list should be valid"); + leaf_params + .distinguished_name + .push(rcgen::DnType::CommonName, "lookup leaf"); + let leaf = leaf_params + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &intermediate, + ) + .expect("intermediate should sign the leaf certificate"); + let key_info_xml = concat!( + "", + "CN=lookup leaf", + "" + ); + let document = roxmltree::Document::parse(key_info_xml) + .expect("static selector KeyInfo should parse as XML"); + let key_info = super::super::parse_key_info(document.root_element()) + .expect("static selector KeyInfo should satisfy XMLDSig structure"); + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + lookup_certs: vec![leaf.der().to_vec(), intermediate.der().to_vec()], + trusted_certs: vec![root.der().to_vec()], + trust: chain_policy(), + ..KeyResolverConfig::default() + }); + + let resolved = resolver + .resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) + .expect("selector-resolved leaf should chain through the lookup intermediate"); + + assert!(resolved.is_some()); + } + + #[test] + fn embedded_leaf_uses_configured_lookup_intermediate() { + // lookup_certs are untrusted path-building material for every X509Data + // source, including an embedded leaf and raw-certificate retrieval. + let root = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("embedded root", true), + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("root should be self-signable"); + let intermediate = rcgen::CertifiedIssuer::signed_by( + generated_certificate_params("embedded intermediate", true), + rcgen::KeyPair::generate().expect("intermediate key generation should succeed"), + &root, + ) + .expect("root should sign the intermediate"); + let leaf = generated_certificate_params("embedded leaf", false) + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &intermediate, + ) + .expect("intermediate should sign the leaf"); + let key_info = KeyInfo { + sources: vec![KeyInfoSource::X509Data(x509_info( + vec![leaf.der().to_vec()], + 0, + ))], + }; + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + lookup_certs: vec![intermediate.der().to_vec()], + trusted_certs: vec![root.der().to_vec()], + trust: chain_policy(), + ..KeyResolverConfig::default() + }); + + let resolved = resolver + .resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) + .expect("embedded leaf should chain through the configured lookup intermediate"); + + assert!(resolved.is_some()); + } + + #[test] + fn selector_resolved_leaf_chooses_unique_valid_same_key_path() { + // Cross-signing can produce issuer certificates with the same subject + // and public key. Trust policy, not the immediate signature edge, must + // select the sole path that reaches a configured anchor. + let trusted_root = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("trusted cross-sign root", true), + rcgen::KeyPair::generate().expect("trusted root key generation should succeed"), + ) + .expect("trusted root should be self-signable"); + let untrusted_root = rcgen::CertifiedIssuer::self_signed( + generated_certificate_params("untrusted cross-sign root", true), + rcgen::KeyPair::generate().expect("untrusted root key generation should succeed"), + ) + .expect("untrusted root should be self-signable"); + let shared_params = generated_certificate_params("shared cross-sign issuer", true); + let shared_key = + rcgen::KeyPair::generate().expect("shared issuer key generation should succeed"); + let trusted_intermediate = shared_params + .signed_by(&shared_key, &trusted_root) + .expect("trusted root should cross-sign the shared issuer key"); + let untrusted_intermediate = shared_params + .signed_by(&shared_key, &untrusted_root) + .expect("untrusted root should cross-sign the shared issuer key"); + let shared_issuer = rcgen::Issuer::from_params(&shared_params, &shared_key); + let leaf = generated_certificate_params("cross-signed leaf", false) + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &shared_issuer, + ) + .expect("shared issuer key should sign the leaf"); + let leaf_metadata = parse_x509_certificate(leaf.der()) + .expect("generated leaf should have supported metadata"); + let key_info = KeyInfo { + sources: vec![KeyInfoSource::X509Data(X509DataInfo { + subject_names: vec![leaf_metadata.subject_dn], + ..X509DataInfo::default() + })], + }; + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + trusted_certs: vec![trusted_root.der().to_vec()], + lookup_certs: vec![ + leaf.der().to_vec(), + untrusted_intermediate.der().to_vec(), + trusted_intermediate.der().to_vec(), + untrusted_root.der().to_vec(), + ], + trust: chain_policy(), + ..KeyResolverConfig::default() + }); + + let resolved = resolver + .resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) + .expect("the sole path to a configured anchor should be selected"); + + assert!(resolved.is_some()); + } + + #[test] + fn selector_resolved_leaf_disambiguates_same_subject_issuers_by_signature() { + // Certificate renewal may leave multiple configured intermediates with + // the same subject DN. The leaf signature, not pool order, identifies + // the one issuer that belongs to the verification path. + let mut root_params = + rcgen::CertificateParams::new(Vec::new()).expect("empty root SAN list should be valid"); + root_params + .distinguished_name + .push(rcgen::DnType::CommonName, "shared-issuer root"); + root_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + root_params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign]; + let root = rcgen::CertifiedIssuer::self_signed( + root_params, + rcgen::KeyPair::generate().expect("root key generation should succeed"), + ) + .expect("root certificate should be self-signable"); + + let intermediate = |key: rcgen::KeyPair| { + let mut params = rcgen::CertificateParams::new(Vec::new()) + .expect("empty intermediate SAN list should be valid"); + params + .distinguished_name + .push(rcgen::DnType::CommonName, "renewed intermediate"); + params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign]; + rcgen::CertifiedIssuer::signed_by(params, key, &root) + .expect("root should sign the intermediate certificate") + }; + let unrelated_intermediate = intermediate( + rcgen::KeyPair::generate().expect("unrelated intermediate key generation should work"), + ); + let signing_intermediate = intermediate( + rcgen::KeyPair::generate().expect("signing intermediate key generation should work"), + ); + + let mut leaf_params = + rcgen::CertificateParams::new(Vec::new()).expect("empty leaf SAN list should be valid"); + leaf_params + .distinguished_name + .push(rcgen::DnType::CommonName, "same-subject leaf"); + let leaf = leaf_params + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &signing_intermediate, + ) + .expect("the selected intermediate should sign the leaf certificate"); + let key_info_xml = concat!( + "", + "CN=same-subject leaf", + "" + ); + let document = roxmltree::Document::parse(key_info_xml) + .expect("static selector KeyInfo should parse as XML"); + let key_info = super::super::parse_key_info(document.root_element()) + .expect("static selector KeyInfo should satisfy XMLDSig structure"); + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + lookup_certs: vec![ + leaf.der().to_vec(), + unrelated_intermediate.der().to_vec(), + signing_intermediate.der().to_vec(), + ], + trusted_certs: vec![root.der().to_vec()], + trust: chain_policy(), + ..KeyResolverConfig::default() + }); + + let resolved = resolver + .resolve(Some(&key_info), SignatureAlgorithm::EcdsaP256Sha256) + .expect("the leaf signature should select its unique same-subject issuer"); + + assert!(resolved.is_some()); + } + + #[test] + fn selector_resolved_certificate_preserves_supplied_crls() { + let selector = "CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=USCRL_PLACEHOLDER"; + let crl = crl_der(include_str!( + "../../tests/fixtures/keys/rsa/rsa-2048-cert-revoked-crl.pem" + )); + let xml = replace_unprefixed_key_info( + RSA_KEY_VALUE_SIGNATURE, + &selector.replace("CRL_PLACEHOLDER", &STANDARD.encode(crl)), + ); + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + lookup_certs: vec![certificate_der(include_str!( + "../../tests/fixtures/keys/rsa/rsa-2048-cert.pem" + ))], + trusted_certs: vec![ + certificate_der(include_str!("../../tests/fixtures/keys/ca2cert.pem")), + certificate_der(include_str!("../../tests/fixtures/keys/cacert.pem")), + ], + trust: crate::policy::KeyTrustPolicy { + check_crls: true, + max_x509_chain_depth: 3, + ..chain_policy_at( + SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_773_964_800), + ) + }, + ..KeyResolverConfig::default() + }); + + let error = super::super::VerifyContext::new() + .key_resolver(&resolver) + .verify(&xml) + .expect_err("selector lookup must retain and enforce the supplied CRL"); + assert!(matches!( + error, + DsigError::KeyResolution(KeyResolutionError::Chain( + super::super::X509ChainError::Revoked(0) + )) + )); + } + #[test] fn resolves_each_x509_selector_from_configured_certificates() { // Every selector form documented by KeyInfo must independently locate // the same configured RSA certificate without embedded key material. let selectors = [ - "C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), CN=Test Key rsa-2048", - "C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), OU=Second level CA, CN=Aleksey Sanin, Email=xmlsec@aleksey.com680572598617295163017172295025714171905498632019", + "CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US", + "CN= test key rsa-2048 ,O=xml security library (HTTP://WWW.ALEKSEY.COM/XMLSEC),ST=california,C=us", + "Email=xmlsec@aleksey.com,CN=Aleksey Sanin,OU=Second level CA,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US680572598617295163017172295025714171905498632019", "bcOXN/nsVl8GatRbcKrPbzIbw0Y=", ]; let configured_certificate = certificate_der(include_str!( @@ -708,7 +1488,7 @@ mod tests { let key_info = format!("{selector}"); let xml = replace_unprefixed_key_info(RSA_KEY_VALUE_SIGNATURE, &key_info); let resolver = DefaultKeyResolver::new(KeyResolverConfig { - trusted_certs: vec![configured_certificate.clone()], + lookup_certs: vec![configured_certificate.clone()], ..KeyResolverConfig::default() }); let result = super::super::VerifyContext::new() @@ -724,10 +1504,10 @@ mod tests { fn resolves_configured_chain_selectors_across_certificates() { // Selector categories may identify different members of one configured // chain; the unique leaf remains the signing certificate. - let key_info = r#"C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), CN=Test Key rsa-20480X0XrEVCio75sBcl1TxymJ2IOiU="#; + let key_info = r#"CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US0X0XrEVCio75sBcl1TxymJ2IOiU="#; let xml = replace_unprefixed_key_info(RSA_KEY_VALUE_SIGNATURE, key_info); let resolver = DefaultKeyResolver::new(KeyResolverConfig { - trusted_certs: vec![ + lookup_certs: vec![ certificate_der(include_str!( "../../tests/fixtures/keys/rsa/rsa-2048-cert.pem" )), @@ -749,7 +1529,7 @@ mod tests { let key_info = "CN=not-the-signer"; let xml = replace_unprefixed_key_info(RSA_KEY_VALUE_SIGNATURE, key_info); let resolver = DefaultKeyResolver::new(KeyResolverConfig { - trusted_certs: vec![certificate_der(include_str!( + lookup_certs: vec![certificate_der(include_str!( "../../tests/fixtures/keys/rsa/rsa-2048-cert.pem" ))], ..KeyResolverConfig::default() @@ -770,7 +1550,7 @@ mod tests { // Duplicate configured certificates must not make key selection order-dependent. let certificate = certificate_der(RSA_4096_CERTIFICATE); let resolver = DefaultKeyResolver::new(KeyResolverConfig { - trusted_certs: vec![certificate.clone(), certificate], + lookup_certs: vec![certificate.clone(), certificate], ..KeyResolverConfig::default() }); let error = super::super::VerifyContext::new() @@ -791,7 +1571,7 @@ mod tests { let key_info = "AQ=="; let xml = replace_unprefixed_key_info(RSA_KEY_VALUE_SIGNATURE, key_info); let resolver = DefaultKeyResolver::new(KeyResolverConfig { - trusted_certs: vec![certificate_der(include_str!( + lookup_certs: vec![certificate_der(include_str!( "../../tests/fixtures/keys/rsa/rsa-2048-cert.pem" ))], ..KeyResolverConfig::default() @@ -875,19 +1655,86 @@ mod tests { #[test] fn rsa_key_value_rejects_legacy_weak_modulus() { - // Embedded keys must obey the same 2048-bit minimum as certificate and DER keys. - let resolver = DefaultKeyResolver::default(); + // The secure policy rejects legacy RSA-SHA1 independently of whether + // the capable key came from RSAKeyValue, DER, X.509, or KeyName. + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + trust: crate::policy::KeyTrustPolicy { + allow_legacy_rsa_sha1: true, + ..crate::policy::KeyTrustPolicy::default() + }, + ..KeyResolverConfig::default() + }); let error = super::super::VerifyContext::new() .key_resolver(&resolver) .verify(LEGACY_RSA_KEY_VALUE_SIGNATURE) - .expect_err("1024-bit RSAKeyValue must fail closed"); + .expect_err("context policy must override permissive resolver defaults"); assert!(matches!( error, - DsigError::Crypto(super::super::SignatureVerificationError::InvalidKeyDer) + DsigError::Policy(crate::policy::PolicyViolation::Algorithm { + operation: "verification", + .. + }) )); } + #[test] + fn generic_key_resolution_keeps_legacy_capability_source_independent() { + let certificate = + include_bytes!("../../tests/fixtures/xmldsig/phaos-xmldsig-three/certs/rsa-cert.der") + .to_vec(); + let (_, parsed_certificate) = X509Certificate::from_der(&certificate) + .expect("the Phaos fixture is a DER certificate"); + let public_key = parsed_certificate.public_key().raw.to_vec(); + let rsa_public_key = rsa::RsaPublicKey::from_public_key_der(&public_key) + .expect("the Phaos certificate contains an RSA public key"); + let certificate_metadata = parse_x509_certificate(&certificate) + .expect("the Phaos fixture has supported X.509 metadata"); + let named_key = VerificationKey { + algorithm: SignatureAlgorithm::RsaSha1, + public_key_bytes: public_key.clone(), + certificate_der: None, + name: Some("legacy".into()), + }; + let key_infos = [ + KeyInfo { + sources: vec![KeyInfoSource::KeyName("legacy".into())], + }, + KeyInfo { + sources: vec![KeyInfoSource::DerEncodedKeyValue(public_key.clone())], + }, + KeyInfo { + sources: vec![KeyInfoSource::KeyValue(KeyValueInfo::Rsa { + modulus: rsa_public_key.n().to_be_bytes_trimmed_vartime().to_vec(), + exponent: rsa_public_key.e().to_be_bytes_trimmed_vartime().to_vec(), + })], + }, + KeyInfo { + sources: vec![KeyInfoSource::X509Data(X509DataInfo { + certificates: vec![certificate], + parsed_certificates: vec![certificate_metadata], + certificate_chain: vec![0], + ..X509DataInfo::default() + })], + }, + ]; + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + named_keys: HashMap::from([("legacy".into(), named_key.clone())]), + ..KeyResolverConfig::default() + }); + + for key_info in &key_infos { + let key = resolver + .resolve(Some(key_info), SignatureAlgorithm::RsaSha1) + .expect("the key source is valid") + .expect("key resolution remains independent from operation policy"); + assert!( + !key.verify(SignatureAlgorithm::RsaSha1, b"data", &[0; 128]) + .expect("the legacy RSA key is structurally valid") + ); + } + } + #[test] fn rsa_key_value_rejects_ecdsa_signature_method() { // Embedded RSA parameters must not be relabeled for an ECDSA SignatureMethod. @@ -1187,7 +2034,7 @@ mod tests { fn chain_verification_rejects_untrusted_embedded_certificate() { // Enabling chain policy must fail closed when no trust anchor is configured. let resolver = DefaultKeyResolver::new(KeyResolverConfig { - verify_chains: true, + trust: chain_policy(), ..KeyResolverConfig::default() }); let error = super::super::VerifyContext::new() diff --git a/src/xmldsig/mod.rs b/src/xmldsig/mod.rs index d7b58ec1..5d5bd1d7 100644 --- a/src/xmldsig/mod.rs +++ b/src/xmldsig/mod.rs @@ -68,11 +68,15 @@ pub mod x509; mod xpath; pub use builder::{ReferenceBuilder, SignatureBuilder, SignatureBuilderError}; -pub use digest::{DigestAlgorithm, compute_digest, constant_time_eq}; -pub use keys::{DefaultKeyResolver, KeyResolutionError, KeyResolverConfig, VerificationKey}; +pub use digest::{DigestAlgorithm, compute_digest, compute_digest_with_provider, constant_time_eq}; +pub use keys::{ + DefaultKeyResolver, HmacSha1VerificationKey, KeyResolutionError, KeyResolverConfig, + VerificationKey, +}; pub use parse::{ - KeyInfo, KeyInfoSource, KeyValueInfo, ParseError, Reference, SignatureAlgorithm, SignedInfo, - X509DataInfo, find_signature_node, parse_key_info, parse_reference, parse_signed_info, + KeyInfo, KeyInfoSource, KeyValueInfo, ParseError, Reference, RetrievalMethodTransforms, + SignatureAlgorithm, SignedInfo, X509DataInfo, find_signature_node, parse_key_info, + parse_reference, parse_signed_info, }; pub use sign::{ ComputedReferenceDigest, EcdsaP256SigningKey, EcdsaP384SigningKey, KeyInfoWriteError, @@ -81,8 +85,8 @@ pub use sign::{ compute_reference_digest_values, fill_reference_digest_values, }; pub use signature::{ - SignatureVerificationError, verify_ecdsa_signature_pem, verify_ecdsa_signature_spki, - verify_rsa_signature_pem, verify_rsa_signature_spki, + SignatureVerificationError, verify_dsa_signature_spki, verify_ecdsa_signature_pem, + verify_ecdsa_signature_spki, verify_rsa_signature_pem, verify_rsa_signature_spki, }; pub use transforms::{ BASE64_TRANSFORM_URI, DEFAULT_IMPLICIT_C14N_URI, ENVELOPED_SIGNATURE_URI, Transform, diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index 9d7c8656..18657d06 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -16,10 +16,14 @@ //! //! ``` +use der::Decode; use roxmltree::{Document, Node}; +use x509_cert::ext::pkix::name::DirectoryString; +use x509_cert::name::Name; use x509_parser::extensions::ParsedExtension; use x509_parser::prelude::FromDer; use x509_parser::public_key::PublicKey; +use x509_parser::x509::X509Name; use super::digest::{DigestAlgorithm, compute_digest, constant_time_eq}; use super::transforms::{self, Transform}; @@ -27,7 +31,9 @@ use super::whitespace::{ XmlBase64NormalizeLimitedError, is_xml_whitespace_only, normalize_xml_base64_text, normalize_xml_base64_text_with_limit, }; +use super::x509::certificate_signature_matches; use crate::c14n::C14nAlgorithm; +use crate::c14n::xml_base::{compute_effective_xml_base, resolve_uri}; /// XMLDSig namespace URI. pub(crate) const XMLDSIG_NS: &str = "http://www.w3.org/2000/09/xmldsig#"; @@ -37,6 +43,9 @@ const MAX_DER_ENCODED_KEY_VALUE_LEN: usize = 8192; const MAX_DER_ENCODED_KEY_VALUE_TEXT_LEN: usize = 65_536; const MAX_DER_ENCODED_KEY_VALUE_BASE64_LEN: usize = MAX_DER_ENCODED_KEY_VALUE_LEN.div_ceil(3) * 4; const MAX_KEY_NAME_TEXT_LEN: usize = 4096; +const MAX_KEY_INFO_CHILD_COUNT: usize = 64; +const MAX_HMAC_OUTPUT_LENGTH_TEXT_LEN: usize = 32; +const MAX_RETRIEVAL_XPATH_TEXT_LEN: usize = 256; const MAX_RSA_MODULUS_LEN: usize = 1024; const MAX_RSA_EXPONENT_LEN: usize = 8; pub(crate) const EC_P256_OID: &str = "1.2.840.10045.3.1.7"; @@ -44,18 +53,28 @@ pub(crate) const EC_P384_OID: &str = "1.3.132.0.34"; const MAX_EC_PUBLIC_KEY_LEN: usize = 97; const MAX_X509_BASE64_TEXT_LEN: usize = 262_144; const MAX_X509_BASE64_NORMALIZED_LEN: usize = MAX_X509_BASE64_TEXT_LEN; -const MAX_X509_DECODED_BINARY_LEN: usize = MAX_X509_BASE64_NORMALIZED_LEN.div_ceil(4) * 3; +pub(crate) const MAX_X509_DECODED_BINARY_LEN: usize = + MAX_X509_BASE64_NORMALIZED_LEN.div_ceil(4) * 3; const MAX_X509_SUBJECT_NAME_TEXT_LEN: usize = 16_384; const MAX_X509_ISSUER_NAME_TEXT_LEN: usize = 16_384; -const MAX_X509_SERIAL_NUMBER_TEXT_LEN: usize = 4096; +const MAX_X509_SERIAL_NUMBER_RAW_TEXT_LEN: usize = 16_384; +// RFC 5280 permits at most 20 DER content octets for a positive certificate +// serial number. The sign bit leaves 159 value bits, or at most 49 significant +// decimal digits; XML Schema permits insignificant leading zeroes. +const MAX_X509_SERIAL_NUMBER_VALUE_DIGITS: usize = 49; +const MAX_X509_SERIAL_NUMBER_BYTES: usize = 20; const MAX_X509_DATA_ENTRY_COUNT: usize = 64; -const MAX_X509_DATA_TOTAL_BINARY_LEN: usize = 1_048_576; +pub(crate) const MAX_X509_DATA_TOTAL_BINARY_LEN: usize = 1_048_576; const MAX_X509_CHAIN_DEPTH: usize = 9; pub(crate) const MAX_REFERENCES_PER_SIGNATURE: usize = 64; /// Signature algorithms supported for signing and verification. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum SignatureAlgorithm { + /// DSA with SHA-1. Verify-only legacy XMLDSig algorithm. + DsaSha1, + /// HMAC with SHA-1. Verify-only legacy XMLDSig algorithm. + HmacSha1, /// RSA with SHA-1. **Verify-only** — signing disabled. RsaSha1, /// RSA with SHA-256 (most common in SAML). @@ -80,6 +99,8 @@ impl SignatureAlgorithm { #[must_use] pub fn from_uri(uri: &str) -> Option { match uri { + "http://www.w3.org/2000/09/xmldsig#dsa-sha1" => Some(Self::DsaSha1), + "http://www.w3.org/2000/09/xmldsig#hmac-sha1" => Some(Self::HmacSha1), "http://www.w3.org/2000/09/xmldsig#rsa-sha1" => Some(Self::RsaSha1), "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" => Some(Self::RsaSha256), "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384" => Some(Self::RsaSha384), @@ -94,6 +115,8 @@ impl SignatureAlgorithm { #[must_use] pub fn uri(self) -> &'static str { match self { + Self::DsaSha1 => "http://www.w3.org/2000/09/xmldsig#dsa-sha1", + Self::HmacSha1 => "http://www.w3.org/2000/09/xmldsig#hmac-sha1", Self::RsaSha1 => "http://www.w3.org/2000/09/xmldsig#rsa-sha1", Self::RsaSha256 => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", Self::RsaSha384 => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384", @@ -106,7 +129,7 @@ impl SignatureAlgorithm { /// Whether this algorithm is allowed for signing (not just verification). #[must_use] pub fn signing_allowed(self) -> bool { - !matches!(self, Self::RsaSha1) + !matches!(self, Self::RsaSha1 | Self::DsaSha1 | Self::HmacSha1) } } @@ -117,6 +140,8 @@ pub struct SignedInfo { pub c14n_method: C14nAlgorithm, /// Signature algorithm. pub signature_method: SignatureAlgorithm, + /// Optional byte-aligned HMAC output length in bits. + pub hmac_output_length_bits: Option, /// One or more `` elements. pub references: Vec, } @@ -158,12 +183,46 @@ pub enum KeyInfoSource { X509Data(X509DataInfo), /// `dsig11:DEREncodedKeyValue` source (base64-decoded DER bytes). DerEncodedKeyValue(Vec), + /// `` URI and optional type URI. + RetrievalMethod { + /// Resource URI. + uri: String, + /// Declared resource type. + resource_type: Option, + /// Supported transform shape declared by the retrieval method. + transforms: RetrievalMethodTransforms, + }, +} + +/// Transform forms accepted on ``. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum RetrievalMethodTransforms { + /// No transform chain is present. + None, + /// Filter a same-document node-set to one `ds:X509Data`-rooted subtree. + X509DataNodeSetFilter, + /// A transform chain attached to a RetrievalMethod type this implementation + /// does not materialize. Resolvers may ignore this advisory key source and + /// continue with later `` children. + Unsupported, } /// Parsed `` dispatch result. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum KeyValueInfo { + /// `` public parameters. + Dsa { + /// Optional prime modulus P, present only together with Q. + p: Option>, + /// Optional prime divisor Q, present only together with P. + q: Option>, + /// Optional generator G. + g: Option>, + /// Public value Y. + y: Vec, + }, /// `` with unsigned big-endian CryptoBinary parameters. Rsa { /// RSA modulus. @@ -368,6 +427,7 @@ pub(crate) fn parse_signed_info_with_xpath_budget( SignatureAlgorithm::from_uri(sig_uri).ok_or_else(|| ParseError::UnsupportedAlgorithm { uri: sig_uri.to_string(), })?; + let hmac_output_length_bits = parse_hmac_output_length(sig_method_node, signature_method)?; // 3. One or more Reference elements let mut references = Vec::new(); @@ -389,10 +449,47 @@ pub(crate) fn parse_signed_info_with_xpath_budget( Ok(SignedInfo { c14n_method, signature_method, + hmac_output_length_bits, references, }) } +fn parse_hmac_output_length( + node: Node<'_, '_>, + algorithm: SignatureAlgorithm, +) -> Result, ParseError> { + ensure_no_non_whitespace_text(node, "SignatureMethod")?; + let mut children = element_children(node); + let Some(child) = children.next() else { + return Ok(None); + }; + if algorithm != SignatureAlgorithm::HmacSha1 + || child.tag_name().namespace() != Some(XMLDSIG_NS) + || child.tag_name().name() != "HMACOutputLength" + || children.next().is_some() + { + return Err(ParseError::InvalidStructure( + "SignatureMethod parameters do not match the selected algorithm".into(), + )); + } + ensure_no_element_children(child, "HMACOutputLength")?; + let text = + collect_text_content_bounded(child, MAX_HMAC_OUTPUT_LENGTH_TEXT_LEN, "HMACOutputLength")?; + let bits = text + .trim() + .parse::() + .map_err(|_| ParseError::InvalidStructure("invalid HMACOutputLength".into()))?; + // XMLDSig 1.1 section 6.3.1 requires HMAC truncation to end on a + // byte boundary because SignatureValue is encoded as complete octets: + // https://www.w3.org/TR/xmldsig-core1/#sec-HMAC + if !(80..=160).contains(&bits) || !bits.is_multiple_of(8) { + return Err(ParseError::InvalidStructure( + "HMACOutputLength must be a byte-aligned value from 80 through 160".into(), + )); + } + Ok(Some(bits)) +} + /// Parse a single `` element. /// /// Structure: `?` → `` → `` @@ -417,20 +514,19 @@ pub(crate) fn parse_reference_with_xpath_budget( // Optional let mut transforms = Vec::new(); - let mut next = children.next().ok_or(ParseError::MissingElement { - element: "DigestMethod", - })?; - - if next.tag_name().name() == "Transforms" && next.tag_name().namespace() == Some(XMLDSIG_NS) { - transforms = transforms::parse_transforms_with_budget(next, xpath_budget)?; - next = children.next().ok_or(ParseError::MissingElement { - element: "DigestMethod", - })?; + let mut transform_error = None; + let (transforms_node, digest_method_node) = + reference_transforms_and_digest_method(&mut children)?; + + if let Some(transforms_node) = transforms_node { + match transforms::parse_transforms_with_budget(transforms_node, xpath_budget) { + Ok(parsed) => transforms = parsed, + Err(error) => transform_error = Some(error), + } } // Required - verify_ds_element(next, "DigestMethod")?; - let digest_uri = required_algorithm_attr(next, "DigestMethod")?; + let digest_uri = required_algorithm_attr(digest_method_node, "DigestMethod")?; let digest_method = DigestAlgorithm::from_uri(digest_uri).ok_or_else(|| ParseError::UnsupportedAlgorithm { uri: digest_uri.to_string(), @@ -451,6 +547,13 @@ pub(crate) fn parse_reference_with_xpath_budget( ))); } + // Validate the complete Reference before reporting an unsupported transform. + // This prevents malformed DigestMethod/DigestValue content from being + // downgraded to a non-fatal unsupported Manifest transform result. + if let Some(error) = transform_error { + return Err(ParseError::Transform(error)); + } + Ok(Reference { uri, id, @@ -461,6 +564,36 @@ pub(crate) fn parse_reference_with_xpath_budget( }) } +pub(crate) fn reference_digest_method( + reference_node: Node<'_, '_>, +) -> Result { + verify_ds_element(reference_node, "Reference")?; + let mut children = element_children(reference_node); + let (_, digest_method_node) = reference_transforms_and_digest_method(&mut children)?; + let uri = required_algorithm_attr(digest_method_node, "DigestMethod")?; + DigestAlgorithm::from_uri(uri).ok_or_else(|| ParseError::UnsupportedAlgorithm { + uri: uri.to_owned(), + }) +} + +fn reference_transforms_and_digest_method<'a, 'input>( + children: &mut impl Iterator>, +) -> Result<(Option>, Node<'a, 'input>), ParseError> { + let first = children.next().ok_or(ParseError::MissingElement { + element: "DigestMethod", + })?; + let transforms_node = is_ds_element(first, "Transforms").then_some(first); + let digest_method_node = if transforms_node.is_some() { + children.next().ok_or(ParseError::MissingElement { + element: "DigestMethod", + })? + } else { + first + }; + verify_ds_element(digest_method_node, "DigestMethod")?; + Ok((transforms_node, digest_method_node)) +} + /// Parse `` and dispatch supported child sources. /// /// Supported source elements: @@ -478,7 +611,13 @@ pub fn parse_key_info(key_info_node: Node) -> Result { ensure_no_non_whitespace_text(key_info_node, "KeyInfo")?; let mut sources = Vec::new(); - for child in element_children(key_info_node) { + let mut x509_total_binary_len = 0usize; + for (index, child) in element_children(key_info_node).enumerate() { + if index >= MAX_KEY_INFO_CHILD_COUNT { + return Err(ParseError::InvalidStructure( + "KeyInfo contains too many child elements".into(), + )); + } match (child.tag_name().namespace(), child.tag_name().name()) { (Some(XMLDSIG_NS), "KeyName") => { ensure_no_element_children(child, "KeyName")?; @@ -491,9 +630,44 @@ pub fn parse_key_info(key_info_node: Node) -> Result { sources.push(KeyInfoSource::KeyValue(key_value)); } (Some(XMLDSIG_NS), "X509Data") => { - let x509 = parse_x509_data_dispatch(child)?; + let x509 = parse_x509_data_dispatch_with_budget(child, &mut x509_total_binary_len)?; sources.push(KeyInfoSource::X509Data(x509)); } + (Some(XMLDSIG_NS), "RetrievalMethod") => { + ensure_no_non_whitespace_text(child, "RetrievalMethod")?; + let lexical_uri = child.attribute("URI").ok_or_else(|| { + ParseError::InvalidStructure("RetrievalMethod requires URI".into()) + })?; + if lexical_uri.len() > MAX_KEY_NAME_TEXT_LEN { + return Err(ParseError::InvalidStructure( + "RetrievalMethod URI exceeds maximum length".into(), + )); + } + let uri = if lexical_uri.is_empty() || lexical_uri.starts_with('#') { + lexical_uri.to_owned() + } else { + // RetrievalMethod is parsed independently from later key + // materialization, so retain its resolved resource identity. + compute_effective_xml_base(child, None) + .map(|base| resolve_uri(&base, lexical_uri)) + .unwrap_or_else(|| lexical_uri.to_owned()) + }; + let resource_type = child.attribute("Type").map(str::to_string); + let transforms = if resource_type.as_deref() + == Some("http://www.w3.org/2000/09/xmldsig#X509Data") + { + parse_retrieval_method_transforms(child)? + } else if element_children(child).next().is_some() { + RetrievalMethodTransforms::Unsupported + } else { + RetrievalMethodTransforms::None + }; + sources.push(KeyInfoSource::RetrievalMethod { + uri, + resource_type, + transforms, + }); + } (Some(XMLDSIG11_NS), "DEREncodedKeyValue") => { ensure_no_element_children(child, "DEREncodedKeyValue")?; let der = decode_der_encoded_key_value_base64(child)?; @@ -506,6 +680,66 @@ pub fn parse_key_info(key_info_node: Node) -> Result { Ok(KeyInfo { sources }) } +fn parse_retrieval_method_transforms( + node: Node<'_, '_>, +) -> Result { + let mut children = element_children(node); + let Some(transforms) = children.next() else { + return Ok(RetrievalMethodTransforms::None); + }; + if children.next().is_some() + || transforms.tag_name().namespace() != Some(XMLDSIG_NS) + || transforms.tag_name().name() != "Transforms" + { + return Err(ParseError::InvalidStructure( + "RetrievalMethod accepts only one optional ds:Transforms child".into(), + )); + } + ensure_no_non_whitespace_text(transforms, "Transforms")?; + let mut transform_children = element_children(transforms); + let transform = transform_children.next().ok_or_else(|| { + ParseError::InvalidStructure("RetrievalMethod Transforms must not be empty".into()) + })?; + if transform_children.next().is_some() + || transform.tag_name().namespace() != Some(XMLDSIG_NS) + || transform.tag_name().name() != "Transform" + || transform.attribute("Algorithm") != Some(transforms::XPATH_TRANSFORM_URI) + { + return Err(ParseError::InvalidStructure( + "unsupported RetrievalMethod transform chain".into(), + )); + } + ensure_no_non_whitespace_text(transform, "Transform")?; + let mut parameters = element_children(transform); + let xpath = parameters.next().ok_or_else(|| { + ParseError::InvalidStructure("RetrievalMethod XPath parameter is missing".into()) + })?; + if parameters.next().is_some() + || xpath.tag_name().namespace() != Some(XMLDSIG_NS) + || xpath.tag_name().name() != "XPath" + { + return Err(ParseError::InvalidStructure( + "unsupported RetrievalMethod transform chain".into(), + )); + } + ensure_no_element_children(xpath, "XPath")?; + let expression = + collect_text_content_bounded(xpath, MAX_RETRIEVAL_XPATH_TEXT_LEN, "RetrievalMethod XPath")?; + let expression = expression.trim(); + let selects_x509_data = expression + .strip_prefix("ancestor-or-self::") + .and_then(|step| step.split_once(':')) + .is_some_and(|(prefix, local)| { + local == "X509Data" && xpath.lookup_namespace_uri(Some(prefix)) == Some(XMLDSIG_NS) + }); + if !selects_x509_data { + return Err(ParseError::InvalidStructure( + "unsupported RetrievalMethod XPath selection".into(), + )); + } + Ok(RetrievalMethodTransforms::X509DataNodeSetFilter) +} + // ── Helpers ────────────────────────────────────────────────────────────────── /// Iterate only element children (skip text, comments, PIs). @@ -613,6 +847,7 @@ fn parse_key_value_dispatch(node: Node) -> Result { first_child.tag_name().name(), ) { (Some(XMLDSIG_NS), "RSAKeyValue") => parse_rsa_key_value(first_child), + (Some(XMLDSIG_NS), "DSAKeyValue") => parse_dsa_key_value(first_child), (Some(XMLDSIG11_NS), "ECKeyValue") => parse_ec_key_value(first_child), (namespace, child_name) => Ok(KeyValueInfo::Unsupported { namespace: namespace.map(str::to_string), @@ -621,6 +856,57 @@ fn parse_key_value_dispatch(node: Node) -> Result { } } +fn parse_dsa_key_value(node: Node<'_, '_>) -> Result { + verify_ds_element(node, "DSAKeyValue")?; + ensure_no_non_whitespace_text(node, "DSAKeyValue")?; + let children = element_children(node).collect::>(); + let mut index = 0; + let p = take_dsa_crypto_binary(&children, &mut index, "P")?; + let q = take_dsa_crypto_binary(&children, &mut index, "Q")?; + if p.is_some() != q.is_some() { + return Err(ParseError::InvalidStructure( + "DSAKeyValue P and Q must be present together".into(), + )); + } + let g = take_dsa_crypto_binary(&children, &mut index, "G")?; + let y = take_dsa_crypto_binary(&children, &mut index, "Y")? + .ok_or_else(|| ParseError::InvalidStructure("DSAKeyValue requires Y".into()))?; + let _j = take_dsa_crypto_binary(&children, &mut index, "J")?; + let seed = take_dsa_crypto_binary(&children, &mut index, "Seed")?; + let counter = take_dsa_crypto_binary(&children, &mut index, "PgenCounter")?; + if seed.is_some() != counter.is_some() { + return Err(ParseError::InvalidStructure( + "DSAKeyValue Seed and PgenCounter must be present together".into(), + )); + } + if index != children.len() { + return Err(ParseError::InvalidStructure( + "DSAKeyValue children do not match the XMLDSig schema order".into(), + )); + } + Ok(KeyValueInfo::Dsa { p, q, g, y }) +} + +fn take_dsa_crypto_binary( + children: &[Node<'_, '_>], + index: &mut usize, + name: &'static str, +) -> Result>, ParseError> { + let Some(&child) = children.get(*index) else { + return Ok(None); + }; + if !is_ds_element(child, name) { + return Ok(None); + } + *index += 1; + ensure_no_element_children(child, name)?; + decode_crypto_binary(child, name, MAX_RSA_MODULUS_LEN).map(Some) +} + +fn is_ds_element(node: Node<'_, '_>, name: &str) -> bool { + node.tag_name().namespace() == Some(XMLDSIG_NS) && node.tag_name().name() == name +} + fn parse_ec_key_value(node: Node<'_, '_>) -> Result { verify_dsig11_element(node, "ECKeyValue")?; ensure_no_non_whitespace_text(node, "ECKeyValue")?; @@ -760,7 +1046,11 @@ fn decode_crypto_binary( let max_base64_len = max_decoded_len.div_ceil(3) * 4; let mut cleaned = String::with_capacity(max_base64_len); - for text in node.children().filter_map(|child| child.text()) { + for text in node + .children() + .filter(|child| child.is_text()) + .filter_map(|child| child.text()) + { normalize_xml_base64_text_with_limit(text, &mut cleaned, max_base64_len).map_err( |err| match err { XmlBase64NormalizeLimitedError::InvalidWhitespace(err) => { @@ -792,19 +1082,21 @@ fn decode_crypto_binary( Ok(value) } -fn parse_x509_data_dispatch(node: Node) -> Result { +pub(crate) fn parse_x509_data_dispatch_with_budget( + node: Node, + total_binary_len: &mut usize, +) -> Result { verify_ds_element(node, "X509Data")?; ensure_no_non_whitespace_text(node, "X509Data")?; let mut info = X509DataInfo::default(); - let mut total_binary_len = 0usize; for child in element_children(node) { match (child.tag_name().namespace(), child.tag_name().name()) { (Some(XMLDSIG_NS), "X509Certificate") => { ensure_no_element_children(child, "X509Certificate")?; ensure_x509_data_entry_budget(&info)?; let cert = decode_x509_base64(child, "X509Certificate")?; - add_x509_data_usage(&mut total_binary_len, cert.len())?; + add_x509_data_usage(total_binary_len, cert.len())?; let parsed_cert = parse_x509_certificate(cert.as_slice())?; info.parsed_certificates.push(parsed_cert); info.certificates.push(cert); @@ -828,14 +1120,14 @@ fn parse_x509_data_dispatch(node: Node) -> Result { ensure_no_element_children(child, "X509SKI")?; ensure_x509_data_entry_budget(&info)?; let ski = decode_x509_base64(child, "X509SKI")?; - add_x509_data_usage(&mut total_binary_len, ski.len())?; + add_x509_data_usage(total_binary_len, ski.len())?; info.skis.push(ski); } (Some(XMLDSIG_NS), "X509CRL") => { ensure_no_element_children(child, "X509CRL")?; ensure_x509_data_entry_budget(&info)?; let crl = decode_x509_base64(child, "X509CRL")?; - add_x509_data_usage(&mut total_binary_len, crl.len())?; + add_x509_data_usage(total_binary_len, crl.len())?; info.crls.push(crl); } (Some(XMLDSIG11_NS), "X509Digest") => { @@ -843,7 +1135,7 @@ fn parse_x509_data_dispatch(node: Node) -> Result { ensure_x509_data_entry_budget(&info)?; let algorithm = required_algorithm_attr(child, "X509Digest")?; let digest = decode_x509_base64(child, "X509Digest")?; - add_x509_data_usage(&mut total_binary_len, digest.len())?; + add_x509_data_usage(total_binary_len, digest.len())?; info.digests.push((algorithm.to_string(), digest)); } (Some(XMLDSIG_NS), child_name) | (Some(XMLDSIG11_NS), child_name) => { @@ -865,20 +1157,57 @@ fn build_x509_certificate_chain(info: &X509DataInfo) -> Result, Parse } let signing_idx = select_x509_signing_certificate(info)?; + build_x509_certificate_chain_from(info, signing_idx).map_err(ParseError::from) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum X509ChainBuildError { + InconsistentMetadata, + DepthExceeded, + Cycle, + IssuerSignatureMismatch, + AmbiguousIssuer, +} + +impl From for ParseError { + fn from(error: X509ChainBuildError) -> Self { + let reason = match error { + X509ChainBuildError::InconsistentMetadata => { + "X509Data certificate metadata is inconsistent" + } + X509ChainBuildError::DepthExceeded => { + "X509Data certificate chain exceeds maximum depth" + } + X509ChainBuildError::Cycle => "X509Data certificate chain contains a cycle", + X509ChainBuildError::IssuerSignatureMismatch => { + "X509Data issuer candidates do not verify the certificate signature" + } + X509ChainBuildError::AmbiguousIssuer => { + "X509Data certificate chain contains ambiguous issuer certificates" + } + }; + Self::InvalidStructure(reason.into()) + } +} + +/// Order an available certificate pool from a preselected signing certificate. +pub(crate) fn build_x509_certificate_chain_from( + info: &X509DataInfo, + signing_idx: usize, +) -> Result, X509ChainBuildError> { + if signing_idx >= info.parsed_certificates.len() + || info.parsed_certificates.len() != info.certificates.len() + { + return Err(X509ChainBuildError::InconsistentMetadata); + } let mut chain = vec![signing_idx]; loop { - if chain.len() > MAX_X509_CHAIN_DEPTH { - return Err(ParseError::InvalidStructure( - "X509Data certificate chain exceeds maximum depth".into(), - )); - } - let current_idx = *chain .last() .expect("chain starts with signing certificate index"); let current = &info.parsed_certificates[current_idx]; - if current.subject_dn == current.issuer_dn { + if distinguished_names_equal(¤t.subject_dn, ¤t.issuer_dn) { break; } @@ -886,36 +1215,121 @@ fn build_x509_certificate_chain(info: &X509DataInfo) -> Result, Parse .parsed_certificates .iter() .enumerate() - .filter(|(idx, cert)| *idx != current_idx && cert.subject_dn == current.issuer_dn) + .filter(|(idx, cert)| { + *idx != current_idx + && distinguished_names_equal(&cert.subject_dn, ¤t.issuer_dn) + }) .map(|(idx, _)| idx) .collect::>(); - match candidates.as_slice() { + let issuer_idx = match candidates.as_slice() { [] => break, - [issuer_idx] => { - if chain.contains(issuer_idx) { - return Err(ParseError::InvalidStructure( - "X509Data certificate chain contains a cycle".into(), - )); - } - if chain.len() == MAX_X509_CHAIN_DEPTH { - return Err(ParseError::InvalidStructure( - "X509Data certificate chain exceeds maximum depth".into(), - )); - } - chain.push(*issuer_idx); - } + [issuer_idx] => *issuer_idx, _ => { - return Err(ParseError::InvalidStructure( - "X509Data certificate chain contains ambiguous issuer certificates".into(), - )); + let verified = candidates + .into_iter() + .filter(|issuer_idx| { + certificate_signature_matches( + &info.certificates[current_idx], + &info.certificates[*issuer_idx], + ) + }) + .collect::>(); + match verified.as_slice() { + [issuer_idx] => *issuer_idx, + [] => return Err(X509ChainBuildError::IssuerSignatureMismatch), + _ => return Err(X509ChainBuildError::AmbiguousIssuer), + } } + }; + if chain.contains(&issuer_idx) { + return Err(X509ChainBuildError::Cycle); } + if chain.len() == MAX_X509_CHAIN_DEPTH { + return Err(X509ChainBuildError::DepthExceeded); + } + chain.push(issuer_idx); } Ok(chain) } +/// Enumerate signature-valid certificate paths that terminate at a certificate +/// in the trusted prefix. Trust and certificate policy are intentionally not +/// assigned here; callers must fully validate every returned candidate. +pub(crate) fn build_x509_certificate_paths_to_trusted_prefix( + info: &X509DataInfo, + signing_idx: usize, + trusted_prefix_len: usize, + max_depth: usize, + max_candidate_paths: usize, +) -> Result>, X509ChainBuildError> { + if signing_idx >= info.parsed_certificates.len() + || info.parsed_certificates.len() != info.certificates.len() + || trusted_prefix_len > info.certificates.len() + { + return Err(X509ChainBuildError::InconsistentMetadata); + } + + let mut pending = vec![vec![signing_idx]]; + let mut completed = Vec::new(); + let mut depth_exceeded = false; + let mut issuer_cache = vec![None; info.parsed_certificates.len()]; + while let Some(path) = pending.pop() { + let current_idx = *path + .last() + .expect("candidate path starts with signing certificate index"); + if current_idx < trusted_prefix_len { + completed.push(path); + if completed.len() > max_candidate_paths { + return Err(X509ChainBuildError::AmbiguousIssuer); + } + continue; + } + if path.len() == max_depth { + depth_exceeded = true; + continue; + } + + let current = &info.parsed_certificates[current_idx]; + if distinguished_names_equal(¤t.subject_dn, ¤t.issuer_dn) { + continue; + } + let issuers = issuer_cache[current_idx].get_or_insert_with(|| { + info.parsed_certificates + .iter() + .enumerate() + .filter(|(issuer_idx, issuer)| { + distinguished_names_equal(&issuer.subject_dn, ¤t.issuer_dn) + && certificate_signature_matches( + &info.certificates[current_idx], + &info.certificates[*issuer_idx], + ) + }) + .map(|(issuer_idx, _)| issuer_idx) + .collect::>() + }); + let issuers = issuers + .iter() + .copied() + .filter(|issuer_idx| !path.contains(issuer_idx)) + .collect::>(); + if pending.len().saturating_add(issuers.len()) > max_candidate_paths { + return Err(X509ChainBuildError::AmbiguousIssuer); + } + for issuer_idx in issuers { + let mut candidate = path.clone(); + candidate.push(issuer_idx); + pending.push(candidate); + } + } + + if completed.is_empty() && depth_exceeded { + return Err(X509ChainBuildError::DepthExceeded); + } + Ok(completed) +} + fn select_x509_signing_certificate(info: &X509DataInfo) -> Result { let has_lookup_identifiers = x509_data_has_lookup_identifiers(info); let mut candidates = Vec::new(); @@ -953,11 +1367,11 @@ fn select_x509_signing_certificate(info: &X509DataInfo) -> Result>(); @@ -1001,7 +1415,7 @@ pub(crate) fn x509_certificate_matches_any_selector( let subject_match = info .subject_names .iter() - .any(|subject| subject.trim() == certificate.subject_dn); + .any(|subject| distinguished_names_equal(subject, &certificate.subject_dn)); let mut issuer_serial_match = false; for (issuer, serial) in &info.issuer_serials { let serial_hex = x509_serial_decimal_to_hex(serial).ok_or_else(|| { @@ -1009,8 +1423,8 @@ pub(crate) fn x509_certificate_matches_any_selector( "X509Data lookup identifiers contain an invalid serial number".into(), ) })?; - issuer_serial_match |= - issuer.trim() == certificate.issuer_dn && serial_hex == certificate.serial_number_hex; + issuer_serial_match |= distinguished_names_equal(issuer, &certificate.issuer_dn) + && serial_hex == certificate.serial_number_hex; } let ski_match = certificate .subject_key_identifier @@ -1034,7 +1448,7 @@ pub(crate) fn x509_selector_categories_match_chain( let subject_match = info.subject_names.iter().all(|subject| { info.parsed_certificates .iter() - .any(|certificate| subject.trim() == certificate.subject_dn) + .any(|certificate| distinguished_names_equal(subject, &certificate.subject_dn)) }); let mut issuer_serial_match = true; @@ -1045,7 +1459,8 @@ pub(crate) fn x509_selector_categories_match_chain( ) })?; issuer_serial_match &= info.parsed_certificates.iter().any(|certificate| { - issuer.trim() == certificate.issuer_dn && serial_hex == certificate.serial_number_hex + distinguished_names_equal(issuer, &certificate.issuer_dn) + && serial_hex == certificate.serial_number_hex }); } @@ -1074,6 +1489,136 @@ pub(crate) fn x509_selector_categories_match_chain( Ok(subject_match && issuer_serial_match && ski_match && digest_match) } +pub(crate) fn distinguished_names_equal(left: &str, right: &str) -> bool { + fn attribute_values_equal( + left: &x509_cert::attr::AttributeTypeAndValue, + right: &x509_cert::attr::AttributeTypeAndValue, + ) -> bool { + if left.oid != right.oid { + return false; + } + match ( + DirectoryString::try_from(&left.value), + DirectoryString::try_from(&right.value), + ) { + (Ok(left), Ok(right)) => { + // RFC 5280 section 7.1 requires caseIgnoreMatch with LDAP/X.520 + // string preparation for PrintableString and UTF8String names. + let Ok(left) = + x520_stringprep::x520_stringprep_to_case_ignore_string(left.value().as_ref()) + else { + return false; + }; + let Ok(right) = + x520_stringprep::x520_stringprep_to_case_ignore_string(right.value().as_ref()) + else { + return false; + }; + left.trim_matches(' ') == right.trim_matches(' ') + } + _ => left.value == right.value, + } + } + + fn rdns_equal( + left: &x509_cert::name::RelativeDistinguishedName, + right: &x509_cert::name::RelativeDistinguishedName, + ) -> bool { + if left.len() != right.len() { + return false; + } + // A DN is an ordered RDN sequence, but each individual RDN is a set. + let right = right.iter().collect::>(); + let mut matched = vec![false; right.len()]; + left.iter().all(|left_attribute| { + right + .iter() + .enumerate() + .find(|(index, right_attribute)| { + !matched[*index] && attribute_values_equal(left_attribute, right_attribute) + }) + .is_some_and(|(index, _)| { + matched[index] = true; + true + }) + }) + } + + fn trailing_whitespace_is_escaped(value: &str) -> bool { + let Some(prefix) = value.as_bytes().strip_suffix(b" ") else { + return false; + }; + prefix + .iter() + .rev() + .take_while(|byte| **byte == b'\\') + .count() + % 2 + == 1 + } + + fn remove_separator_padding(name: &str) -> String { + let mut normalized = String::with_capacity(name.len()); + let mut chars = name + .trim_start_matches([' ', '\t', '\r', '\n']) + .chars() + .peekable(); + let mut escaped = false; + + while let Some(ch) = chars.next() { + if escaped { + normalized.push(ch); + escaped = false; + continue; + } + if ch == '\\' { + normalized.push(ch); + escaped = true; + continue; + } + if matches!(ch, ',' | '+') { + while normalized + .chars() + .next_back() + .is_some_and(|last| matches!(last, ' ' | '\t' | '\r' | '\n')) + && !trailing_whitespace_is_escaped(&normalized) + { + normalized.pop(); + } + normalized.push(ch); + while chars + .next_if(|next| matches!(next, ' ' | '\t' | '\r' | '\n')) + .is_some() + {} + continue; + } + normalized.push(ch); + } + + while normalized + .chars() + .next_back() + .is_some_and(|last| matches!(last, ' ' | '\t' | '\r' | '\n')) + && !trailing_whitespace_is_escaped(&normalized) + { + normalized.pop(); + } + + normalized + } + + let parse_name = |value: &str| remove_separator_padding(value).parse::().ok(); + parse_name(left) + .zip(parse_name(right)) + .is_some_and(|(left, right)| { + left.len() == right.len() + && left + .iter_rdn() + .zip(right.iter_rdn()) + .all(|(left, right)| rdns_equal(left, right)) + }) +} + fn ensure_x509_data_entry_budget(info: &X509DataInfo) -> Result<(), ParseError> { let total_entries = info.certificates.len() + info.subject_names.len() @@ -1161,8 +1706,12 @@ pub(crate) fn parse_x509_certificate(cert_der: &[u8]) -> Result Result) -> Result { + let name = Name::from_der(name.as_raw()).map_err(|error| { + ParseError::InvalidStructure(format!( + "X509Certificate distinguished name is invalid DER: {error}" + )) + })?; + Ok(name.to_string()) +} + fn format_x509_serial_hex(serial: &[u8]) -> String { serial .iter() @@ -1243,11 +1801,15 @@ fn format_x509_serial_value_hex(serial: &[u8]) -> String { fn x509_serial_decimal_to_hex(serial: &str) -> Option { let serial = serial.trim(); let serial = serial.strip_prefix('+').unwrap_or(serial); - if serial.is_empty() || !serial.bytes().all(|byte| byte.is_ascii_digit()) { + let serial = serial.trim_start_matches('0'); + let serial = if serial.is_empty() { "0" } else { serial }; + if serial.len() > MAX_X509_SERIAL_NUMBER_VALUE_DIGITS + || !serial.bytes().all(|byte| byte.is_ascii_digit()) + { return None; } - let mut bytes = Vec::::new(); + let mut bytes = [0_u8; MAX_X509_SERIAL_NUMBER_BYTES]; for digit in serial.bytes().map(|byte| byte - b'0') { let mut carry = u16::from(digit); for byte in bytes.iter_mut().rev() { @@ -1255,12 +1817,20 @@ fn x509_serial_decimal_to_hex(serial: &str) -> Option { *byte = value as u8; carry = value >> 8; } - while carry > 0 { - bytes.insert(0, carry as u8); - carry >>= 8; + if carry != 0 { + return None; } } + // DER INTEGER is signed, so a positive 20-octet serial must keep its high + // bit clear. Values requiring a 21st sign-extension octet exceed RFC 5280. + if bytes[0] & 0x80 != 0 { + return None; + } + if bytes.iter().all(|byte| *byte == 0) { + return None; + } + Some(format_x509_serial_value_hex(&bytes)) } @@ -1312,12 +1882,8 @@ fn parse_x509_issuer_serial(node: Node<'_, '_>) -> Result<(String, String), Pars let serial_node = children[1]; ensure_no_element_children(serial_node, "X509SerialNumber")?; - let serial_number = collect_text_content_bounded( - serial_node, - MAX_X509_SERIAL_NUMBER_TEXT_LEN, - "X509SerialNumber", - )?; - if issuer_name.trim().is_empty() || serial_number.trim().is_empty() { + let serial_number = collect_x509_serial_number(serial_node)?; + if issuer_name.trim().is_empty() { return Err(ParseError::InvalidStructure( "X509IssuerSerial requires non-empty X509IssuerName and X509SerialNumber".into(), )); @@ -1458,6 +2024,67 @@ fn collect_text_content_bounded( Ok(text) } +fn collect_x509_serial_number(node: Node<'_, '_>) -> Result { + let mut serial = String::with_capacity(MAX_X509_SERIAL_NUMBER_VALUE_DIGITS); + let mut raw_text_len = 0usize; + let mut trailing_whitespace = false; + let mut explicit_positive = false; + let mut saw_digit = false; + + for chunk in node + .children() + .filter_map(|child| child.is_text().then(|| child.text()).flatten()) + { + raw_text_len = raw_text_len.saturating_add(chunk.len()); + if raw_text_len > MAX_X509_SERIAL_NUMBER_RAW_TEXT_LEN { + return Err(ParseError::InvalidStructure( + "X509SerialNumber exceeds maximum allowed text length".into(), + )); + } + for byte in chunk.bytes() { + if matches!(byte, b' ' | b'\t' | b'\r' | b'\n') { + trailing_whitespace |= explicit_positive || saw_digit; + continue; + } + if byte == b'+' && !saw_digit && !explicit_positive && !trailing_whitespace { + explicit_positive = true; + continue; + } + if trailing_whitespace || !byte.is_ascii_digit() { + return Err(ParseError::InvalidStructure( + "invalid X509SerialNumber decimal value".into(), + )); + } + saw_digit = true; + if byte == b'0' && serial.is_empty() { + continue; + } + if serial.len() == MAX_X509_SERIAL_NUMBER_VALUE_DIGITS { + return Err(ParseError::InvalidStructure( + "X509SerialNumber exceeds maximum allowed decimal value".into(), + )); + } + serial.push(char::from(byte)); + } + } + + if !saw_digit { + return Err(ParseError::InvalidStructure( + "X509IssuerSerial requires non-empty X509IssuerName and X509SerialNumber".into(), + )); + } + if serial.is_empty() { + serial.push('0'); + } + if x509_serial_decimal_to_hex(&serial).is_none() { + return Err(ParseError::InvalidStructure( + "invalid X509SerialNumber decimal value or RFC 5280 range".into(), + )); + } + + Ok(serial) +} + fn ensure_no_element_children(node: Node<'_, '_>, element_name: &str) -> Result<(), ParseError> { if node.children().any(|child| child.is_element()) { return Err(ParseError::InvalidStructure(format!( @@ -1550,6 +2177,8 @@ mod tests { #[test] fn signature_algorithm_uri_round_trip() { for algo in [ + SignatureAlgorithm::DsaSha1, + SignatureAlgorithm::HmacSha1, SignatureAlgorithm::RsaSha1, SignatureAlgorithm::RsaSha256, SignatureAlgorithm::RsaSha384, @@ -1566,7 +2195,9 @@ mod tests { } #[test] - fn rsa_sha1_verify_only() { + fn legacy_algorithms_are_verify_only() { + assert!(!SignatureAlgorithm::DsaSha1.signing_allowed()); + assert!(!SignatureAlgorithm::HmacSha1.signing_allowed()); assert!(!SignatureAlgorithm::RsaSha1.signing_allowed()); assert!(SignatureAlgorithm::RsaSha256.signing_allowed()); assert!(SignatureAlgorithm::EcdsaP256Sha256.signing_allowed()); @@ -1623,9 +2254,9 @@ mod tests { {cert_base64} - C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), CN=Test Key rsa-2048 + CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US - C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), OU=Second level CA, CN=Aleksey Sanin, Email=xmlsec@aleksey.com + Email=xmlsec@aleksey.com,CN=Aleksey Sanin,OU=Second level CA,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US 680572598617295163017172295025714171905498632019 bcOXN/nsVl8GatRbcKrPbzIbw0Y= @@ -1659,14 +2290,14 @@ mod tests { assert_eq!( x509_info.subject_names, vec![ - "C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), CN=Test Key rsa-2048" + "CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US" .to_string() ] ); assert_eq!( x509_info.issuer_serials, vec![( - "C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), OU=Second level CA, CN=Aleksey Sanin, Email=xmlsec@aleksey.com".to_string(), + "Email=xmlsec@aleksey.com,CN=Aleksey Sanin,OU=Second level CA,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US".to_string(), "680572598617295163017172295025714171905498632019".to_string() )] ); @@ -1709,13 +2340,13 @@ mod tests { #[test] fn parse_rsa_key_value_preserves_wrapped_crypto_binary() { // CryptoBinary is unsigned big-endian data and XML whitespace is insignificant. - let xml = r#" + let xml = r##" AQID BA== AQAB - "#; + "##; let doc = Document::parse(xml).unwrap(); assert_eq!( @@ -1730,11 +2361,11 @@ BA== #[test] fn parse_rsa_key_value_rejects_reordered_parameters() { // XMLDSig defines Modulus followed by Exponent; accepting reordered input is ambiguous. - let xml = r#" + let xml = r##" AQABAQID - "#; + "##; let doc = Document::parse(xml).unwrap(); assert!(matches!( @@ -1746,9 +2377,9 @@ BA== #[test] fn parse_rsa_key_value_rejects_missing_exponent() { // Both RSA public parameters are required to construct a usable key. - let xml = r#" + let xml = r##" AQID - "#; + "##; let doc = Document::parse(xml).unwrap(); assert!(matches!( @@ -1760,11 +2391,11 @@ BA== #[test] fn parse_rsa_key_value_rejects_duplicate_exponent() { // RSAKeyValue has a closed two-child schema; duplicate parameters are invalid. - let xml = r#" + let xml = r##" AQIDAQABAQAB - "#; + "##; let doc = Document::parse(xml).unwrap(); assert!(matches!( @@ -2154,6 +2785,41 @@ BA== assert_eq!(x509_info.certificate_chain, vec![2, 1, 0]); } + #[test] + fn chain_builder_matches_x509_equivalent_distinguished_names() { + // RFC 5280 name chaining uses X.501 matching rather than the lexical + // RFC 4514 rendering. Case differences in DirectoryString values must + // not disconnect an otherwise valid configured path. + let certificates = [ + fixture_cert_base64("../../tests/fixtures/keys/rsa/rsa-2048-cert.pem"), + fixture_cert_base64("../../tests/fixtures/keys/ca2cert.pem"), + fixture_cert_base64("../../tests/fixtures/keys/cacert.pem"), + ] + .map(|encoded| { + base64::engine::general_purpose::STANDARD + .decode(encoded) + .unwrap() + }) + .to_vec(); + let mut parsed_certificates = certificates + .iter() + .map(|certificate| parse_x509_certificate(certificate).unwrap()) + .collect::>(); + parsed_certificates[0].issuer_dn = parsed_certificates[1].subject_dn.to_ascii_lowercase(); + parsed_certificates[1].issuer_dn = parsed_certificates[2].subject_dn.to_ascii_lowercase(); + let info = X509DataInfo { + certificates, + parsed_certificates, + ..X509DataInfo::default() + }; + + assert_eq!(select_x509_signing_certificate(&info).unwrap(), 0); + assert_eq!( + build_x509_certificate_chain_from(&info, 0).unwrap(), + vec![0, 1, 2] + ); + } + #[test] fn parse_key_info_uses_issuer_serial_to_select_x509_signing_certificate() { let root = fixture_cert_base64("../../tests/fixtures/keys/cacert.pem"); @@ -2163,7 +2829,7 @@ BA== r#" - C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), OU=Second level CA, CN=Aleksey Sanin, Email=xmlsec@aleksey.com + Email=xmlsec@aleksey.com,CN=Aleksey Sanin,OU=Second level CA,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US 680572598617295163017172295025714171905498632019 {root} @@ -2193,7 +2859,7 @@ BA== let xml = format!( r#" - C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), CN=Test Key rsa-2048 + CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US 0X0XrEVCio75sBcl1TxymJ2IOiU= {root} {intermediate} @@ -2214,9 +2880,10 @@ BA== #[test] fn parse_key_info_uses_decimal_issuer_serial_to_select_x509_signing_certificate() { + let serial = "680572598617295163017172295025714171905498632019"; + let padded_serial = format!("{}{}", "0".repeat(64), serial); assert_eq!( - x509_serial_decimal_to_hex("680572598617295163017172295025714171905498632019") - .as_deref(), + x509_serial_decimal_to_hex(&padded_serial).as_deref(), Some("7735EE487F6862DAF1B3956D961CCB0FA6F34F53") ); let root = fixture_cert_base64("../../tests/fixtures/keys/cacert.pem"); @@ -2227,8 +2894,8 @@ BA== r#" - C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), OU=Second level CA, CN=Aleksey Sanin, Email=xmlsec@aleksey.com - 680572598617295163017172295025714171905498632019 + Email=xmlsec@aleksey.com,CN=Aleksey Sanin,OU=Second level CA,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US + {padded_serial} {root} {intermediate} @@ -2293,7 +2960,7 @@ BA== let xml = format!( r#" - C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), CN=Test Key rsa-2048 + CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US CN=Not In The Embedded Chain {cert} @@ -2309,13 +2976,15 @@ BA== #[test] fn parse_key_info_rejects_malformed_issuer_serial_even_with_matching_subject() { + // Lexically invalid serials must fail while parsing X509IssuerSerial, + // before another selector or embedded certificate can mask them. let cert = fixture_cert_base64("../../tests/fixtures/keys/rsa/rsa-2048-cert.pem"); let xml = format!( r#" - C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), CN=Test Key rsa-2048 + CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US - C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), OU=Second level CA, CN=Aleksey Sanin, Email=xmlsec@aleksey.com + Email=xmlsec@aleksey.com,CN=Aleksey Sanin,OU=Second level CA,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US not-a-decimal-serial {cert} @@ -2326,7 +2995,7 @@ BA== let err = parse_key_info(doc.root_element()).unwrap_err(); assert!( - matches!(err, ParseError::InvalidStructure(message) if message.contains("lookup identifiers")) + matches!(err, ParseError::InvalidStructure(message) if message.contains("invalid X509SerialNumber")) ); } @@ -2336,7 +3005,7 @@ BA== let xml = format!( r#" - C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), CN=Test Key rsa-2048 + CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US AQIDBA== {cert} @@ -2357,7 +3026,7 @@ BA== let xml = format!( r#" - C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), CN=Test Key rsa-2048 + CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US 60zMLKCfzQ3qnXAzABzRNpdgQ8Q= {first_cert} {second_cert} @@ -2374,7 +3043,7 @@ BA== #[test] fn build_x509_certificate_chain_rejects_chain_exceeding_max_depth() { - let parsed_certificates = (0..=MAX_X509_CHAIN_DEPTH) + let parsed_certificates: Vec = (0..=MAX_X509_CHAIN_DEPTH) .map(|idx| ParsedX509Certificate { subject_dn: format!("CN=cert-{idx}"), issuer_dn: if idx == MAX_X509_CHAIN_DEPTH { @@ -2390,7 +3059,9 @@ BA== }, }) .collect(); + let certificates = vec![Vec::new(); parsed_certificates.len()]; let info = X509DataInfo { + certificates, parsed_certificates, ..X509DataInfo::default() }; @@ -2408,10 +3079,146 @@ BA== assert_eq!(format_x509_serial_value_hex(&[0x00, 0x00]), "00"); } + #[test] + fn x509_serial_decimal_parser_enforces_rfc5280_positive_range() { + // RFC 5280 limits positive certificate serials to 20 DER content + // octets, leaving 159 value bits because the high bit is the sign. + let max_serial = "730750818665451459101842416358141509827966271487"; + assert_eq!( + x509_serial_decimal_to_hex(max_serial), + Some("7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF".into()) + ); + assert_eq!( + x509_serial_decimal_to_hex("0000000000000000000000000000000000000000000000001"), + Some("01".into()) + ); + assert_eq!( + x509_serial_decimal_to_hex("00000000000000000000000000000000000000000000000001"), + Some("01".into()) + ); + assert_eq!(x509_serial_decimal_to_hex("+1"), Some("01".into())); + + for invalid in [ + "", + "0", + "000", + "+0", + "++1", + "-1", + "1a", + "730750818665451459101842416358141509827966271488", + "1461501637330902918203684832716283019655932542976", + ] { + assert_eq!( + x509_serial_decimal_to_hex(invalid), + None, + "invalid serial {invalid:?} must be rejected" + ); + } + } + + #[test] + fn parse_x509_serial_normalizes_boundary_whitespace_and_rejects_overflow() { + // XML Schema collapses integer whitespace before validation; the + // normalized value must still obey the RFC 5280 positive range. + let max_serial = "730750818665451459101842416358141509827966271487"; + let valid = format!( + "CN=issuer\n {max_serial}\t" + ); + let doc = Document::parse(&valid).unwrap(); + let parsed = parse_key_info(doc.root_element()).unwrap(); + let KeyInfoSource::X509Data(x509) = &parsed.sources[0] else { + panic!("expected X509Data source"); + }; + assert_eq!(x509.issuer_serials[0].1, max_serial); + + let explicit_positive = valid.replace(max_serial, "+42"); + let doc = Document::parse(&explicit_positive).unwrap(); + let parsed = parse_key_info(doc.root_element()).unwrap(); + let KeyInfoSource::X509Data(x509) = &parsed.sources[0] else { + panic!("expected X509Data source"); + }; + assert_eq!(x509.issuer_serials[0].1, "42"); + + let overflow = valid.replace( + max_serial, + "730750818665451459101842416358141509827966271488", + ); + let doc = Document::parse(&overflow).unwrap(); + assert!(matches!( + parse_key_info(doc.root_element()), + Err(ParseError::InvalidStructure(message)) + if message.contains("invalid X509SerialNumber") + )); + } + + #[test] + fn distinguished_name_matching_preserves_rdn_order() { + // RFC 4514 permits alternate encodings within an RDN, but reversing + // the RDN sequence identifies a different hierarchical name. + assert!(distinguished_names_equal( + "CN=leaf, O=example", + "CN=leaf,O=example" + )); + assert!(!distinguished_names_equal( + "CN=leaf,O=example", + "O=example,CN=leaf" + )); + } + + #[test] + fn distinguished_name_matching_applies_x520_string_preparation() { + // RFC 5280 requires caseIgnoreMatch with insignificant-space handling + // for DirectoryString values rather than exact ASN.1 value equality. + assert!(distinguished_names_equal( + "CN= TEST key ,O=Example", + "CN=test key,O=example" + )); + assert!(distinguished_names_equal( + "CN=Straße,O=Example", + "CN=STRASSE,O=EXAMPLE" + )); + assert!(distinguished_names_equal( + "CN=test+OU=security,O=example", + "OU=SECURITY+CN=TEST,O=EXAMPLE" + )); + assert!(!distinguished_names_equal( + "1.2.3.4=#040141,O=example", + "1.2.3.4=#040142,O=example" + )); + } + + #[test] + fn distinguished_name_matching_handles_rfc4514_escaped_values() { + // Certificate values containing RFC 4514 separators and boundary spaces + // must remain one attribute when matched against an XMLDSig selector. + let value = " leading,plus+equals=slash\\trailing "; + let mut params = rcgen::CertificateParams::new(Vec::new()).unwrap(); + params + .distinguished_name + .push(rcgen::DnType::CommonName, value); + let key = rcgen::KeyPair::generate().unwrap(); + let certificate = params.self_signed(&key).unwrap(); + let parsed = parse_x509_certificate(certificate.der()).unwrap(); + + assert_eq!( + parsed.subject_dn, + r"CN=\ leading\,plus\+equals=slash\\trailing\ " + ); + assert!(distinguished_names_equal( + r"CN=\ leading\,plus\+equals=slash\\trailing\ ", + &parsed.subject_dn + )); + assert!(distinguished_names_equal( + "\n CN=\\ leading\\,plus\\+equals=slash\\\\trailing\\ \n", + &parsed.subject_dn + )); + } + #[test] fn parse_key_info_accepts_large_textual_x509_entries_within_entry_budget() { let issuer_name = "C".repeat(MAX_X509_ISSUER_NAME_TEXT_LEN); - let serial_number = "7".repeat(MAX_X509_SERIAL_NUMBER_TEXT_LEN); + let serial_number = "0".repeat(MAX_X509_SERIAL_NUMBER_VALUE_DIGITS - 1) + "1"; let issuer_serials = (0..52) .map(|_| { format!( @@ -2433,6 +3240,25 @@ BA== assert_eq!(parsed.issuer_serials.len(), 52); } + #[test] + fn parse_key_info_bounds_raw_x509_serial_text() { + // Leading zeroes are lexically valid, but their raw XML representation + // remains bounded independently from the canonical certificate value. + let serial = "0".repeat(MAX_X509_SERIAL_NUMBER_RAW_TEXT_LEN + 1); + let xml = format!( + "CN=issuer{serial}" + ); + let doc = Document::parse(&xml).unwrap(); + + let error = parse_key_info(doc.root_element()).unwrap_err(); + + assert!(matches!( + error, + ParseError::InvalidStructure(reason) + if reason == "X509SerialNumber exceeds maximum allowed text length" + )); + } + #[test] fn parse_key_info_accepts_x509data_with_only_foreign_namespace_children() { let xml = r#" fn parse_key_info_keeps_unsupported_keyvalue_child_as_marker() { let xml = r#" - + "#; let doc = Document::parse(xml).unwrap(); @@ -2674,11 +3500,202 @@ BA== key_info.sources, vec![KeyInfoSource::KeyValue(KeyValueInfo::Unsupported { namespace: Some(XMLDSIG_NS.to_string()), - local_name: "DSAKeyValue".into(), + local_name: "FutureKeyValue".into(), })] ); } + #[test] + fn parse_key_info_accepts_supported_x509_retrieval_xpath() { + // Merlin's same-document RetrievalMethod selects only X509Data nodes. + let xml = r##" + + + ancestor-or-self::dsig:X509Data + + + "##; + let doc = Document::parse(xml).unwrap(); + + let key_info = parse_key_info(doc.root_element()).unwrap(); + assert!(matches!( + key_info.sources.as_slice(), + [KeyInfoSource::RetrievalMethod { + uri, + resource_type: Some(resource_type), + transforms: RetrievalMethodTransforms::X509DataNodeSetFilter, + }] if uri == "#keys" + && resource_type == "http://www.w3.org/2000/09/xmldsig#X509Data" + )); + } + + #[test] + fn parse_key_info_accepts_namespace_equivalent_retrieval_xpath_prefix() { + let xml = r##" + + + ancestor-or-self::ds:X509Data + + + "##; + let doc = Document::parse(xml).unwrap(); + + assert!(matches!( + parse_key_info(doc.root_element()) + .unwrap() + .sources + .as_slice(), + [KeyInfoSource::RetrievalMethod { + transforms: RetrievalMethodTransforms::X509DataNodeSetFilter, + .. + }] + )); + } + + #[test] + fn parse_key_info_reads_complete_retrieval_xpath_text() { + // XML comments split character data into multiple text nodes; all chunks + // still belong to the XPath parameter's string-value. + let valid = r##" + + + ancestor-or-self::ds:X509Data + + + "##; + let document = Document::parse(valid).unwrap(); + assert!(parse_key_info(document.root_element()).is_ok()); + + let unsupported = + valid.replace("X509Data", "X509Data[false()]"); + let document = Document::parse(&unsupported).unwrap(); + assert!(matches!( + parse_key_info(document.root_element()), + Err(ParseError::InvalidStructure(reason)) + if reason == "unsupported RetrievalMethod XPath selection" + )); + } + + #[test] + fn parse_dsa_key_value_accepts_schema_optional_parameters_and_rejects_half_pair() { + let key_info = |parameters: &str| { + format!( + r#" + {parameters} + "# + ) + }; + for parameters in [ + "AQ==", + "AQ==AQ==", + "

AQ==

AQ==AQ==", + "

AQ==

AQ==AQ==AQ==AQ==", + "AQ==AQ==AQ==", + "AQ==AQ==AQ==AQ==", + ] { + let xml = key_info(parameters); + let doc = Document::parse(&xml).unwrap(); + assert!(matches!( + parse_key_info(doc.root_element()) + .unwrap() + .sources + .as_slice(), + [KeyInfoSource::KeyValue(KeyValueInfo::Dsa { .. })] + )); + } + + for invalid_parameters in [ + "

AQ==

AQ==", + "AQ==AQ==", + "AQ==AQ==", + "AQ==AQ==", + ] { + let xml = key_info(invalid_parameters); + let doc = Document::parse(&xml).unwrap(); + assert!(matches!( + parse_key_info(doc.root_element()), + Err(ParseError::InvalidStructure(_)) + )); + } + } + + #[test] + fn parse_dsa_crypto_binary_ignores_comment_nodes() { + // XML comments split simple content without contributing to its string value. + let xml = r#" + AQID + "#; + let doc = Document::parse(xml).unwrap(); + + assert!(matches!( + parse_key_info(doc.root_element()) + .unwrap() + .sources + .as_slice(), + [KeyInfoSource::KeyValue(KeyValueInfo::Dsa { y, .. })] if y == &[1, 2, 3] + )); + } + + #[test] + fn parse_rsa_crypto_binary_ignores_comment_nodes() { + // The shared CryptoBinary decoder must apply XML simple-content semantics to every key type. + let xml = r#" + + AQIDAw== + + "#; + let doc = Document::parse(xml).unwrap(); + + assert!(matches!( + parse_key_info(doc.root_element()) + .unwrap() + .sources + .as_slice(), + [KeyInfoSource::KeyValue(KeyValueInfo::Rsa { modulus, exponent })] + if modulus == &[1, 2, 3] && exponent == &[3] + )); + } + + #[test] + fn parse_key_info_preserves_advisory_unsupported_retrieval_transform() { + // Unsupported RetrievalMethod types are advisory key sources. Their + // transform syntax must not hide a later source the resolver can use. + let xml = r##" + + + + fallback + "##; + let doc = Document::parse(xml).unwrap(); + + let key_info = parse_key_info(doc.root_element()) + .expect("unsupported advisory retrieval must not reject all KeyInfo sources"); + assert!(matches!( + key_info.sources.as_slice(), + [ + KeyInfoSource::RetrievalMethod { resource_type: Some(resource_type), .. }, + KeyInfoSource::KeyName(name), + ] if resource_type == "urn:vendor:key" && name == "fallback" + )); + } + + #[test] + fn parse_key_info_rejects_excessive_child_sources() { + // KeyInfo extensions are lax, but their parse work remains bounded. + let children = (0..=64) + .map(|index| format!(r#""#)) + .collect::(); + let xml = + format!(r#"{children}"#); + let document = Document::parse(&xml).unwrap(); + + assert!(matches!( + parse_key_info(document.root_element()), + Err(ParseError::InvalidStructure(reason)) + if reason == "KeyInfo contains too many child elements" + )); + } + #[test] fn parse_key_info_rejects_keyname_with_child_elements() { let xml = r#" @@ -2770,6 +3787,52 @@ BA== // ── parse_signed_info: happy path ──────────────────────────────── + #[test] + fn parse_hmac_output_length_reads_all_text_nodes() { + // A comment may split valid simple content without changing its value. + let xml = r#" + 80 + "#; + let document = Document::parse(xml).unwrap(); + + assert_eq!( + parse_hmac_output_length(document.root_element(), SignatureAlgorithm::HmacSha1) + .unwrap(), + Some(80) + ); + } + + #[test] + fn parse_hmac_output_length_rejects_hidden_suffix_text() { + // Reading only the first text node would misinterpret 800 bits as 80. + let xml = r#" + 800 + "#; + let document = Document::parse(xml).unwrap(); + + assert!(matches!( + parse_hmac_output_length(document.root_element(), SignatureAlgorithm::HmacSha1), + Err(ParseError::InvalidStructure(reason)) + if reason == "HMACOutputLength must be a byte-aligned value from 80 through 160" + )); + } + + #[test] + fn parse_hmac_output_length_rejects_non_octet_truncation() { + // XMLDSig 1.1 section 6.3.1 requires a byte boundary even though the + // HMACOutputLength schema represents the value as a bit count. + let xml = r#" + 81 + "#; + let document = Document::parse(xml).unwrap(); + + assert!(matches!( + parse_hmac_output_length(document.root_element(), SignatureAlgorithm::HmacSha1), + Err(ParseError::InvalidStructure(reason)) + if reason == "HMACOutputLength must be a byte-aligned value from 80 through 160" + )); + } + #[test] fn parse_signed_info_rsa_sha256_with_reference() { let xml = r#" diff --git a/src/xmldsig/sign.rs b/src/xmldsig/sign.rs index 8564db48..48558fd9 100644 --- a/src/xmldsig/sign.rs +++ b/src/xmldsig/sign.rs @@ -23,7 +23,7 @@ use x509_parser::prelude::FromDer; use crate::c14n::canonicalize; use super::builder::{SignatureBuilder, SignatureBuilderError}; -use super::digest::{DigestAlgorithm, compute_digest}; +use super::digest::DigestAlgorithm; use super::mutation::{ XmlMutationError, append_signature_to_root, fill_key_info, fill_signature_value, fill_signed_info_digest_values, @@ -56,6 +56,14 @@ pub struct ComputedReferenceDigest { /// Errors returned by the XMLDSig signing digest pass. #[derive(Debug, thiserror::Error)] pub enum SigningDigestError { + /// The selected provider could not compute a reference digest. + #[error("cryptographic provider error: {0}")] + Provider(#[from] crate::provider::ProviderError), + + /// The compiled signing policy rejected an operation input. + #[error("signing policy violation: {0}")] + Policy(#[from] crate::policy::PolicyViolation), + /// The input XML document is not well-formed. #[error("XML parse error: {0}")] XmlParse(#[from] roxmltree::Error), @@ -97,6 +105,10 @@ pub enum SigningDigestError { /// Errors returned by the full XMLDSig signing pipeline. #[derive(Debug, thiserror::Error)] pub enum SigningError { + /// The compiled signing policy rejected an operation input. + #[error("signing policy violation: {0}")] + Policy(#[from] crate::policy::PolicyViolation), + /// Reference digest computation failed. #[error("signing digest pass failed: {0}")] Digest(#[from] SigningDigestError), @@ -130,6 +142,10 @@ pub enum SigningError { #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum SigningKeyError { + /// The selected provider cannot execute the requested operation. + #[error("cryptographic provider error: {0}")] + Provider(#[from] crate::provider::ProviderError), + /// PEM input could not be parsed. #[error("invalid PEM private key")] InvalidKeyPem, @@ -472,7 +488,8 @@ impl SigningKey for EcdsaP384SigningKey { pub struct SignContext<'a> { signing_key: &'a dyn SigningKey, key_info_writer: Option<&'a dyn KeyInfoWriter>, - transform_options: TransformOptions, + policy: crate::policy::SigningPolicy, + provider: &'a dyn crate::provider::CryptoProvider, } impl<'a> SignContext<'a> { @@ -481,10 +498,25 @@ impl<'a> SignContext<'a> { Self { signing_key, key_info_writer: None, - transform_options: TransformOptions::default(), + policy: crate::policy::SigningPolicy::default(), + provider: crate::provider::default_provider(), } } + /// Replace the complete immutable signing policy snapshot. + #[must_use] + pub fn policy(mut self, policy: crate::policy::SigningPolicy) -> Self { + self.policy = policy; + self + } + + /// Select the cryptographic provider for digest and randomness operations. + #[must_use] + pub fn provider(mut self, provider: &'a dyn crate::provider::CryptoProvider) -> Self { + self.provider = provider; + self + } + /// Configure signing to populate the direct `/` placeholder. #[must_use] pub fn key_info_writer(mut self, writer: &'a dyn KeyInfoWriter) -> Self { @@ -499,7 +531,7 @@ impl<'a> SignContext<'a> { /// signatures compatible with libxmlsec1's `` interpretation. #[must_use] pub fn xpath_here_semantics(mut self, semantics: XPathHereSemantics) -> Self { - self.transform_options = self.transform_options.xpath_here_semantics(semantics); + self.policy.xpath_here_semantics = semantics; self } @@ -510,9 +542,40 @@ impl<'a> SignContext<'a> { /// canonicalizes ``, signs those canonical bytes, and fills the /// base64 ``. pub fn sign_template(&self, xml: &str) -> Result { - let with_digests = fill_reference_digest_values_with_options(xml, self.transform_options)?; + self.policy.resources.validate()?; + let execution_budget = TransformExecutionBudget::with_c14n_limit( + self.policy.resources.max_canonicalized_bytes, + ); + let transform_options = TransformOptions::default() + .allow_internal_dtd(self.policy.xml.allow_internal_dtd) + .xpath_here_semantics(self.policy.xpath_here_semantics); + let with_digests = fill_reference_digest_values_with_options( + xml, + transform_options, + Some(&self.policy), + self.provider, + &execution_budget, + )?; let (algorithm, canonical_signed_info) = canonicalize_signed_info(&with_digests)?; - let signature_value = self.signing_key.sign(algorithm, &canonical_signed_info)?; + execution_budget + .charge_c14n_output(canonical_signed_info.len()) + .map_err(SigningDigestError::Transform)?; + if !algorithm.signing_allowed() + || self + .policy + .signature_algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(&algorithm)) + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "signing", + algorithm: algorithm.uri().to_string(), + } + .into()); + } + let signature_value = + self.provider + .sign(self.signing_key, algorithm, &canonical_signed_info)?; let signature_b64 = base64::engine::general_purpose::STANDARD.encode(signature_value); let signed = fill_signature_value(&with_digests, &signature_b64)?; if let Some(writer) = self.key_info_writer { @@ -551,24 +614,76 @@ struct SigningReference { pub fn compute_reference_digest_values( xml: &str, ) -> Result, SigningDigestError> { - compute_reference_digest_values_with_options(xml, TransformOptions::default()) + let execution_budget = TransformExecutionBudget::default(); + compute_reference_digest_values_with_options( + xml, + TransformOptions::default(), + None, + crate::provider::default_provider(), + &execution_budget, + ) } fn compute_reference_digest_values_with_options( xml: &str, transform_options: TransformOptions, + policy: Option<&crate::policy::SigningPolicy>, + provider: &dyn crate::provider::CryptoProvider, + execution_budget: &TransformExecutionBudget, ) -> Result, SigningDigestError> { let doc = Document::parse(xml)?; let signature = find_signing_signature_node(&doc)?; let signed_info = find_required_child(signature, "SignedInfo")?; let references = parse_signing_references(signed_info)?; + if let Some(policy) = policy { + if references.len() > policy.resources.max_references { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: "signature references", + maximum: policy.resources.max_references, + actual: references.len(), + } + .into()); + } + for reference in &references { + if reference.transforms.len() > policy.resources.max_transforms_per_reference { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: "reference transforms", + maximum: policy.resources.max_transforms_per_reference, + actual: reference.transforms.len(), + } + .into()); + } + if let Some(allowed) = policy.transforms.as_ref() { + for transform in &reference.transforms { + let uri = transform.algorithm_uri(); + if !allowed.contains(uri) { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "signing transform", + algorithm: uri.to_owned(), + } + .into()); + } + } + } + } + } let resolver = UriReferenceResolver::new(&doc); - let execution_budget = TransformExecutionBudget::default(); - references .into_iter() .enumerate() .map(|(index, reference)| { + if policy.is_some_and(|policy| { + policy + .digest_algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(&reference.digest_method)) + }) { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "signing", + algorithm: reference.digest_method.uri().to_string(), + } + .into()); + } let initial_data = resolver.dereference_with_budget( &reference.uri, execution_budget.node_set_materialization(), @@ -578,9 +693,13 @@ fn compute_reference_digest_values_with_options( initial_data, &reference.transforms, transform_options, - &execution_budget, + execution_budget, + )?; + let digest = super::compute_digest_with_provider( + provider, + reference.digest_method, + &pre_digest, )?; - let digest = compute_digest(reference.digest_method, &pre_digest); let digest_value = base64::engine::general_purpose::STANDARD.encode(digest); Ok(ComputedReferenceDigest { index, @@ -599,16 +718,32 @@ fn compute_reference_digest_values_with_options( /// and writes the base64 digest into the matching `` in document /// order. pub fn fill_reference_digest_values(xml: &str) -> Result { - fill_reference_digest_values_with_options(xml, TransformOptions::default()) + let execution_budget = TransformExecutionBudget::default(); + fill_reference_digest_values_with_options( + xml, + TransformOptions::default(), + None, + crate::provider::default_provider(), + &execution_budget, + ) } fn fill_reference_digest_values_with_options( xml: &str, transform_options: TransformOptions, + policy: Option<&crate::policy::SigningPolicy>, + provider: &dyn crate::provider::CryptoProvider, + execution_budget: &TransformExecutionBudget, ) -> Result { - let digest_values = compute_reference_digest_values_with_options(xml, transform_options)? - .into_iter() - .map(|digest| digest.digest_value); + let digest_values = compute_reference_digest_values_with_options( + xml, + transform_options, + policy, + provider, + execution_budget, + )? + .into_iter() + .map(|digest| digest.digest_value); Ok(fill_signed_info_digest_values(xml, digest_values)?) } diff --git a/src/xmldsig/signature.rs b/src/xmldsig/signature.rs index a37addab..315dabdc 100644 --- a/src/xmldsig/signature.rs +++ b/src/xmldsig/signature.rs @@ -1,8 +1,7 @@ //! Signature verification helpers for XMLDSig. //! -//! This module currently covers roadmap task P1-019 (RSA PKCS#1 v1.5) and -//! P1-020 (ECDSA P-256/P-384) verification, plus donor P-521 interop under -//! the XMLDSig `ecdsa-sha384` URI. +//! This module covers RSA PKCS#1 v1.5, DSA-SHA1, and ECDSA verification, +//! including donor P-521 interoperability under the XMLDSig `ecdsa-sha384` URI. //! //! Input public keys are accepted in SubjectPublicKeyInfo (SPKI) form because //! that is how the vendored PEM fixtures are stored. @@ -134,6 +133,22 @@ pub fn verify_rsa_signature_spki( public_key_spki_der: &[u8], signed_data: &[u8], signature_value: &[u8], +) -> Result { + verify_rsa_signature_spki_with_minimum( + algorithm, + public_key_spki_der, + signed_data, + signature_value, + 2048, + ) +} + +pub(crate) fn verify_rsa_signature_spki_with_minimum( + algorithm: SignatureAlgorithm, + public_key_spki_der: &[u8], + signed_data: &[u8], + signature_value: &[u8], + minimum_modulus_bits: usize, ) -> Result { let (rest, spki) = SubjectPublicKeyInfo::from_der(public_key_spki_der) .map_err(|_| SignatureVerificationError::InvalidKeyDer)?; @@ -146,7 +161,7 @@ pub fn verify_rsa_signature_spki( match public_key { PublicKey::RSA(rsa) => { - validate_rsa_public_key(&rsa, algorithm)?; + validate_rsa_public_key(&rsa, algorithm, minimum_modulus_bits)?; let key = rsa::RsaPublicKey::from_public_key_der(public_key_spki_der) .map_err(|_| SignatureVerificationError::InvalidKeyDer)?; let Ok(signature) = RsaPkcs1v15Signature::try_from(signature_value) else { @@ -185,6 +200,37 @@ pub fn verify_rsa_signature_spki( } } +/// Verify an XMLDSig DSA-SHA1 signature using a DER SPKI public key. +/// +/// XMLDSig 1.0 encodes the signature as the fixed-width 20-byte `r` followed +/// by the fixed-width 20-byte `s`, rather than ASN.1 DER. +#[must_use = "discarding the verification result skips signature validation"] +pub fn verify_dsa_signature_spki( + algorithm: SignatureAlgorithm, + public_key_spki_der: &[u8], + signed_data: &[u8], + signature_value: &[u8], +) -> Result { + if algorithm != SignatureAlgorithm::DsaSha1 { + return Err(SignatureVerificationError::UnsupportedAlgorithm { + uri: algorithm.uri().to_string(), + }); + } + if signature_value.len() != 40 { + return Ok(false); + } + let key = dsa::VerifyingKey::from_public_key_der(public_key_spki_der) + .map_err(|_| SignatureVerificationError::InvalidKeyDer)?; + let Some(signature) = dsa::Signature::from_components( + crypto_bigint::BoxedUint::from_be_slice_vartime(&signature_value[..20]), + crypto_bigint::BoxedUint::from_be_slice_vartime(&signature_value[20..]), + ) else { + return Ok(false); + }; + let digest = Sha1::digest(signed_data); + Ok(key.verify_prehash(&digest, &signature).is_ok()) +} + /// Verify an ECDSA XMLDSig signature using DER-encoded SPKI public key bytes. /// /// The input must be an X.509 `SubjectPublicKeyInfo` wrapping an EC key. The @@ -253,8 +299,9 @@ pub fn verify_ecdsa_signature_spki( fn validate_rsa_public_key( rsa: &x509_parser::public_key::RSAPublicKey<'_>, algorithm: SignatureAlgorithm, + minimum_modulus_bits: usize, ) -> Result<(), SignatureVerificationError> { - let min_modulus_bits = minimum_rsa_modulus_bits(algorithm)?; + ensure_rsa_signature_algorithm(algorithm)?; let modulus_start = rsa .modulus .iter() @@ -271,7 +318,7 @@ fn validate_rsa_public_key( .len() .checked_mul(8) .ok_or(SignatureVerificationError::InvalidKeyDer)?; - if !(min_modulus_bits..=8192).contains(&modulus_bits) { + if !(minimum_modulus_bits..=8192).contains(&modulus_bits) { return Err(SignatureVerificationError::InvalidKeyDer); } @@ -285,14 +332,14 @@ fn validate_rsa_public_key( Ok(()) } -fn minimum_rsa_modulus_bits( +fn ensure_rsa_signature_algorithm( algorithm: SignatureAlgorithm, -) -> Result { +) -> Result<(), SignatureVerificationError> { match algorithm { SignatureAlgorithm::RsaSha1 | SignatureAlgorithm::RsaSha256 | SignatureAlgorithm::RsaSha384 - | SignatureAlgorithm::RsaSha512 => Ok(2048), + | SignatureAlgorithm::RsaSha512 => Ok(()), _ => Err(SignatureVerificationError::UnsupportedAlgorithm { uri: algorithm.uri().to_string(), }), @@ -646,7 +693,7 @@ mod tests { SignatureAlgorithm::EcdsaP256Sha256, SignatureAlgorithm::EcdsaP384Sha384, ] { - let err = minimum_rsa_modulus_bits(algorithm).unwrap_err(); + let err = ensure_rsa_signature_algorithm(algorithm).unwrap_err(); assert!(matches!( err, SignatureVerificationError::UnsupportedAlgorithm { .. } @@ -654,6 +701,24 @@ mod tests { } } + #[test] + fn malformed_dsa_components_are_verification_misses() { + let public_key = include_bytes!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh.der" + ); + let signature = [0_u8; 40]; + + assert!(matches!( + verify_dsa_signature_spki( + SignatureAlgorithm::DsaSha1, + public_key, + b"signed", + &signature, + ), + Ok(false) + )); + } + #[test] fn der_like_prefix_with_fixed_width_len_is_classified_as_raw() { let mut signature = vec![0xAA_u8; 96]; diff --git a/src/xmldsig/transforms.rs b/src/xmldsig/transforms.rs index 9e27e9c3..323c3642 100644 --- a/src/xmldsig/transforms.rs +++ b/src/xmldsig/transforms.rs @@ -35,6 +35,7 @@ use super::xpath::{ apply_xpath_filter2_with_semantics_and_budget, compile_xpath, is_xpath_whitespace, }; use crate::c14n::{self, C14nAlgorithm}; +use crate::hard_limits::XML_DOCUMENT_NODE_CEILING; /// The algorithm URI for the enveloped signature transform. pub const ENVELOPED_SIGNATURE_URI: &str = "http://www.w3.org/2000/09/xmldsig#enveloped-signature"; @@ -92,6 +93,7 @@ pub enum XPathHereSemantics { #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct TransformOptions { xpath_here_semantics: XPathHereSemantics, + allow_internal_dtd: bool, } #[derive(Default)] @@ -132,6 +134,7 @@ struct Base64WorkBudget { struct C14nOutputBudget { remaining: Cell, + max_bytes: usize, } fn charge_byte_budget(remaining: &Cell, bytes: usize) -> bool { @@ -147,11 +150,19 @@ impl Default for C14nOutputBudget { fn default() -> Self { Self { remaining: Cell::new(MAX_C14N_OUTPUT_BYTES), + max_bytes: MAX_C14N_OUTPUT_BYTES, } } } impl C14nOutputBudget { + fn with_limit(max_bytes: usize) -> Self { + Self { + remaining: Cell::new(max_bytes), + max_bytes, + } + } + fn remaining(&self) -> usize { self.remaining.get() } @@ -159,7 +170,7 @@ impl C14nOutputBudget { fn charge(&self, bytes: usize) -> Result<(), TransformError> { if !charge_byte_budget(&self.remaining, bytes) { return Err(TransformError::C14nOutputTooLarge { - max_bytes: MAX_C14N_OUTPUT_BYTES, + max_bytes: self.max_bytes, }); } Ok(()) @@ -197,18 +208,6 @@ impl TransformExecutionBudget { } } - fn with_c14n_limit(limit: usize) -> Self { - Self { - xpath: XPathWorkBudget::default(), - base64: Base64WorkBudget::default(), - c14n: C14nOutputBudget { - remaining: Cell::new(limit), - }, - node_filter: NodeFilterWorkBudget::default(), - node_set_materialization: NodeSetMaterializationBudget::default(), - } - } - fn with_node_filter_limit(limit: usize) -> Self { Self { xpath: XPathWorkBudget::default(), @@ -233,6 +232,17 @@ impl TransformExecutionBudget { } impl TransformExecutionBudget { + pub(crate) fn with_c14n_limit(max_bytes: usize) -> Self { + Self { + c14n: C14nOutputBudget::with_limit(max_bytes), + ..Self::default() + } + } + + pub(crate) fn charge_c14n_output(&self, bytes: usize) -> Result<(), TransformError> { + self.c14n.charge(bytes) + } + pub(crate) fn node_set_materialization(&self) -> &NodeSetMaterializationBudget { &self.node_set_materialization } @@ -246,9 +256,21 @@ impl TransformOptions { self } + /// Allow internal DTD declarations when a transform parses caller-supplied + /// octets as XML. External entity resolution remains disabled. + #[must_use] + pub fn allow_internal_dtd(mut self, enabled: bool) -> Self { + self.allow_internal_dtd = enabled; + self + } + pub(crate) fn here_semantics(self) -> XPathHereSemantics { self.xpath_here_semantics } + + pub(crate) fn internal_dtd_allowed(self) -> bool { + self.allow_internal_dtd + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -444,6 +466,18 @@ pub enum Transform { Base64Decode, } +impl Transform { + pub(crate) fn algorithm_uri(&self) -> &'static str { + match self { + Self::Enveloped => ENVELOPED_SIGNATURE_URI, + Self::XpathExcludeAllSignatures | Self::XPath(_) => XPATH_TRANSFORM_URI, + Self::XPathFilter2(_) => XPATH_FILTER2_TRANSFORM_URI, + Self::C14n(algorithm) => algorithm.uri(), + Self::Base64Decode => BASE64_TRANSFORM_URI, + } + } +} + /// Apply a single transform to the pipeline data. /// /// `signature_node` is the `` element that contains the @@ -775,8 +809,15 @@ fn execute_transform_chain<'s, 'e, 'd>( // recursion, so these retained buffers remain a bounded subset of the // signature-wide canonicalization work budget. let xml = decode_xml_octets(&bytes)?; - let document = roxmltree::Document::parse(&xml) - .map_err(|error| TransformError::XmlParse(error.to_string()))?; + let document = roxmltree::Document::parse_with_options( + &xml, + roxmltree::ParsingOptions { + allow_dtd: context.options.internal_dtd_allowed(), + nodes_limit: XML_DOCUMENT_NODE_CEILING, + entity_resolver: None, + }, + ) + .map_err(|error| TransformError::XmlParse(error.to_string()))?; context.state.document_reparsed(); let nodes = super::types::NodeSet::entire_document_with_comments_with_budget( &document, @@ -1776,6 +1817,27 @@ mod tests { )); } + #[test] + fn binary_to_node_set_adapter_bounds_external_xml_nodes_during_parse() { + // The parser must reject a dense external XML resource before allocating + // an unbounded roxmltree arena or beginning XPath materialization. + let signature_document = Document::parse("").unwrap(); + let xml = format!( + "{}", + "".repeat(XML_DOCUMENT_NODE_CEILING as usize + 1), + ); + let transforms = [Transform::XPath(XPathExpression::new("true()"))]; + + let error = execute_transforms( + signature_document.root_element(), + TransformData::Binary(xml.into_bytes()), + &transforms, + ) + .expect_err("external XML exceeding the node ceiling must fail during parse"); + + assert!(matches!(error, TransformError::XmlParse(_))); + } + #[test] fn xpath_projection_uses_shared_materialization_budget() { // XPath projects exact attribute and namespace identities back into a diff --git a/src/xmldsig/types.rs b/src/xmldsig/types.rs index 8ddd8899..89f2c1e2 100644 --- a/src/xmldsig/types.rs +++ b/src/xmldsig/types.rs @@ -202,6 +202,37 @@ impl<'a> NodeSet<'a> { Ok(Self::collect_subtree(element)) } + /// Create a bare-name same-document fragment node-set, which excludes + /// comment nodes before any transforms are applied. + pub(crate) fn subtree_without_comments_with_budget( + element: Node<'a, 'a>, + budget: Option<&NodeSetMaterializationBudget>, + ) -> Result { + match budget { + Some(budget) => Self::charge_subtree_materialization(element, budget)?, + None => { + Self::ensure_subtree_materialization_fits(element)?; + } + } + let mut set = Self { + doc: element.document(), + nodes: HashSet::new(), + with_comments: false, + }; + for node in element.descendants().filter(|node| !node.is_comment()) { + set.insert_node(node); + if node.is_element() { + for attribute in node.attributes() { + set.insert_attribute(node, attribute.namespace(), attribute.name()); + } + for namespace in node.namespaces() { + set.insert_namespace(node, namespace.name().unwrap_or(""), namespace.uri()); + } + } + } + Ok(set) + } + pub(crate) fn subtree_with_budget( element: Node<'a, 'a>, budget: &NodeSetMaterializationBudget, diff --git a/src/xmldsig/uri.rs b/src/xmldsig/uri.rs index a27881fe..d5cb431a 100644 --- a/src/xmldsig/uri.rs +++ b/src/xmldsig/uri.rs @@ -4,18 +4,21 @@ //! [XMLDSig §4.3.3.2](https://www.w3.org/TR/xmldsig-core1/#sec-Same-Document): //! //! - **Empty URI** (`""` or absent): the entire document, excluding comments. -//! - **Bare-name `#id`**: the element whose ID attribute matches `id`, as a subtree. +//! - **Bare-name `#id`**: the element whose ID attribute matches `id`, as a subtree +//! with comments removed by the XMLDSig same-document dereference rule. //! - **`#xpointer(/)`**: the entire document, including comments. -//! - **`#xpointer(id('id'))` / `#xpointer(id("id"))`**: element by ID (equivalent to bare-name). +//! - **`#xpointer(id('id'))` / `#xpointer(id("id"))`**: element by ID, with comments retained. //! -//! External URIs (http://, file://, etc.) are not supported — only same-document -//! references are needed for SAML signature verification. +//! External URI bytes are resolved only from an explicit caller-owned map; this +//! module never performs network or filesystem I/O. use std::collections::hash_map::Entry; use std::collections::{HashMap, HashSet}; use roxmltree::{Document, Node, NodeId}; +use crate::c14n::xml_base::{compute_effective_xml_base, resolve_uri}; + use super::types::{NodeSet, NodeSetMaterializationBudget, TransformData, TransformError}; /// Default ID attribute names to scan when building the ID index. @@ -51,6 +54,7 @@ pub struct UriReferenceResolver<'a> { doc: &'a Document<'a>, /// ID → element node mapping for O(1) fragment lookups. id_map: HashMap<&'a str, Node<'a, 'a>>, + external_resources: Option<&'a HashMap>>, } impl<'a> UriReferenceResolver<'a> { @@ -117,7 +121,19 @@ impl<'a> UriReferenceResolver<'a> { } } - Self { doc, id_map } + Self { + doc, + id_map, + external_resources: None, + } + } + + /// Attach an explicit caller-owned external-resource map. + /// + /// No network or filesystem access is performed by this resolver. + pub fn with_external_resources(mut self, resources: &'a HashMap>) -> Self { + self.external_resources = Some(resources); + self } /// Dereference a URI string to a [`TransformData`]. @@ -127,9 +143,10 @@ impl<'a> UriReferenceResolver<'a> { /// | URI | Result | /// |-----|--------| /// | `""` (empty) | Entire document, comments excluded | - /// | `"#foo"` | Subtree rooted at element with ID `foo` | + /// | `"#foo"` | Subtree rooted at element with ID `foo`, comments excluded | /// | `"#xpointer(/)"` | Entire document, comments included | - /// | `"#xpointer(id('foo'))"` | Subtree rooted at element with ID `foo` | + /// | `"#xpointer(id('foo'))"` | Subtree rooted at element with ID `foo`, comments included | + /// | external URI in caller map | A copy of the mapped bytes | /// | other | `Err(UnsupportedUri)` | pub fn dereference(&self, uri: &str) -> Result, TransformError> { self.dereference_with_optional_budget(uri, None) @@ -143,6 +160,23 @@ impl<'a> UriReferenceResolver<'a> { self.dereference_with_optional_budget(uri, Some(budget)) } + pub(crate) fn dereference_from_with_budget( + &self, + uri: &str, + origin: Node<'_, '_>, + budget: &NodeSetMaterializationBudget, + ) -> Result, TransformError> { + // XMLDSig assigns special dereference semantics to lexical empty and + // fragment-only references. Only external references use XML Base. + if uri.is_empty() || uri.starts_with('#') { + return self.dereference_with_budget(uri, budget); + } + let resolved = compute_effective_xml_base(origin, None) + .map(|base| resolve_uri(&base, uri)) + .unwrap_or_else(|| uri.to_owned()); + self.dereference_with_budget(&resolved, budget) + } + fn dereference_with_optional_budget( &self, uri: &str, @@ -166,7 +200,10 @@ impl<'a> UriReferenceResolver<'a> { // xmlsec1 also passes fragments through without decoding. self.dereference_fragment(fragment, budget) } else { - Err(TransformError::UnsupportedUri(uri.to_string())) + self.external_resources + .and_then(|resources| resources.get(uri)) + .map(|bytes| TransformData::Binary(bytes.clone())) + .ok_or_else(|| TransformError::UnsupportedUri(uri.to_string())) } } @@ -174,7 +211,7 @@ impl<'a> UriReferenceResolver<'a> { /// /// Handles: /// - `xpointer(/)` → entire document (with comments, per XPointer spec) - /// - `xpointer(id('foo'))` → element by ID (equivalent to bare-name `#foo`) + /// - `xpointer(id('foo'))` → element by ID, retaining comments /// - bare name `foo` → element by ID attribute fn dereference_fragment( &self, @@ -198,18 +235,18 @@ impl<'a> UriReferenceResolver<'a> { }; Ok(TransformData::NodeSet(nodes)) } else if let Some(id) = parse_xpointer_id_fragment(fragment) { - // xpointer(id('foo')) → same as bare-name #foo + // XPointer dereference retains comments, unlike a bare-name fragment. // Reject empty parsed ID (e.g., xpointer(id(''))) — not a valid XML Name if id.is_empty() { return Err(TransformError::UnsupportedUri(format!("#{fragment}"))); } - self.resolve_id(id, budget) + self.resolve_id(id, budget, true) } else if fragment.starts_with("xpointer(") { // Any other XPointer expression is unsupported Err(TransformError::UnsupportedUri(format!("#{fragment}"))) } else { // Bare-name fragment: #foo → element by ID - self.resolve_id(fragment, budget) + self.resolve_id(fragment, budget, false) } } @@ -218,12 +255,17 @@ impl<'a> UriReferenceResolver<'a> { &self, id: &str, budget: Option<&NodeSetMaterializationBudget>, + with_comments: bool, ) -> Result, TransformError> { match self.id_map.get(id) { Some(&element) => { - let nodes = match budget { - Some(budget) => NodeSet::subtree_with_budget(element, budget)?, - None => NodeSet::subtree(element)?, + let nodes = if with_comments { + match budget { + Some(budget) => NodeSet::subtree_with_budget(element, budget)?, + None => NodeSet::subtree(element)?, + } + } else { + NodeSet::subtree_without_comments_with_budget(element, budget)? }; Ok(TransformData::NodeSet(nodes)) } @@ -244,6 +286,14 @@ impl<'a> UriReferenceResolver<'a> { self.id_map.get(id).map(|node| node.id()) } + pub(crate) fn node_for_id(&self, id: &str) -> Option> { + self.id_map.get(id).copied() + } + + pub(crate) fn node_for_node_id(&self, id: NodeId) -> Option> { + self.doc.get_node(id) + } + /// Get the number of registered IDs. pub fn id_count(&self) -> usize { self.id_map.len() @@ -266,6 +316,21 @@ pub(crate) fn parse_xpointer_id_fragment(fragment: &str) -> Option<&str> { } } +/// Extract the ID selected by a supported same-document URI. +/// +/// This keeps secondary consumers such as KeyInfo and Manifest processing in +/// lockstep with the resolver's bare-fragment and XPointer ID semantics. +pub(crate) fn same_document_reference_id(uri: &str) -> Option<&str> { + let fragment = uri.strip_prefix('#')?; + if fragment.is_empty() || fragment == "xpointer(/)" { + return None; + } + if let Some(id) = parse_xpointer_id_fragment(fragment) { + return (!id.is_empty()).then_some(id); + } + (!fragment.starts_with("xpointer(")).then_some(fragment) +} + #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { @@ -480,6 +545,148 @@ mod tests { assert!(data.into_node_set().is_ok()); } + #[test] + fn absolute_external_uri_uses_normalized_resource_identity() { + // Caller maps are keyed by the resolved RFC 3986 identity, not by an + // unnormalized spelling embedded in an untrusted Signature document. + let xml = r#" + + "#; + let doc = Document::parse(xml).unwrap(); + let reference = doc + .descendants() + .find(|node| node.has_tag_name("reference")) + .unwrap(); + let resources = HashMap::from([( + "https://example.test/data.bin".to_owned(), + b"payload".to_vec(), + )]); + let budget = NodeSetMaterializationBudget::default(); + let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources); + + let data = resolver + .dereference_from_with_budget(reference.attribute("URI").unwrap(), reference, &budget) + .unwrap(); + + assert_eq!(data.into_binary().unwrap(), b"payload"); + } + + #[test] + fn pathless_relative_xml_base_preserves_relative_resource_identity() { + // Query-only xml:base values do not turn a relative URI into an + // absolute-path reference when resolving caller-owned resources. + let xml = r#" + + "#; + let doc = Document::parse(xml).unwrap(); + let reference = doc + .descendants() + .find(|node| node.has_tag_name("reference")) + .unwrap(); + let resources = HashMap::from([("data.bin".to_owned(), b"payload".to_vec())]); + let budget = NodeSetMaterializationBudget::default(); + let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources); + + let data = resolver + .dereference_from_with_budget(reference.attribute("URI").unwrap(), reference, &budget) + .unwrap(); + + assert_eq!(data.into_binary().unwrap(), b"payload"); + } + + #[test] + fn relative_xml_base_normalizes_absolute_external_path() { + // An absolute-path reference replaces a relative base path, but RFC + // 3986 dot-segment removal still defines the caller resource identity. + let xml = r#" + + "#; + let doc = Document::parse(xml).unwrap(); + let reference = doc + .descendants() + .find(|node| node.has_tag_name("reference")) + .unwrap(); + let resources = HashMap::from([("/data.bin".to_owned(), b"payload".to_vec())]); + let budget = NodeSetMaterializationBudget::default(); + let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources); + + let data = resolver + .dereference_from_with_budget(reference.attribute("URI").unwrap(), reference, &budget) + .unwrap(); + + assert_eq!(data.into_binary().unwrap(), b"payload"); + } + + #[test] + fn network_path_xml_base_preserves_external_resource_authority() { + // A schemeless authority remains part of the resolved caller-owned + // resource identity when an absolute-path URI replaces the base path. + let xml = r#" + + "#; + let doc = Document::parse(xml).unwrap(); + let reference = doc + .descendants() + .find(|node| node.has_tag_name("reference")) + .unwrap(); + let resources = HashMap::from([("//cdn.example/data.bin".to_owned(), b"payload".to_vec())]); + let budget = NodeSetMaterializationBudget::default(); + let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources); + + let data = resolver + .dereference_from_with_budget(reference.attribute("URI").unwrap(), reference, &budget) + .unwrap(); + + assert_eq!(data.into_binary().unwrap(), b"payload"); + } + + #[test] + fn unicode_external_uri_resolves_without_panicking() { + // Untrusted XML may start a relative URI with a multibyte scalar; the + // resolver must produce its UTF-8 resource identity without panicking. + let xml = r#" + + "#; + let doc = Document::parse(xml).unwrap(); + let reference = doc + .descendants() + .find(|node| node.has_tag_name("reference")) + .unwrap(); + let resources = HashMap::from([( + "https://example.test/base/é?x".to_owned(), + b"payload".to_vec(), + )]); + let budget = NodeSetMaterializationBudget::default(); + let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources); + + let data = resolver + .dereference_from_with_budget(reference.attribute("URI").unwrap(), reference, &budget) + .unwrap(); + + assert_eq!(data.into_binary().unwrap(), b"payload"); + } + + #[test] + fn absolute_rootless_external_uri_discards_leading_parent_segment() { + let xml = r#" + + "#; + let doc = Document::parse(xml).unwrap(); + let reference = doc + .descendants() + .find(|node| node.has_tag_name("reference")) + .unwrap(); + let resources = HashMap::from([("urn:payload".to_owned(), b"payload".to_vec())]); + let budget = NodeSetMaterializationBudget::default(); + let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources); + + let data = resolver + .dereference_from_with_budget(reference.attribute("URI").unwrap(), reference, &budget) + .unwrap(); + + assert_eq!(data.into_binary().unwrap(), b"payload"); + } + #[test] fn namespaced_id_attr_found_by_local_name() { // roxmltree strips prefix: `wsu:Id` → local name "Id", which is in DEFAULT_ID_ATTRS @@ -564,8 +771,8 @@ mod tests { } #[test] - fn subtree_includes_comments() { - // Subtree dereference (via #id) includes comments, unlike empty URI + fn bare_name_subtree_excludes_comments() { + // XMLDSig's bare-name same-document shortcut removes comment nodes. let xml = r#""#; let doc = Document::parse(xml).unwrap(); let resolver = UriReferenceResolver::new(&doc); @@ -576,8 +783,8 @@ mod tests { for node in doc.descendants() { if node.is_comment() { assert!( - node_set.contains(node), - "comment should be included in #id subtree" + !node_set.contains(node), + "comment must be excluded from #id" ); } } @@ -606,7 +813,8 @@ mod tests { #[test] fn xpointer_id_single_quotes() { - let xml = r#"content"#; + // XPointer ID dereference retains comments, unlike bare-name fragments. + let xml = r#"content"#; let doc = Document::parse(xml).unwrap(); let resolver = UriReferenceResolver::new(&doc); @@ -618,6 +826,10 @@ mod tests { .find(|n| n.attribute("ID") == Some("abc")) .unwrap(); assert!(node_set.contains(elem)); + assert!( + elem.children() + .any(|node| node.is_comment() && node_set.contains(node)) + ); } #[test] @@ -691,6 +903,29 @@ mod tests { ); } + #[test] + fn same_document_reference_id_rejects_non_id_fragments() { + assert_eq!(super::same_document_reference_id("#target"), Some("target")); + assert_eq!( + super::same_document_reference_id("#xpointer(id('target'))"), + Some("target") + ); + assert_eq!( + super::same_document_reference_id(r#"#xpointer(id("target"))"#), + Some("target") + ); + for uri in [ + "", + "target", + "#", + "#xpointer(/)", + "#xpointer(id(''))", + "#xpointer(id(target))", + ] { + assert_eq!(super::same_document_reference_id(uri), None, "{uri}"); + } + } + #[test] fn same_element_multiple_id_attrs_not_duplicate() { // An element with both ID="x" and Id="x" should NOT be treated as diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index cc4710cc..1a58ae4e 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -12,30 +12,41 @@ use base64::Engine; use roxmltree::{Document, Node, NodeId}; -use std::collections::HashSet; +use std::cell::Cell; +use std::collections::{HashMap, HashSet}; -use crate::c14n::canonicalize; +use crate::c14n::{canonicalize_bounded, is_output_limit_error}; +use crate::hard_limits::{CANONICALIZED_SIGNATURE_DATA_BYTE_CEILING, XML_DOCUMENT_NODE_CEILING}; -use super::digest::{DigestAlgorithm, compute_digest, constant_time_eq}; +#[cfg(test)] +use super::digest::compute_digest; +use super::digest::{DigestAlgorithm, constant_time_eq}; +#[cfg(test)] +use super::parse::MAX_REFERENCES_PER_SIGNATURE; use super::parse::{ - KeyInfo, MAX_REFERENCES_PER_SIGNATURE, ParseError, Reference, SignatureAlgorithm, XMLDSIG_NS, + KeyInfo, MAX_X509_DATA_TOTAL_BINARY_LEN, MAX_X509_DECODED_BINARY_LEN, ParseError, Reference, + RetrievalMethodTransforms, SignatureAlgorithm, XMLDSIG_NS, }; use super::parse::{ parse_key_info, parse_reference_with_xpath_budget, parse_signed_info_with_xpath_budget, + parse_x509_certificate, parse_x509_data_dispatch_with_budget, reference_digest_method, }; use super::signature::{ - SignatureVerificationError, verify_ecdsa_signature_pem, verify_rsa_signature_pem, + SignatureVerificationError, verify_dsa_signature_spki, verify_ecdsa_signature_pem, + verify_rsa_signature_pem, }; +#[cfg(test)] +use super::transforms::{BASE64_TRANSFORM_URI, XPATH_TRANSFORM_URI}; use super::transforms::{ - BASE64_TRANSFORM_URI, DEFAULT_IMPLICIT_C14N_URI, Transform, TransformExecutionBudget, - TransformOptions, XPATH_TRANSFORM_URI, XPathHereSemantics, XPathSignatureParseBudget, - execute_transforms_with_options_and_budget, + DEFAULT_IMPLICIT_C14N_URI, Transform, TransformExecutionBudget, TransformOptions, + XPathHereSemantics, XPathSignatureParseBudget, execute_transforms_with_options_and_budget, }; -use super::uri::{UriReferenceResolver, parse_xpointer_id_fragment}; +use super::uri::{UriReferenceResolver, same_document_reference_id}; use super::whitespace::{is_xml_whitespace_only, normalize_xml_base64_bytes}; const MAX_SIGNATURE_VALUE_LEN: usize = 8192; const MAX_SIGNATURE_VALUE_TEXT_LEN: usize = 65_536; +const MAX_RETRIEVAL_METHOD_COUNT: usize = 64; /// Cryptographic verifier used by [`VerifyContext`]. /// /// This trait intentionally has no `Send + Sync` supertraits so lightweight @@ -67,6 +78,20 @@ pub trait KeyResolver { algorithm: SignatureAlgorithm, ) -> Result>, DsigError>; + /// Resolve under the operation's immutable policy snapshot. + /// + /// Implementations that make trust or key-source decisions must override + /// this method. The default preserves source-only custom resolvers whose + /// behavior is independent of policy. + fn resolve_with_policy<'a>( + &'a self, + key_info: Option<&KeyInfo>, + algorithm: SignatureAlgorithm, + _policy: &crate::policy::VerificationPolicy, + ) -> Result>, DsigError> { + self.resolve(key_info, algorithm) + } + /// Return `true` when this resolver consumes document `` material. /// /// The verification pipeline uses this to decide whether malformed @@ -80,9 +105,8 @@ pub trait KeyResolver { /// Allowed URI classes for ``. /// -/// Note: `UriReferenceResolver` currently supports only same-document URIs. -/// Allowing external URIs via this policy only disables the early policy -/// rejection; dereference still fails until an external resolver path is added. +/// External URIs resolve only from bytes supplied through +/// [`VerifyContext::external_resources`]; allowing them never enables I/O. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[must_use = "pass the policy to VerifyContext::allowed_uri_types(), or store it for reuse"] pub struct UriTypeSet { @@ -91,6 +115,23 @@ pub struct UriTypeSet { allow_external: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UriClass { + Empty, + SameDocument, + External, +} + +fn classify_uri(uri: &str) -> UriClass { + if uri.is_empty() { + UriClass::Empty + } else if uri.starts_with('#') { + UriClass::SameDocument + } else { + UriClass::External + } +} + impl UriTypeSet { /// Create a custom URI policy. pub const fn new(allow_empty: bool, allow_same_document: bool, allow_external: bool) -> Self { @@ -110,8 +151,7 @@ impl UriTypeSet { /// Allow all URI classes. /// - /// This includes external URI classes at policy level, but external - /// dereference is not implemented yet by the default resolver. + /// External URIs still require an explicit caller-owned resource map. pub const ALL: Self = Self { allow_empty: true, allow_same_document: true, @@ -119,13 +159,11 @@ impl UriTypeSet { }; fn allows(self, uri: &str) -> bool { - if uri.is_empty() { - return self.allow_empty; + match classify_uri(uri) { + UriClass::Empty => self.allow_empty, + UriClass::SameDocument => self.allow_same_document, + UriClass::External => self.allow_external, } - if uri.starts_with('#') { - return self.allow_same_document; - } - self.allow_external } } @@ -140,11 +178,10 @@ impl Default for UriTypeSet { pub struct VerifyContext<'a> { key: Option<&'a dyn VerifyingKey>, key_resolver: Option<&'a dyn KeyResolver>, - process_manifests: bool, - allowed_uri_types: UriTypeSet, - allowed_transforms: Option>, + policy: crate::policy::VerificationPolicy, + provider: &'a dyn crate::provider::CryptoProvider, store_pre_digest: bool, - transform_options: TransformOptions, + external_resources: Option<&'a HashMap>>, } impl<'a> VerifyContext<'a> { @@ -160,11 +197,10 @@ impl<'a> VerifyContext<'a> { Self { key: None, key_resolver: None, - process_manifests: false, - allowed_uri_types: UriTypeSet::default(), - allowed_transforms: None, + policy: crate::policy::VerificationPolicy::default(), + provider: crate::provider::default_provider(), store_pre_digest: false, - transform_options: TransformOptions::default(), + external_resources: None, } } @@ -180,6 +216,18 @@ impl<'a> VerifyContext<'a> { self } + /// Replace the complete immutable verification policy snapshot. + pub fn policy(mut self, policy: crate::policy::VerificationPolicy) -> Self { + self.policy = policy; + self + } + + /// Select the cryptographic provider for this verification operation. + pub fn provider(mut self, provider: &'a dyn crate::provider::CryptoProvider) -> Self { + self.provider = provider; + self + } + /// Enable or disable `` processing. /// /// When enabled, references in `` elements that are direct @@ -211,13 +259,41 @@ impl<'a> VerifyContext<'a> { /// Structural/parse errors in Manifest content abort `verify()` and are /// returned as `Err(...)`. pub fn process_manifests(mut self, enabled: bool) -> Self { - self.process_manifests = enabled; + self.policy.process_manifests = enabled; self } /// Restrict allowed reference URI classes. pub fn allowed_uri_types(mut self, types: UriTypeSet) -> Self { - self.allowed_uri_types = types; + self.policy.reference_uri_types = types; + self + } + + /// Restrict URI classes used to retrieve key material from ``. + /// + /// This policy is independent from [`Self::allowed_uri_types`]: allowing an + /// external signed payload does not implicitly allow external key retrieval. + /// Same-document retrieval is enabled by default; external retrieval requires + /// an explicit opt-in and still uses only caller-supplied resources. + pub fn allowed_retrieval_method_uri_types(mut self, types: UriTypeSet) -> Self { + self.policy.retrieval_uri_types = 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`]. + pub fn external_resources(mut self, resources: &'a HashMap>) -> Self { + self.external_resources = Some(resources); + self + } + + /// Allow bounded internal DTD declarations while keeping external entity + /// resolution disabled. This is off by default. + pub fn allow_internal_dtd(mut self, enabled: bool) -> Self { + self.policy.xml.allow_internal_dtd = enabled; self } @@ -236,11 +312,17 @@ impl<'a> VerifyContext<'a> { I: IntoIterator, S: Into, { - self.allowed_transforms = Some(transforms.into_iter().map(Into::into).collect()); + self.policy.transforms = Some(transforms.into_iter().map(Into::into).collect()); self } /// Store pre-digest buffers for diagnostics. + /// + /// Retained reference buffers and canonicalized `` share a + /// non-configurable 32 MiB safety ceiling. Canonicalized `` is + /// charged even when diagnostic retention is disabled because signature + /// verification always materializes it. Verification returns + /// [`ReferenceProcessingError::CanonicalizedDataTooLarge`] on overflow. pub fn store_pre_digest(mut self, enabled: bool) -> Self { self.store_pre_digest = enabled; self @@ -252,12 +334,18 @@ impl<'a> VerifyContext<'a> { /// Use [`XPathHereSemantics::XmlSecLegacy`] only for documents known to /// have been generated with libxmlsec1's `` interpretation. pub fn xpath_here_semantics(mut self, semantics: XPathHereSemantics) -> Self { - self.transform_options = self.transform_options.xpath_here_semantics(semantics); + self.policy.xpath_here_semantics = semantics; self } fn allowed_transform_uris(&self) -> Option<&HashSet> { - self.allowed_transforms.as_ref() + self.policy.transforms.as_ref() + } + + fn transform_options(&self) -> TransformOptions { + TransformOptions::default() + .allow_internal_dtd(self.policy.xml.allow_internal_dtd) + .xpath_here_semantics(self.policy.xpath_here_semantics) } /// Verify one XMLDSig signature using this context. @@ -382,7 +470,8 @@ impl ReferencesResult { /// - `signature_node`: The `` element (for enveloped-signature transform). /// - `reference_set`: Whether this reference belongs to `` or ``. /// - `reference_index`: Zero-based index of this reference inside `reference_set`. -/// - `store_pre_digest`: If true, store the pre-digest bytes in the result. +/// - `store_pre_digest`: If true, store the pre-digest bytes in the result, +/// subject to the signature-wide diagnostic retention ceiling. /// /// # Errors /// @@ -398,10 +487,13 @@ pub fn process_reference( store_pre_digest: bool, ) -> Result { let execution_budget = TransformExecutionBudget::default(); + let canonicalized_data_budget = CanonicalizedDataBudget::default(); let execution = ReferenceExecutionContext { store_pre_digest, transform_options: TransformOptions::default(), transform_budget: &execution_budget, + canonicalized_data_budget: &canonicalized_data_budget, + provider: crate::provider::default_provider(), }; process_reference_with_options( reference, @@ -409,14 +501,95 @@ pub fn process_reference( signature_node, reference_set, reference_index, + reference_origin_node(signature_node, reference_set, reference_index), &execution, ) } +fn reference_origin_node<'a, 'input>( + signature_node: Node<'a, 'input>, + reference_set: ReferenceSet, + reference_index: usize, +) -> Option> { + let is_reference = |node: &Node<'_, '_>| { + node.is_element() + && node.tag_name().namespace() == Some(XMLDSIG_NS) + && node.tag_name().name() == "Reference" + }; + match reference_set { + ReferenceSet::SignedInfo => signature_node + .children() + .find(|node| { + node.is_element() + && node.tag_name().namespace() == Some(XMLDSIG_NS) + && node.tag_name().name() == "SignedInfo" + })? + .children() + .filter(is_reference) + .nth(reference_index), + ReferenceSet::Manifest => signature_node + .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), + } +} + struct ReferenceExecutionContext<'a> { store_pre_digest: bool, transform_options: TransformOptions, transform_budget: &'a TransformExecutionBudget, + canonicalized_data_budget: &'a CanonicalizedDataBudget, + provider: &'a dyn crate::provider::CryptoProvider, +} + +struct CanonicalizedDataBudget { + remaining: Cell, + max_bytes: usize, +} + +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 Some(remaining) = self.remaining.get().checked_sub(bytes) else { + self.remaining.set(0); + return Err(ReferenceProcessingError::CanonicalizedDataTooLarge { + max_bytes: self.max_bytes, + }); + }; + 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( @@ -425,6 +598,7 @@ fn process_reference_with_options( signature_node: Node<'_, '_>, reference_set: ReferenceSet, reference_index: usize, + reference_node: Option>, execution: &ReferenceExecutionContext<'_>, ) -> Result { // 1. Dereference URI. Omitted URI is distinct from URI="" in XMLDSig and @@ -433,8 +607,22 @@ fn process_reference_with_options( .uri .as_deref() .ok_or(ReferenceProcessingError::MissingUri)?; - let initial_data = resolver - .dereference_with_budget(uri, execution.transform_budget.node_set_materialization()) + let initial_data = reference_node + .map_or_else( + || { + resolver.dereference_with_budget( + uri, + execution.transform_budget.node_set_materialization(), + ) + }, + |node| { + resolver.dereference_from_with_budget( + uri, + node, + execution.transform_budget.node_set_materialization(), + ) + }, + ) .map_err(ReferenceProcessingError::UriDereference)?; // 2. Apply transform chain @@ -448,7 +636,11 @@ fn process_reference_with_options( .map_err(ReferenceProcessingError::Transform)?; // 3. Compute digest - let computed_digest = compute_digest(reference.digest_method, &pre_digest_bytes); + let computed_digest = super::compute_digest_with_provider( + execution.provider, + reference.digest_method, + &pre_digest_bytes, + )?; // 4. Compare with stored DigestValue (constant-time) let status = if constant_time_eq(&computed_digest, &reference.digest_value) { @@ -459,17 +651,22 @@ fn process_reference_with_options( }) }; + 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_set, reference_index, uri: uri.to_owned(), digest_algorithm: reference.digest_method, status, - pre_digest_data: if execution.store_pre_digest { - Some(pre_digest_bytes) - } else { - None - }, + pre_digest_data, }) } @@ -491,10 +688,13 @@ pub fn process_all_references( store_pre_digest: bool, ) -> Result { let execution_budget = TransformExecutionBudget::default(); + let canonicalized_data_budget = CanonicalizedDataBudget::default(); let execution = ReferenceExecutionContext { store_pre_digest, transform_options: TransformOptions::default(), transform_budget: &execution_budget, + canonicalized_data_budget: &canonicalized_data_budget, + provider: crate::provider::default_provider(), }; process_all_references_with_options(references, resolver, signature_node, &execution) } @@ -514,6 +714,7 @@ fn process_all_references_with_options( signature_node, ReferenceSet::SignedInfo, i, + reference_origin_node(signature_node, ReferenceSet::SignedInfo, i), execution, )?; let failed = matches!(result.status, DsigStatus::Invalid(_)); @@ -539,6 +740,10 @@ fn process_all_references_with_options( #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum ReferenceProcessingError { + /// The selected provider could not compute the declared digest. + #[error("cryptographic provider error: {0}")] + Provider(#[from] crate::provider::ProviderError), + /// `` omitted the `URI` attribute, which we do not resolve implicitly. #[error("reference URI is required; omitted URI references are not supported")] MissingUri, @@ -550,6 +755,13 @@ pub enum ReferenceProcessingError { /// Transform execution failed. #[error("transform failed: {0}")] Transform(#[source] super::types::TransformError), + + /// Canonicalized signature data would exceed its signature-wide cap. + #[error("canonicalized signature data exceeds signature-wide maximum of {max_bytes} bytes")] + CanonicalizedDataTooLarge { + /// Maximum bytes consumed by canonicalized SignedInfo and retained diagnostics. + max_bytes: usize, + }, } /// End-to-end XMLDSig verification result for one ``. @@ -585,6 +797,14 @@ pub struct VerifyResult { #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum DsigError { + /// The compiled verification policy rejected an operation input. + #[error("verification policy violation: {0}")] + Policy(#[from] crate::policy::PolicyViolation), + + /// The selected provider cannot execute the requested operation. + #[error("cryptographic provider error: {0}")] + Provider(#[from] crate::provider::ProviderError), + /// XML parsing failed. #[error("XML parse error: {0}")] XmlParse(#[from] roxmltree::Error), @@ -710,7 +930,16 @@ fn verify_signature_with_context( xml: &str, ctx: &VerifyContext<'_>, ) -> Result { - let doc = Document::parse(xml)?; + ctx.policy.validate()?; + let doc = Document::parse_with_options( + xml, + roxmltree::ParsingOptions { + allow_dtd: ctx.policy.xml.allow_internal_dtd, + nodes_limit: u32::try_from(ctx.policy.resources.max_xml_nodes) + .unwrap_or(XML_DOCUMENT_NODE_CEILING), + entity_resolver: None, + }, + )?; let mut signatures = doc.descendants().filter(|node| { node.is_element() && node.tag_name().name() == "Signature" @@ -737,7 +966,7 @@ fn verify_signature_with_context( (None, Some(resolver)) => resolver.consumes_document_key_info(), (None, None) => true, }; - let key_info = if should_parse_key_info { + let mut key_info = if should_parse_key_info { signature_children .key_info_node .map(parse_key_info) @@ -750,18 +979,95 @@ fn verify_signature_with_context( let mut xpath_parse_budget = XPathSignatureParseBudget::default(); let signed_info = parse_signed_info_with_xpath_budget(signed_info_node, &mut xpath_parse_budget)?; + if signed_info.references.len() > ctx.policy.resources.max_references { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: "signature references", + maximum: ctx.policy.resources.max_references, + actual: signed_info.references.len(), + } + .into()); + } + for reference in &signed_info.references { + if reference.transforms.len() > ctx.policy.resources.max_transforms_per_reference { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: "reference transforms", + maximum: ctx.policy.resources.max_transforms_per_reference, + actual: reference.transforms.len(), + } + .into()); + } + } + ctx.policy + .check_signature_algorithm(signed_info.signature_method)?; + for reference in &signed_info.references { + if ctx + .policy + .digest_algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(&reference.digest_method)) + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "verification", + algorithm: reference.digest_method.uri().to_string(), + } + .into()); + } + } enforce_reference_policies( &signed_info.references, - ctx.allowed_uri_types, + ctx.policy.reference_uri_types, ctx.allowed_transform_uris(), )?; - let resolver = UriReferenceResolver::new(&doc); + if let Some(resources) = ctx.external_resources { + let mut total = 0usize; + for bytes in resources.values() { + if bytes.len() > ctx.policy.resources.max_external_resource_bytes { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: "external resource bytes", + maximum: ctx.policy.resources.max_external_resource_bytes, + actual: bytes.len(), + } + .into()); + } + total = total.checked_add(bytes.len()).ok_or( + SignatureVerificationPipelineError::InvalidStructure { + reason: "external resource total length overflow", + }, + )?; + } + if total > ctx.policy.resources.max_external_resource_total_bytes { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: "aggregate external resource bytes", + maximum: ctx.policy.resources.max_external_resource_total_bytes, + actual: total, + } + .into()); + } + } + let resolver = match ctx.external_resources { + Some(resources) => UriReferenceResolver::new(&doc).with_external_resources(resources), + None => UriReferenceResolver::new(&doc), + }; + let retrieval_materialization = if let Some(info) = key_info.as_mut() { + materialize_retrieval_methods( + info, + &resolver, + ctx.external_resources, + ctx.policy.retrieval_uri_types, + )? + } else { + RetrievalMaterialization::default() + }; let execution_budget = TransformExecutionBudget::default(); + let canonicalized_data_budget = + CanonicalizedDataBudget::with_limit(ctx.policy.resources.max_canonicalized_bytes); let execution = ReferenceExecutionContext { store_pre_digest: ctx.store_pre_digest, - transform_options: ctx.transform_options, + transform_options: ctx.transform_options(), transform_budget: &execution_budget, + canonicalized_data_budget: &canonicalized_data_budget, + provider: ctx.provider, }; let references = process_all_references_with_options( &signed_info.references, @@ -785,17 +1091,41 @@ fn verify_signature_with_context( .map(|node: Node<'_, '_>| node.id()) .collect(); let mut canonical_signed_info = Vec::new(); - canonicalize( + canonicalize_bounded( &doc, Some(&|node| signed_info_subtree.contains(&node.id())), &signed_info.c14n_method, + canonicalized_data_budget.remaining(), &mut canonical_signed_info, - )?; + ) + .map_err(|error| { + if is_output_limit_error(&error) { + SignatureVerificationPipelineError::Reference( + ReferenceProcessingError::CanonicalizedDataTooLarge { + max_bytes: canonicalized_data_budget.max_bytes, + }, + ) + } else { + SignatureVerificationPipelineError::Canonicalization(error) + } + })?; + canonicalized_data_budget.charge(canonical_signed_info.len())?; let signature_value = decode_signature_value(signature_children.signature_value_node)?; + if signed_info.signature_method == SignatureAlgorithm::HmacSha1 { + let expected_bits = signed_info.hmac_output_length_bits.unwrap_or(160); + if signature_value.len() != expected_bits / 8 { + return Err(SignatureVerificationPipelineError::InvalidStructure { + reason: "SignatureValue length does not match HMACOutputLength", + }); + } + } let Some(resolved_key) = resolve_verifying_key(ctx, key_info.as_ref(), signed_info.signature_method)? else { + if let Some(error) = retrieval_materialization.deferred_error { + return Err(error); + } return Ok(VerifyResult { status: DsigStatus::Invalid(FailureReason::KeyNotFound), signed_info_references: references.results, @@ -808,7 +1138,8 @@ fn verify_signature_with_context( }); }; let verifier = resolved_key.as_ref(); - let signature_valid = verifier.verify( + let signature_valid = ctx.provider.verify( + verifier, signed_info.signature_method, &canonical_signed_info, &signature_value, @@ -827,14 +1158,23 @@ fn verify_signature_with_context( }); } - let manifest_references = if ctx.process_manifests { + let manifest_references = if ctx.policy.process_manifests { let signed_info_reference_nodes = collect_authenticated_signed_info_reference_nodes(&signed_info.references, &resolver); + let remaining_reference_capacity = ctx + .policy + .resources + .max_references + .checked_sub(signed_info.references.len()) + .ok_or(SignatureVerificationPipelineError::InvalidStructure { + reason: "SignedInfo exceeds the per-signature Reference limit", + })?; process_manifest_references( signature_node, &resolver, ctx, &signed_info_reference_nodes, + remaining_reference_capacity, &execution, &mut xpath_parse_budget, )? @@ -854,27 +1194,255 @@ fn verify_signature_with_context( }) } +#[derive(Debug, Default)] +struct RetrievalMaterialization { + deferred_error: Option, +} + +fn materialize_retrieval_methods( + key_info: &mut KeyInfo, + resolver: &UriReferenceResolver<'_>, + external_resources: Option<&HashMap>>, + allowed_uri_types: UriTypeSet, +) -> Result { + 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, + resource_type, + transforms, + } = source + else { + materialized.push(source); + continue; + }; + + let identity = (uri.clone(), resource_type.clone(), transforms); + if !seen.insert(identity) { + continue; + } + + if resource_type.as_deref() == Some("http://www.w3.org/2000/09/xmldsig#rawX509Certificate") + { + if transforms != RetrievalMethodTransforms::None + || classify_uri(&uri) != UriClass::External + { + return Err(SignatureVerificationPipelineError::InvalidStructure { + reason: "raw X509 RetrievalMethod requires an untransformed external URI", + }); + } + if !allowed_uri_types.allows(&uri) { + return Err(SignatureVerificationPipelineError::DisallowedUri { uri }); + } + let Some(certificate) = external_resources.and_then(|resources| resources.get(&uri)) + else { + outcome.deferred_error.get_or_insert_with(|| { + SignatureVerificationPipelineError::Reference( + ReferenceProcessingError::Transform(super::TransformError::UnsupportedUri( + uri.clone(), + )), + ) + }); + materialized.push(super::parse::KeyInfoSource::RetrievalMethod { + uri, + resource_type, + transforms, + }); + continue; + }; + if certificate.len() > MAX_X509_DECODED_BINARY_LEN { + return Err(SignatureVerificationPipelineError::InvalidStructure { + reason: "raw X509 RetrievalMethod certificate exceeds maximum allowed length", + }); + } + add_retrieval_binary_usage(&mut total_binary_len, certificate.len())?; + let parsed = match parse_x509_certificate(certificate) { + Ok(parsed) => parsed, + Err(error) => { + outcome + .deferred_error + .get_or_insert(SignatureVerificationPipelineError::ParseKeyInfo(error)); + materialized.push(super::parse::KeyInfoSource::RetrievalMethod { + uri, + resource_type, + transforms, + }); + continue; + } + }; + materialized.push(super::parse::KeyInfoSource::X509Data( + super::parse::X509DataInfo { + certificates: vec![certificate.clone()], + parsed_certificates: vec![parsed], + certificate_chain: vec![0], + ..super::parse::X509DataInfo::default() + }, + )); + } else if resource_type.as_deref() == Some("http://www.w3.org/2000/09/xmldsig#X509Data") { + if !allowed_uri_types.allows(&uri) { + return Err(SignatureVerificationPipelineError::DisallowedUri { uri }); + } + let id = same_document_reference_id(&uri).ok_or( + SignatureVerificationPipelineError::InvalidStructure { + reason: "X509Data RetrievalMethod requires a same-document URI", + }, + )?; + let target = resolver.node_for_id(id).ok_or( + SignatureVerificationPipelineError::InvalidStructure { + reason: "X509Data RetrievalMethod target is missing or ambiguous", + }, + )?; + let node = match transforms { + RetrievalMethodTransforms::None + if target.has_tag_name((XMLDSIG_NS, "X509Data")) => + { + target + } + RetrievalMethodTransforms::None => { + return Err(SignatureVerificationPipelineError::InvalidStructure { + reason: "untransformed X509Data RetrievalMethod must target X509Data directly", + }); + } + RetrievalMethodTransforms::X509DataNodeSetFilter => { + select_retrieved_x509_data_root(target)? + } + RetrievalMethodTransforms::Unsupported => { + return Err(SignatureVerificationPipelineError::InvalidStructure { + reason: "X509Data RetrievalMethod contains unsupported transforms", + }); + } + }; + let data = parse_x509_data_dispatch_with_budget(node, &mut total_binary_len) + .map_err(SignatureVerificationPipelineError::ParseKeyInfo)?; + materialized.push(super::parse::KeyInfoSource::X509Data(data)); + } else { + materialized.push(super::parse::KeyInfoSource::RetrievalMethod { + uri, + resource_type, + transforms, + }); + } + } + key_info.sources = materialized; + Ok(outcome) +} + +fn select_retrieved_x509_data_root<'a, 'input>( + target: Node<'a, 'input>, +) -> Result, SignatureVerificationPipelineError> { + // XMLDSig XPath filtering evaluates the predicate for every node in the + // dereferenced node-set. `ancestor-or-self::ds:X509Data` therefore retains + // one X509Data descendant and its subtree; it cannot import an ancestor + // that was outside the URI target's node-set. + let mut roots = target.descendants().filter(|candidate| { + candidate.is_element() + && candidate.tag_name().namespace() == Some(XMLDSIG_NS) + && candidate.tag_name().name() == "X509Data" + }); + let root = roots + .next() + .ok_or(SignatureVerificationPipelineError::InvalidStructure { + reason: "X509Data RetrievalMethod selected no X509Data element", + })?; + if roots.next().is_some() { + return Err(SignatureVerificationPipelineError::InvalidStructure { + reason: "X509Data RetrievalMethod selected multiple X509Data elements", + }); + } + Ok(root) +} + +fn existing_x509_binary_len( + key_info: &KeyInfo, +) -> Result { + let mut total = 0usize; + for source in &key_info.sources { + if let super::parse::KeyInfoSource::X509Data(info) = source { + for len in info + .certificates + .iter() + .chain(&info.skis) + .chain(&info.crls) + .map(Vec::len) + .chain(info.digests.iter().map(|(_, digest)| digest.len())) + { + add_retrieval_binary_usage(&mut total, len)?; + } + } + } + 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 process_manifest_references( signature_node: Node<'_, '_>, resolver: &UriReferenceResolver<'_>, ctx: &VerifyContext<'_>, signed_info_reference_nodes: &HashSet, + remaining_reference_capacity: usize, execution: &ReferenceExecutionContext<'_>, xpath_parse_budget: &mut XPathSignatureParseBudget, ) -> Result, SignatureVerificationPipelineError> { - let manifest_references = parse_manifest_references( + let parsed = parse_manifest_references( signature_node, signed_info_reference_nodes, + remaining_reference_capacity, xpath_parse_budget, )?; - if manifest_references.is_empty() { + let manifest_references = parsed.references; + let mut results = parsed.invalid_results; + if manifest_references.is_empty() && results.is_empty() { return Ok(Vec::new()); } - let mut results = Vec::with_capacity(manifest_references.len()); - for (index, reference) in manifest_references.iter().enumerate() { + results.reserve(manifest_references.len()); + for (index, reference, reference_node_id) in &manifest_references { + if ctx + .policy + .digest_algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(&reference.digest_method)) + { + results.push(manifest_reference_invalid_result( + reference, + *index, + FailureReason::ReferencePolicyViolation { ref_index: *index }, + )); + continue; + } match enforce_reference_policies( std::slice::from_ref(reference), - ctx.allowed_uri_types, + ctx.policy.reference_uri_types, ctx.allowed_transform_uris(), ) { Ok(()) => {} @@ -884,8 +1452,8 @@ fn process_manifest_references( ) => { results.push(manifest_reference_invalid_result( reference, - index, - FailureReason::ReferencePolicyViolation { ref_index: index }, + *index, + FailureReason::ReferencePolicyViolation { ref_index: *index }, )); continue; } @@ -894,8 +1462,8 @@ fn process_manifest_references( )) => { results.push(manifest_reference_invalid_result( reference, - index, - FailureReason::ReferenceProcessingFailure { ref_index: index }, + *index, + FailureReason::ReferenceProcessingFailure { ref_index: *index }, )); continue; } @@ -904,8 +1472,8 @@ fn process_manifest_references( // record as non-fatal per-reference processing failure instead of aborting. results.push(manifest_reference_invalid_result( reference, - index, - FailureReason::ReferenceProcessingFailure { ref_index: index }, + *index, + FailureReason::ReferenceProcessingFailure { ref_index: *index }, )); continue; } @@ -916,17 +1484,19 @@ fn process_manifest_references( resolver, signature_node, ReferenceSet::Manifest, - index, + *index, + resolver.node_for_node_id(*reference_node_id), execution, ) { Ok(result) => results.push(result), Err(_) => results.push(manifest_reference_invalid_result( reference, - index, - FailureReason::ReferenceProcessingFailure { ref_index: index }, + *index, + FailureReason::ReferenceProcessingFailure { ref_index: *index }, )), } } + results.sort_by_key(|result| result.reference_index); Ok(results) } @@ -951,9 +1521,12 @@ fn manifest_reference_invalid_result( fn parse_manifest_references( signature_node: Node<'_, '_>, signed_info_reference_nodes: &HashSet, + remaining_reference_capacity: usize, xpath_parse_budget: &mut XPathSignatureParseBudget, -) -> Result, SignatureVerificationPipelineError> { +) -> Result { let mut references = Vec::new(); + let mut invalid = Vec::new(); + let mut reference_index = 0usize; for object_node in signature_node.children().filter(|node| { node.is_element() && node.tag_name().namespace() == Some(XMLDSIG_NS) @@ -997,19 +1570,49 @@ fn parse_manifest_references( reason: "Manifest must contain only ds:Reference element children", }); } - if references.len() == MAX_REFERENCES_PER_SIGNATURE { + if references.len() + invalid.len() >= remaining_reference_capacity { return Err(SignatureVerificationPipelineError::InvalidStructure { reason: "signed Manifests exceed the per-signature Reference limit", }); } - references.push( - parse_reference_with_xpath_budget(child, xpath_parse_budget) - .map_err(SignatureVerificationPipelineError::ParseManifestReference)?, - ); + match parse_reference_with_xpath_budget(child, xpath_parse_budget) { + Ok(reference) => references.push((reference_index, reference, child.id())), + Err(ParseError::Transform(super::TransformError::UnsupportedTransform(_))) => { + let digest_algorithm = reference_digest_method(child).map_err(|error| { + SignatureVerificationPipelineError::ParseManifestReference(error) + })?; + invalid.push(ReferenceResult { + reference_set: ReferenceSet::Manifest, + reference_index, + uri: child.attribute("URI").unwrap_or("").to_owned(), + digest_algorithm, + status: DsigStatus::Invalid( + FailureReason::ReferenceProcessingFailure { + ref_index: reference_index, + }, + ), + pre_digest_data: None, + }); + } + Err(error) => { + return Err(SignatureVerificationPipelineError::ParseManifestReference( + error, + )); + } + } + reference_index += 1; } } } - Ok(references) + Ok(ParsedManifestReferences { + references, + invalid_results: invalid, + }) +} + +struct ParsedManifestReferences { + references: Vec<(usize, Reference, NodeId)>, + invalid_results: Vec, } fn collect_authenticated_signed_info_reference_nodes( @@ -1028,7 +1631,7 @@ fn collect_authenticated_signed_info_reference_nodes( .all(transform_preserves_manifest_structure) }) .filter_map(|reference| reference.uri.as_deref()) - .filter_map(signed_info_reference_id_from_uri) + .filter_map(same_document_reference_id) .filter_map(|id| resolver.node_id_for_id(id)) .collect() } @@ -1048,17 +1651,6 @@ fn transform_preserves_manifest_structure(transform: &Transform) -> bool { } } -fn signed_info_reference_id_from_uri(uri: &str) -> Option<&str> { - let fragment = uri.strip_prefix('#')?; - if fragment.is_empty() || fragment == "xpointer(/)" { - return None; - } - if let Some(id) = parse_xpointer_id_fragment(fragment) { - return (!id.is_empty()).then_some(id); - } - (!fragment.starts_with("xpointer(")).then_some(fragment) -} - enum ResolvedVerifyingKey<'a> { Borrowed(&'a dyn VerifyingKey), Owned(Box), @@ -1082,7 +1674,7 @@ fn resolve_verifying_key<'k>( return Ok(Some(ResolvedVerifyingKey::Borrowed(key))); } if let Some(resolver) = ctx.key_resolver { - let resolved = resolver.resolve(key_info, algorithm)?; + let resolved = resolver.resolve_with_policy(key_info, algorithm, &ctx.policy)?; return Ok(resolved.map(ResolvedVerifyingKey::Owned)); } Ok(None) @@ -1108,7 +1700,7 @@ fn enforce_reference_policies( if let Some(allowed) = allowed_transforms { for transform in &reference.transforms { - let transform_uri = transform_uri(transform); + let transform_uri = transform.algorithm_uri(); if !allowed.contains(transform_uri) { return Err(SignatureVerificationPipelineError::DisallowedTransform { algorithm: transform_uri.to_owned(), @@ -1116,9 +1708,14 @@ fn enforce_reference_policies( } } - let produces_binary = reference.transforms.last().is_some_and(|transform| { - matches!(transform, Transform::C14n(_) | Transform::Base64Decode) - }); + // External dereference has an octet-stream data type independent of + // whether the caller supplied the resource. Every transform then + // determines the next type, including implicit binary-to-node-set + // adapters before XML-level transforms. + let mut produces_binary = classify_uri(uri) == UriClass::External; + for transform in &reference.transforms { + produces_binary = matches!(transform, Transform::C14n(_) | Transform::Base64Decode); + } if !produces_binary && !allowed.contains(DEFAULT_IMPLICIT_C14N_URI) { return Err(SignatureVerificationPipelineError::DisallowedTransform { algorithm: DEFAULT_IMPLICIT_C14N_URI.to_owned(), @@ -1129,16 +1726,6 @@ fn enforce_reference_policies( Ok(()) } -fn transform_uri(transform: &Transform) -> &'static str { - match transform { - Transform::Enveloped => super::transforms::ENVELOPED_SIGNATURE_URI, - Transform::XpathExcludeAllSignatures | Transform::XPath(_) => XPATH_TRANSFORM_URI, - Transform::XPathFilter2(_) => super::transforms::XPATH_FILTER2_TRANSFORM_URI, - Transform::C14n(algo) => algo.uri(), - Transform::Base64Decode => BASE64_TRANSFORM_URI, - } -} - #[derive(Debug, Clone, Copy)] struct SignatureChildNodes<'a, 'input> { signed_info_node: Node<'a, 'input>, @@ -1324,6 +1911,23 @@ fn verify_with_algorithm( signature_value: &[u8], ) -> Result { match algorithm { + SignatureAlgorithm::DsaSha1 => { + let (rest, pem) = x509_parser::pem::parse_x509_pem(public_key_pem.as_bytes()) + .map_err(|_| SignatureVerificationError::InvalidKeyPem)?; + if !rest.iter().all(|byte| byte.is_ascii_whitespace()) || pem.label != "PUBLIC KEY" { + return Err(SignatureVerificationError::InvalidKeyPem.into()); + } + Ok(verify_dsa_signature_spki( + algorithm, + &pem.contents, + signed_data, + signature_value, + )?) + } + SignatureAlgorithm::HmacSha1 => Err(SignatureVerificationError::UnsupportedAlgorithm { + uri: algorithm.uri().to_string(), + } + .into()), SignatureAlgorithm::RsaSha1 | SignatureAlgorithm::RsaSha256 | SignatureAlgorithm::RsaSha384 @@ -1382,23 +1986,282 @@ mod tests { } } - struct RejectingKey; + #[test] + fn reference_resolution_uses_each_elements_effective_xml_base() { + // Equal lexical URIs under different xml:base values identify distinct + // caller-owned resources and must not collide in the resolver. + let first = b"first payload"; + let second = b"second payload"; + let first_digest = base64::engine::general_purpose::STANDARD + .encode(compute_digest(DigestAlgorithm::Sha256, first)); + let second_digest = base64::engine::general_purpose::STANDARD + .encode(compute_digest(DigestAlgorithm::Sha256, second)); + let xml = format!( + r#" + + + + + + {first_digest} + + + + {second_digest} + + AA== + "# + ); + let document = Document::parse(&xml).unwrap(); + let signature = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "Signature"))) + .unwrap(); + let signed_info_node = signature + .children() + .find(|node| node.has_tag_name((XMLDSIG_NS, "SignedInfo"))) + .unwrap(); + let signed_info = parse_signed_info(signed_info_node).unwrap(); + let resources = HashMap::from([ + ( + "https://example.test/base/one/payload.bin".into(), + first.to_vec(), + ), + ( + "https://example.test/two/payload.bin".into(), + second.to_vec(), + ), + ]); + let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources); - impl VerifyingKey for RejectingKey { - fn verify( - &self, - _algorithm: SignatureAlgorithm, - _signed_data: &[u8], - _signature_value: &[u8], - ) -> Result { - Ok(false) - } - } + let result = process_all_references(&signed_info.references, &resolver, signature, false) + .expect("each Reference should resolve against its own effective base"); - struct AcceptingKey; + assert!(result.all_valid()); + } - impl VerifyingKey for AcceptingKey { - fn verify( + #[test] + fn internal_dtd_opt_in_applies_to_detached_xml_transforms() { + // The parse policy covers every XML document in one verification + // pipeline, including caller-owned octets converted to a node-set. + let detached = b"]>ok"; + let digest = base64::engine::general_purpose::STANDARD.encode(compute_digest( + DigestAlgorithm::Sha256, + b"ok", + )); + let xml = format!( + r#" + + + + + + + + + + {digest} + + + AQ== + +"# + ); + let resources = HashMap::from([("urn:detached-dtd".to_owned(), detached.to_vec())]); + let key = AcceptingKey; + + let default_error = VerifyContext::new() + .key(&key) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&xml) + .expect_err("internal DTD parsing must remain disabled by default"); + assert!(matches!( + default_error, + SignatureVerificationPipelineError::Reference(ReferenceProcessingError::Transform( + crate::xmldsig::TransformError::XmlParse(_) + )) + )); + + let result = VerifyContext::new() + .key(&key) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .allow_internal_dtd(true) + .verify(&xml) + .expect("the explicit DTD opt-in must cover detached XML transforms"); + + assert_eq!(result.status, DsigStatus::Valid); + + let external_entity = br#" + ]>&ext;"#; + let external_entity_resources = + HashMap::from([("urn:detached-dtd".to_owned(), external_entity.to_vec())]); + let external_entity_error = VerifyContext::new() + .key(&key) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&external_entity_resources) + .allow_internal_dtd(true) + .verify(&xml) + .expect_err("the internal-DTD opt-in must not resolve external entities"); + assert!(matches!( + external_entity_error, + SignatureVerificationPipelineError::Reference(ReferenceProcessingError::Transform( + crate::xmldsig::TransformError::XmlParse(_) + )) + )); + } + + #[test] + fn query_only_reference_resolves_against_relative_xml_base() { + // A query-only URI replaces the inherited base query without changing + // its relative path; no absolute document base is required by XML Base. + let payload = b"query-selected payload"; + let digest = base64::engine::general_purpose::STANDARD + .encode(compute_digest(DigestAlgorithm::Sha256, payload)); + let xml = format!( + r#" + + + + + {digest} + + AA=="# + ); + let document = Document::parse(&xml).unwrap(); + let signature = document.root_element(); + let signed_info_node = signature + .children() + .find(|node| node.has_tag_name((XMLDSIG_NS, "SignedInfo"))) + .unwrap(); + let signed_info = parse_signed_info(signed_info_node).unwrap(); + let resources = HashMap::from([("a/b?new".to_string(), payload.to_vec())]); + let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources); + + let result = process_all_references(&signed_info.references, &resolver, signature, false) + .expect("query-only URI must resolve against the complete relative base path"); + + assert!(result.all_valid()); + } + + #[test] + fn manifest_reference_resolution_uses_its_effective_xml_base() { + // Manifest references carry their own XML Base context and must not + // accidentally reuse the SignedInfo or Signature element context. + let payload = b"manifest payload"; + let digest = base64::engine::general_purpose::STANDARD + .encode(compute_digest(DigestAlgorithm::Sha256, payload)); + let xml = format!( + r#" + + + + {digest} + + + "# + ); + let document = Document::parse(&xml).unwrap(); + let signature = document.root_element(); + let reference_node = signature + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "Reference"))) + .unwrap(); + let reference = super::super::parse::parse_reference(reference_node).unwrap(); + let resources = HashMap::from([( + "https://example.test/manifests/payload.bin".to_string(), + payload.to_vec(), + )]); + let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources); + + let result = process_reference( + &reference, + &resolver, + signature, + ReferenceSet::Manifest, + 0, + false, + ) + .expect("Manifest Reference should inherit its own XML Base context"); + + assert_eq!(result.status, DsigStatus::Valid); + } + + #[test] + fn manifest_reference_index_ignores_nested_manifest_descendants() { + // The public Manifest index follows Signature/Object/Manifest structure; + // wrapper descendants must not steal an index and supply another base URI. + let payload = b"direct manifest payload"; + let digest = base64::engine::general_purpose::STANDARD + .encode(compute_digest(DigestAlgorithm::Sha256, payload)); + let xml = format!( + r#" + + + + {digest} + + + + + + {digest} + + + "# + ); + let document = Document::parse(&xml).unwrap(); + let signature = document.root_element(); + let direct_reference_node = signature + .children() + .filter(|node| node.has_tag_name((XMLDSIG_NS, "Object"))) + .nth(1) + .unwrap() + .children() + .find(|node| node.has_tag_name((XMLDSIG_NS, "Manifest"))) + .unwrap() + .children() + .find(|node| node.has_tag_name((XMLDSIG_NS, "Reference"))) + .unwrap(); + let reference = super::super::parse::parse_reference(direct_reference_node).unwrap(); + let resources = HashMap::from([( + "https://example.test/direct/payload.bin".to_string(), + payload.to_vec(), + )]); + let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources); + + let result = process_reference( + &reference, + &resolver, + signature, + ReferenceSet::Manifest, + 0, + false, + ) + .expect("Manifest index must select the direct Object/Manifest reference"); + + assert_eq!(result.status, DsigStatus::Valid); + } + + struct RejectingKey; + + impl VerifyingKey for RejectingKey { + fn verify( + &self, + _algorithm: SignatureAlgorithm, + _signed_data: &[u8], + _signature_value: &[u8], + ) -> Result { + Ok(false) + } + } + + struct AcceptingKey; + + impl VerifyingKey for AcceptingKey { + fn verify( &self, _algorithm: SignatureAlgorithm, _signed_data: &[u8], @@ -1451,6 +2314,56 @@ mod tests { } } + struct FallbackKeyInfoResolver; + + impl KeyResolver for FallbackKeyInfoResolver { + fn resolve<'a>( + &'a self, + key_info: Option<&KeyInfo>, + _algorithm: SignatureAlgorithm, + ) -> Result>, SignatureVerificationPipelineError> + { + let sources = &key_info.expect("KeyInfo must be parsed").sources; + assert!(matches!( + sources.as_slice(), + [ + super::super::parse::KeyInfoSource::RetrievalMethod { .. }, + super::super::parse::KeyInfoSource::KeyName(name), + ] if name == "fallback" + )); + Ok(Some(Box::new(AcceptingKey))) + } + + fn consumes_document_key_info(&self) -> bool { + true + } + } + + struct EarlyKeyInfoResolver; + + impl KeyResolver for EarlyKeyInfoResolver { + fn resolve<'a>( + &'a self, + key_info: Option<&KeyInfo>, + _algorithm: SignatureAlgorithm, + ) -> Result>, SignatureVerificationPipelineError> + { + let sources = &key_info.expect("KeyInfo must be parsed").sources; + assert!(matches!( + sources.as_slice(), + [ + super::super::parse::KeyInfoSource::KeyName(name), + super::super::parse::KeyInfoSource::RetrievalMethod { .. }, + ] if name == "primary" + )); + Ok(Some(Box::new(AcceptingKey))) + } + + fn consumes_document_key_info(&self) -> bool { + true + } + } + fn minimal_signature_xml(reference_uri: &str, transforms_xml: &str) -> String { format!( r#" @@ -2037,6 +2950,48 @@ mod tests { assert!(matches!(result.status, DsigStatus::Valid)); } + #[test] + fn verify_context_applies_digest_policy_to_manifest_references() { + // Manifest results are authenticated extension data and must obey the + // same digest allowlist as SignedInfo references. + let policy = crate::policy::VerificationPolicy { + process_manifests: true, + digest_algorithms: Some(HashSet::from([DigestAlgorithm::Sha1])), + ..crate::policy::VerificationPolicy::default() + }; + let xml = signature_with_manifest_xml_with_manifest_mutation(true, |mut xml| { + let legacy = "http://www.w3.org/2000/09/xmldsig#sha1"; + let offset = xml + .rfind(legacy) + .expect("Manifest DigestMethod must be present"); + xml.replace_range(offset..offset + legacy.len(), DigestAlgorithm::Sha256.uri()); + let value_start = xml[offset..] + .find("") + .map(|relative| offset + relative + "".len()) + .expect("Manifest DigestValue must be present"); + let value_end = xml[value_start..] + .find("") + .map(|relative| value_start + relative) + .expect("Manifest DigestValue must be closed"); + xml.replace_range( + value_start..value_end, + &base64::engine::general_purpose::STANDARD.encode([0_u8; 32]), + ); + xml + }); + let result = VerifyContext::new() + .key(&AcceptingKey) + .policy(policy) + .verify(&xml) + .expect("a disallowed Manifest digest is a per-reference result"); + + assert!(matches!(result.status, DsigStatus::Valid)); + assert!(matches!( + result.manifest_references[0].status, + DsigStatus::Invalid(FailureReason::ReferencePolicyViolation { ref_index: 0 }) + )); + } + #[test] fn verify_context_skips_manifest_uri_work_when_signature_is_invalid() { // Missing Manifest URIs remain unauthenticated until SignatureValue @@ -2121,6 +3076,494 @@ mod tests { )); } + #[test] + fn verify_context_reports_unsupported_manifest_transform_with_declared_digest() { + // Unsupported optional Manifest transforms do not invalidate core + // SignedInfo, but their result must preserve the declared digest method. + let xml = signature_with_manifest_xml_with_manifest_mutation(true, |xml| { + let xml = xml.replacen( + "", + "", + 1, + ); + let xml = xml.replacen( + "\n ", + "\n ", + 1, + ); + replace_fixture_manifest_digest(&xml, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + }); + assert!(xml.contains("urn:unsupported")); + assert!(xml.contains("http://www.w3.org/2001/04/xmlenc#sha256")); + + let result = VerifyContext::new() + .key(&AcceptingKey) + .process_manifests(true) + .verify(&xml) + .expect("unsupported Manifest transform is a per-reference result"); + assert_eq!(result.status, DsigStatus::Valid); + assert_eq!(result.manifest_references.len(), 1); + assert_eq!( + result.manifest_references[0].digest_algorithm, + DigestAlgorithm::Sha256 + ); + assert!(matches!( + result.manifest_references[0].status, + DsigStatus::Invalid(FailureReason::ReferenceProcessingFailure { ref_index: 0 }) + )); + } + + #[test] + fn manifest_reference_limit_counts_unsupported_entries() { + let references = (0..=MAX_REFERENCES_PER_SIGNATURE) + .map(|index| { + format!( + r##"AAAAAAAAAAAAAAAAAAAAAAAAAAA="## + ) + }) + .collect::(); + let xml = format!( + r#"{references}"# + ); + let document = Document::parse(&xml).unwrap(); + let signature = document.root_element(); + let object = signature.children().find(|node| node.is_element()).unwrap(); + let authenticated = HashSet::from([object.id()]); + + let error = match parse_manifest_references( + signature, + &authenticated, + MAX_REFERENCES_PER_SIGNATURE, + &mut XPathSignatureParseBudget::default(), + ) { + Ok(_) => panic!("unsupported references must consume the same aggregate limit"), + Err(error) => error, + }; + assert!(matches!( + error, + SignatureVerificationPipelineError::InvalidStructure { + reason: "signed Manifests exceed the per-signature Reference limit" + } + )); + } + + #[test] + fn manifest_reference_limit_includes_signed_info_references() { + // The per-signature ceiling is shared by core and authenticated + // Manifest references; enabling Manifest processing must not reset it. + let xml = signature_with_manifest_xml(true); + let reference_start = xml + .find(r##""##) + .expect("fixture SignedInfo must reference the Manifest"); + let reference_end = xml[reference_start..] + .find("") + .map(|offset| reference_start + offset + "".len()) + .expect("fixture SignedInfo Reference must be closed"); + let repeated = xml[reference_start..reference_end].repeat(MAX_REFERENCES_PER_SIGNATURE); + let xml = format!( + "{}{repeated}{}", + &xml[..reference_start], + &xml[reference_end..] + ); + + let error = VerifyContext::new() + .key(&AcceptingKey) + .process_manifests(true) + .verify(&xml) + .expect_err("one Manifest Reference must exceed the exhausted signature-wide limit"); + + assert!(matches!( + error, + SignatureVerificationPipelineError::InvalidStructure { + reason: "signed Manifests exceed the per-signature Reference limit" + } + )); + } + + #[test] + fn configured_reference_limit_is_shared_with_manifests() { + // Lowering the operation policy must lower the aggregate SignedInfo and + // Manifest capacity rather than falling back to the crate hard limit. + let policy = crate::policy::VerificationPolicy { + process_manifests: true, + resources: crate::policy::ResourcePolicy { + max_references: 1, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::VerificationPolicy::default() + }; + + let error = VerifyContext::new() + .key(&AcceptingKey) + .policy(policy) + .verify(&signature_with_manifest_xml(true)) + .expect_err("Manifest must exceed the caller-selected aggregate limit"); + assert!(matches!( + error, + SignatureVerificationPipelineError::InvalidStructure { + reason: "signed Manifests exceed the per-signature Reference limit" + } + )); + } + + #[test] + fn retrieval_method_materializes_single_x509_data_subtree() { + for uri in [ + "#target", + "#xpointer(id('target'))", + "#xpointer(id("target"))", + ] { + for target_xml in [ + r#"CN=leaf"#, + r#"CN=leaf"#, + ] { + let xml = format!( + r#"ancestor-or-self::ds:X509Data{target_xml}"# + ); + let document = Document::parse(&xml).unwrap(); + let key_info_node = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) + .unwrap(); + let mut key_info = parse_key_info(key_info_node).unwrap(); + let resolver = UriReferenceResolver::new(&document); + + materialize_retrieval_methods( + &mut key_info, + &resolver, + None, + UriTypeSet::SAME_DOCUMENT, + ) + .expect("XPath filter must produce one X509Data-rooted node-set"); + assert!(matches!( + key_info.sources.as_slice(), + [super::super::parse::KeyInfoSource::X509Data(info)] + if info.subject_names == ["CN=leaf"] + )); + } + } + } + + #[test] + fn retrieval_method_materializes_direct_untransformed_x509_data() { + // A typed RetrievalMethod may point directly at the XML structure it + // identifies; no transform is needed when X509Data is the URI root. + let xml = r##" + + CN=leaf + "##; + let document = Document::parse(xml).unwrap(); + let key_info_node = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) + .unwrap(); + let mut key_info = parse_key_info(key_info_node).unwrap(); + + materialize_retrieval_methods( + &mut key_info, + &UriReferenceResolver::new(&document), + None, + UriTypeSet::SAME_DOCUMENT, + ) + .expect("a direct X509Data target needs no transform"); + assert!(matches!( + key_info.sources.as_slice(), + [super::super::parse::KeyInfoSource::X509Data(info)] + if info.subject_names == ["CN=leaf"] + )); + } + + #[test] + fn raw_x509_retrieval_method_uses_inherited_xml_base() { + // RetrievalMethod URI is an attribute URI reference, so XML Base uses + // the effective base of the element bearing that attribute. + const RAW_X509_TYPE: &str = "http://www.w3.org/2000/09/xmldsig#rawX509Certificate"; + let xml = format!( + r#" + + "# + ); + let document = Document::parse(&xml).unwrap(); + let key_info_node = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) + .unwrap(); + let mut key_info = parse_key_info(key_info_node).unwrap(); + let certificate = include_bytes!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/balor.der" + ) + .to_vec(); + let resources = HashMap::from([( + "https://example.test/keys/signer.der".to_string(), + certificate, + )]); + + materialize_retrieval_methods( + &mut key_info, + &UriReferenceResolver::new(&document), + Some(&resources), + UriTypeSet::ALL, + ) + .expect("RetrievalMethod should resolve against inherited xml:base"); + + assert!(matches!( + key_info.sources.as_slice(), + [super::super::parse::KeyInfoSource::X509Data(info)] + if info.certificates.len() == 1 + )); + } + + #[test] + fn retrieval_method_requires_xpath_for_x509_data_below_uri_root() { + // Without a transform the dereferenced holder, not its descendant, + // is the result and therefore cannot masquerade as typed X509Data. + let xml = r##" + + CN=leaf + "##; + let document = Document::parse(xml).unwrap(); + let key_info_node = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) + .unwrap(); + let mut key_info = parse_key_info(key_info_node).unwrap(); + + let error = materialize_retrieval_methods( + &mut key_info, + &UriReferenceResolver::new(&document), + None, + UriTypeSet::SAME_DOCUMENT, + ) + .expect_err("a wrapper target requires an explicit selection transform"); + assert!(matches!( + error, + SignatureVerificationPipelineError::InvalidStructure { + reason: "untransformed X509Data RetrievalMethod must target X509Data directly" + } + )); + } + + #[test] + fn retrieval_method_rejects_target_inside_external_x509_data_ancestor() { + // XPath filtering cannot add an ancestor that was outside the URI's + // dereferenced node-set, so this result is not rooted at X509Data. + let xml = r##" + ancestor-or-self::ds:X509Data + CN=leaf + "##; + let document = Document::parse(xml).unwrap(); + let key_info_node = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) + .unwrap(); + let mut key_info = parse_key_info(key_info_node).unwrap(); + + let error = materialize_retrieval_methods( + &mut key_info, + &UriReferenceResolver::new(&document), + None, + UriTypeSet::SAME_DOCUMENT, + ) + .expect_err("filter output without an X509Data root must be rejected"); + assert!(matches!( + error, + SignatureVerificationPipelineError::InvalidStructure { + reason: "X509Data RetrievalMethod selected no X509Data element" + } + )); + } + + #[test] + fn retrieval_method_rejects_ambiguous_x509_data_relation() { + // A transformed result with multiple X509Data roots is not one KeyInfo child. + let xml = r##" + ancestor-or-self::ds:X509Data + + "##; + let document = Document::parse(xml).unwrap(); + let key_info_node = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) + .unwrap(); + let mut key_info = parse_key_info(key_info_node).unwrap(); + + let error = materialize_retrieval_methods( + &mut key_info, + &UriReferenceResolver::new(&document), + None, + UriTypeSet::SAME_DOCUMENT, + ) + .expect_err("multiple transformed X509Data roots must be rejected"); + assert!(matches!( + error, + SignatureVerificationPipelineError::InvalidStructure { + reason: "X509Data RetrievalMethod selected multiple X509Data elements" + } + )); + } + + #[test] + fn retrieval_method_materialization_preserves_key_info_order() { + // Replacing the source in place keeps a later fallback behind the + // retrieved key material for first-match resolvers. + let xml = r##" + + ancestor-or-self::ds:X509Data + fallback + + CN=leaf + "##; + let document = Document::parse(xml).unwrap(); + let key_info_node = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) + .unwrap(); + let mut key_info = parse_key_info(key_info_node).unwrap(); + + materialize_retrieval_methods( + &mut key_info, + &UriReferenceResolver::new(&document), + None, + UriTypeSet::SAME_DOCUMENT, + ) + .unwrap(); + assert!(matches!( + key_info.sources.as_slice(), + [ + super::super::parse::KeyInfoSource::X509Data(_), + super::super::parse::KeyInfoSource::KeyName(name) + ] if name == "fallback" + )); + } + + #[test] + fn retrieval_method_materialization_bounds_repeated_sources() { + // Repeating one allowed certificate must not multiply parsing and clones + // before SignatureValue validation. + const RAW_X509_TYPE: &str = "http://www.w3.org/2000/09/xmldsig#rawX509Certificate"; + let certificate = include_bytes!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/balor.der" + ) + .to_vec(); + let resources = HashMap::from([("urn:certificate".to_string(), certificate)]); + let mut key_info = KeyInfo { + sources: (0..=64) + .map(|_| super::super::parse::KeyInfoSource::RetrievalMethod { + uri: "urn:certificate".into(), + resource_type: Some(RAW_X509_TYPE.into()), + transforms: RetrievalMethodTransforms::None, + }) + .collect(), + }; + let document = Document::parse("").unwrap(); + + let error = materialize_retrieval_methods( + &mut key_info, + &UriReferenceResolver::new(&document), + Some(&resources), + UriTypeSet::ALL, + ) + .expect_err("retrieval count must be bounded before materialization"); + assert!(matches!( + error, + SignatureVerificationPipelineError::InvalidStructure { + reason: "KeyInfo contains too many RetrievalMethod elements" + } + )); + } + + #[test] + fn retrieval_method_materialization_deduplicates_within_count_limit() { + // Repeated references to the same raw certificate produce one parsed + // key source rather than one certificate clone per XML element. + const RAW_X509_TYPE: &str = "http://www.w3.org/2000/09/xmldsig#rawX509Certificate"; + let certificate = include_bytes!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/balor.der" + ) + .to_vec(); + let resources = HashMap::from([("urn:certificate".to_string(), certificate)]); + let mut key_info = KeyInfo { + sources: (0..MAX_RETRIEVAL_METHOD_COUNT) + .map(|_| super::super::parse::KeyInfoSource::RetrievalMethod { + uri: "urn:certificate".into(), + resource_type: Some(RAW_X509_TYPE.into()), + transforms: RetrievalMethodTransforms::None, + }) + .collect(), + }; + let document = Document::parse("").unwrap(); + + materialize_retrieval_methods( + &mut key_info, + &UriReferenceResolver::new(&document), + Some(&resources), + UriTypeSet::ALL, + ) + .unwrap(); + assert!(matches!( + key_info.sources.as_slice(), + [super::super::parse::KeyInfoSource::X509Data(info)] + if info.certificates.len() == 1 + )); + } + + #[test] + fn raw_x509_retrieval_rejects_empty_same_document_uri() { + // rawX509Certificate consumes external DER octets; an empty URI denotes + // the XML document and must never become a key into the external map. + const RAW_X509_TYPE: &str = "http://www.w3.org/2000/09/xmldsig#rawX509Certificate"; + let certificate = include_bytes!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/balor.der" + ) + .to_vec(); + let resources = HashMap::from([(String::new(), certificate)]); + let mut key_info = KeyInfo { + sources: vec![super::super::parse::KeyInfoSource::RetrievalMethod { + uri: String::new(), + resource_type: Some(RAW_X509_TYPE.into()), + transforms: RetrievalMethodTransforms::None, + }], + }; + let document = Document::parse("").unwrap(); + + let error = materialize_retrieval_methods( + &mut key_info, + &UriReferenceResolver::new(&document), + Some(&resources), + UriTypeSet::ALL, + ) + .expect_err("empty URI must retain same-document semantics"); + assert!(matches!( + error, + SignatureVerificationPipelineError::InvalidStructure { + reason: "raw X509 RetrievalMethod requires an untransformed external URI" + } + )); + } + + #[test] + fn verify_context_does_not_hide_malformed_digest_behind_unsupported_transform() { + // A bad DigestValue remains a parse error even when its transform URI is unsupported. + let broken_xml = signature_with_manifest_xml_with_manifest_mutation(true, |xml| { + let xml = xml.replacen( + "", + "", + 1, + ); + replace_fixture_manifest_digest(&xml, "!!!") + }); + + let error = VerifyContext::new() + .key(&AcceptingKey) + .process_manifests(true) + .verify(&broken_xml) + .expect_err("malformed Manifest digest must not become a validity result"); + assert!(matches!( + error, + SignatureVerificationPipelineError::ParseManifestReference(_) + )); + } + #[test] fn verify_context_rejects_manifest_non_whitespace_mixed_content() { // Authenticated mixed content is still structurally invalid under the @@ -2310,6 +3753,131 @@ mod tests { )); } + #[test] + fn verify_context_ignores_unsupported_retrieval_before_valid_key_source() { + // An advisory vendor RetrievalMethod cannot prevent the resolver from + // reaching a later supported source in document order. + let xml = signature_with_target_reference("AQ==").replace( + "\n ", + r##" + + + + + fallback + + "##, + ); + + let result = VerifyContext::new() + .key_resolver(&FallbackKeyInfoResolver) + .verify(&xml) + .expect("unsupported advisory retrieval must not abort key resolution"); + assert_eq!(result.status, DsigStatus::Valid); + } + + #[test] + fn verify_context_does_not_eagerly_fail_unused_retrieval_fallback() { + // KeyInfo sources are alternatives in document order. Once an earlier + // source resolves, a missing later RetrievalMethod is irrelevant. + let xml = signature_with_target_reference("AQ==").replace( + "\n ", + r#" + + primary + + + "#, + ); + + let result = VerifyContext::new() + .key_resolver(&EarlyKeyInfoResolver) + .allowed_retrieval_method_uri_types(UriTypeSet::new(true, true, true)) + .verify(&xml) + .expect("an unused missing retrieval fallback must not abort verification"); + + assert_eq!(result.status, DsigStatus::Valid); + } + + #[test] + fn verify_context_does_not_eagerly_parse_unused_retrieval_fallback() { + // Materialization must preserve ordered fallback semantics even when + // caller-supplied bytes exist but are not a certificate. + let xml = signature_with_target_reference("AQ==").replace( + "\n ", + r#" + + primary + + + "#, + ); + let resources = HashMap::from([("malformed.der".to_string(), b"not DER".to_vec())]); + + let result = VerifyContext::new() + .key_resolver(&EarlyKeyInfoResolver) + .allowed_retrieval_method_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&xml) + .expect("an unused malformed retrieval fallback must not abort verification"); + + assert_eq!(result.status, DsigStatus::Valid); + } + + #[test] + fn verify_context_reports_missing_retrieval_when_no_key_source_resolves() { + // Deferral changes ordering, not diagnostics: if no alternative source + // resolves, the first missing retrieval remains the pipeline failure. + let xml = signature_with_target_reference("AQ==").replace( + "\n ", + r#" + + + + "#, + ); + + let error = VerifyContext::new() + .key_resolver(&ConsumingKeyInfoResolver) + .allowed_retrieval_method_uri_types(UriTypeSet::new(true, true, true)) + .verify(&xml) + .expect_err("a missing sole RetrievalMethod must remain an explicit error"); + + assert!(matches!( + error, + SignatureVerificationPipelineError::Reference(ReferenceProcessingError::Transform( + crate::xmldsig::TransformError::UnsupportedUri(uri) + )) if uri == "missing.der" + )); + } + + #[test] + fn verify_context_reports_malformed_retrieval_when_no_key_source_resolves() { + // Deferral must retain the parse error when the malformed certificate + // is the only candidate rather than degrading it to KeyNotFound. + let xml = signature_with_target_reference("AQ==").replace( + "\n ", + r#" + + + + "#, + ); + let resources = HashMap::from([("malformed.der".to_string(), b"not DER".to_vec())]); + + let error = VerifyContext::new() + .key_resolver(&ConsumingKeyInfoResolver) + .allowed_retrieval_method_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&xml) + .expect_err("a malformed sole RetrievalMethod must remain a parse error"); + + assert!(matches!( + error, + SignatureVerificationPipelineError::ParseKeyInfo(_) + )); + } + #[test] fn verify_context_preserves_signaturevalue_decode_errors_when_resolver_misses() { let xml = signature_with_target_reference("@@@"); @@ -2408,6 +3976,102 @@ mod tests { SignatureVerificationPipelineError::DisallowedTransform { 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::DisallowedTransform { ref algorithm } + if algorithm == DEFAULT_IMPLICIT_C14N_URI + )); + } + + #[test] + fn stored_pre_digest_budget_counts_repeated_external_references() { + // The caller map owns one bounded payload, but diagnostic retention is + // charged per Reference because every result owns its pre-digest bytes. + let document = + Document::parse("") + .unwrap(); + let payload = vec![b'x'; 7]; + let digest = compute_digest(DigestAlgorithm::Sha256, &payload); + let references = (0..5) + .map(|_| { + make_reference( + "urn:repeated", + Vec::new(), + DigestAlgorithm::Sha256, + digest.clone(), + ) + }) + .collect::>(); + let resources = HashMap::from([("urn:repeated".to_owned(), payload)]); + let resolver = UriReferenceResolver::new(&document).with_external_resources(&resources); + let transform_budget = TransformExecutionBudget::default(); + let 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::CanonicalizedDataTooLarge { max_bytes: 32 } + )); + } + + #[test] + fn canonical_signed_info_is_bounded_without_diagnostic_retention() { + // SignedInfo is always materialized for crypto verification, so its + // canonical bytes must consume the ceiling even under default options. + let xml = signature_with_target_reference("AQ=="); + let marker = " 1 - && last.subject() == last.issuer() - && last.verify_signature(None).is_ok() + && certificate_names_equal(last.subject(), last.issuer()) + && verify_certificate_signature(&last, &last) { let child = parse_certificate(path_der[path_der.len() - 2])?; - child.issuer() == last.subject() && child.verify_signature(Some(last.public_key())).is_ok() + certificate_names_equal(child.issuer(), last.subject()) + && verify_certificate_signature(&child, &last) } else { false }; @@ -139,10 +150,8 @@ pub fn verify_x509_certificate_chain( let mut first_validation_error = None; for (anchor_der, _) in trusted_anchors.iter().filter(|(_, cert)| { - cert.subject() == candidate_child.issuer() - && candidate_child - .verify_signature(Some(cert.public_key())) - .is_ok() + certificate_names_equal(cert.subject(), candidate_child.issuer()) + && verify_certificate_signature(&candidate_child, cert) }) { let mut candidate_path = candidate_base.to_vec(); candidate_path.push(anchor_der); @@ -185,8 +194,8 @@ fn validate_path( let [child, issuer] = pair else { unreachable!() }; - if child.issuer() != issuer.subject() - || child.verify_signature(Some(issuer.public_key())).is_err() + if !certificate_names_equal(child.issuer(), issuer.subject()) + || !verify_certificate_signature(child, issuer) { return Err(X509ChainError::InvalidSignature(position)); } @@ -198,6 +207,90 @@ fn validate_path( Ok(()) } +fn verify_certificate_signature( + certificate: &X509Certificate<'_>, + issuer: &X509Certificate<'_>, +) -> bool { + // RFC 5280 sections 4.1.1.2 and 4.1.2.3 require the outer and signed + // AlgorithmIdentifier values to be identical. Enforce this independently + // of the backend so the legacy DSA path cannot bypass the invariant. + if certificate.signature_algorithm != certificate.tbs_certificate.signature { + return false; + } + if certificate + .verify_signature(Some(issuer.public_key())) + .is_ok() + { + return true; + } + verify_dsa_sha1_signature( + &certificate.signature_algorithm.algorithm.to_id_string(), + &certificate.signature_value.data, + certificate.tbs_certificate.as_ref(), + issuer.public_key().raw, + ) +} + +/// Test a candidate certificate-path edge without assigning trust to either +/// certificate. Path construction uses this only to distinguish certificates +/// that share an issuer subject name; full policy validation still happens +/// after the complete path has been assembled. +pub(crate) fn certificate_signature_matches(certificate_der: &[u8], issuer_der: &[u8]) -> bool { + let (Ok(certificate), Ok(issuer)) = ( + parse_certificate(certificate_der), + parse_certificate(issuer_der), + ) else { + return false; + }; + verify_certificate_signature(&certificate, &issuer) +} + +fn certificate_names_equal( + left: &x509_parser::x509::X509Name<'_>, + right: &x509_parser::x509::X509Name<'_>, +) -> bool { + let (Ok(left), Ok(right)) = (x509_name_to_rfc4514(left), x509_name_to_rfc4514(right)) else { + return false; + }; + distinguished_names_equal(&left, &right) +} + +fn verify_crl_signature(crl: &CertificateRevocationList<'_>, issuer: &X509Certificate<'_>) -> bool { + // RFC 5280 sections 5.1.1.2 and 5.1.2.2 impose the same equality rule on + // CRLs as certificates. + if crl.signature_algorithm != crl.tbs_cert_list.signature { + return false; + } + if crl.verify_signature(issuer.public_key()).is_ok() { + return true; + } + verify_dsa_sha1_signature( + &crl.signature_algorithm.algorithm.to_id_string(), + &crl.signature_value.data, + crl.tbs_cert_list.as_ref(), + issuer.public_key().raw, + ) +} + +fn verify_dsa_sha1_signature( + algorithm_oid: &str, + signature_der: &[u8], + signed_data: &[u8], + issuer_spki_der: &[u8], +) -> bool { + if algorithm_oid != "1.2.840.10040.4.3" { + return false; + } + let Ok(key) = dsa::VerifyingKey::from_public_key_der(issuer_spki_der) else { + return false; + }; + let Ok(signature) = dsa::Signature::from_der(signature_der) else { + return false; + }; + let digest = Sha1::digest(signed_data); + key.verify_prehash(&digest, &signature).is_ok() +} + fn validate_leaf_key_usage(cert: &X509Certificate<'_>) -> Result<(), X509ChainError> { // RFC 5280 section 4.2.1.3 restricts key purpose only when KeyUsage is present. if cert @@ -307,7 +400,10 @@ fn verify_crls( for (position, cert) in path.iter().enumerate().take(path.len().saturating_sub(1)) { let issuer = &path[position + 1]; - for (crl_index, crl) in crls.iter().filter(|(_, crl)| crl.issuer() == cert.issuer()) { + for (crl_index, crl) in crls + .iter() + .filter(|(_, crl)| certificate_names_equal(crl.issuer(), cert.issuer())) + { if issuer .key_usage() .map_err(|error| X509ChainError::InvalidDer { @@ -325,7 +421,7 @@ fn verify_crls( && crl .next_update() .is_none_or(|next| verification_time <= next); - if !time_valid || crl.verify_signature(issuer.public_key()).is_err() { + if !time_valid || !verify_crl_signature(crl, issuer) { return Err(X509ChainError::InvalidCrl(*crl_index)); } if crl.iter_revoked_certificates().any(|revoked| { @@ -338,3 +434,167 @@ fn verify_crls( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::xmldsig::{KeyInfoSource, parse::XMLDSIG_NS, parse_key_info}; + use roxmltree::Document; + use std::time::Duration; + + #[test] + fn path_edge_signature_check_does_not_repeat_name_matching() { + // Path construction performs RFC 5280 name matching before asking this + // helper to disambiguate same-name candidates. Only proof of possession + // of the issuer key belongs in this second gate. + let issuer_key = rcgen::KeyPair::generate().expect("issuer key generation should succeed"); + let issuer_key_pem = issuer_key.serialize_pem(); + let mut signing_params = rcgen::CertificateParams::new(Vec::new()) + .expect("empty issuer SAN list should be valid"); + signing_params + .distinguished_name + .push(rcgen::DnType::CommonName, "signing name"); + signing_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + signing_params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign]; + let signing_issuer = rcgen::CertifiedIssuer::self_signed(signing_params, issuer_key) + .expect("issuer certificate should be self-signable"); + + let mut alternate_params = rcgen::CertificateParams::new(Vec::new()) + .expect("empty alternate SAN list should be valid"); + alternate_params + .distinguished_name + .push(rcgen::DnType::CommonName, "name already matched by caller"); + alternate_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + alternate_params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign]; + let alternate_issuer = rcgen::CertifiedIssuer::self_signed( + alternate_params, + rcgen::KeyPair::from_pem(&issuer_key_pem) + .expect("serialized issuer key should parse again"), + ) + .expect("alternate issuer certificate should be self-signable"); + + let leaf = rcgen::CertificateParams::new(Vec::new()) + .expect("empty leaf SAN list should be valid") + .signed_by( + &rcgen::KeyPair::generate().expect("leaf key generation should succeed"), + &signing_issuer, + ) + .expect("issuer should sign leaf certificate"); + + assert!(certificate_signature_matches( + leaf.der(), + alternate_issuer.der() + )); + } + + #[test] + fn dsa_rollover_replaces_embedded_root_before_depth_validation() { + let leaf = include_bytes!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/balor.der" + ) + .to_vec(); + let embedded_root = + include_bytes!("../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/ca.der") + .to_vec(); + + // Trust-anchor self-signatures are not part of path validation. Changing + // only that signature gives this test a distinct rollover certificate + // with the same subject and DSA public key as the embedded stale root. + let mut rollover_anchor = embedded_root.clone(); + *rollover_anchor + .last_mut() + .expect("certificate is non-empty") ^= 1; + parse_certificate(&rollover_anchor).expect("modified trust anchor remains valid DER"); + let anchors = vec![rollover_anchor]; + let info = X509DataInfo { + certificates: vec![leaf, embedded_root], + certificate_chain: vec![0, 1], + ..X509DataInfo::default() + }; + let options = X509ChainOptions { + trusted_certs: &anchors, + verification_time: UNIX_EPOCH + Duration::from_secs(1_104_580_800), + max_chain_depth: 2, + check_crls: false, + }; + + verify_x509_certificate_chain(&info, &options) + .expect("the stale DSA root must be replaced by the configured anchor"); + } + + #[test] + fn dsa_certificate_rejects_mismatched_inner_signature_algorithm() { + // The signed TBSCertificate algorithm is a separate RFC 5280 invariant; + // a valid signature over the original bytes must not bypass a mismatch + // in the parsed metadata through the legacy DSA fallback. + let (_, mut certificate) = X509Certificate::from_der(include_bytes!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/balor.der" + )) + .expect("the tracked Merlin certificate is valid DER"); + let (_, issuer) = X509Certificate::from_der(include_bytes!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/ca.der" + )) + .expect("the tracked Merlin issuer is a DER certificate"); + assert!(verify_certificate_signature(&certificate, &issuer)); + + certificate.tbs_certificate.signature = issuer.public_key().algorithm.clone(); + + assert_ne!( + certificate.tbs_certificate.signature, + certificate.signature_algorithm + ); + assert!(!verify_certificate_signature(&certificate, &issuer)); + } + + #[test] + fn dsa_sha1_crl_signature_uses_the_same_fallback_as_certificates() { + let xml = include_str!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt-crl.xml" + ); + let document = Document::parse(xml).expect("the tracked Merlin document is valid XML"); + let key_info_node = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) + .expect("the Merlin document contains KeyInfo"); + let key_info = parse_key_info(key_info_node).expect("the Merlin KeyInfo is valid"); + let KeyInfoSource::X509Data(info) = &key_info.sources[0] else { + panic!("expected X509Data") + }; + let (_, issuer) = X509Certificate::from_der(include_bytes!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/ca.der" + )) + .expect("the tracked Merlin issuer is a DER certificate"); + let (_, crl) = CertificateRevocationList::from_der(&info.crls[0]) + .expect("the tracked Merlin CRL is valid DER"); + + assert!(verify_crl_signature(&crl, &issuer)); + } + + #[test] + fn dsa_crl_rejects_mismatched_inner_signature_algorithm() { + let xml = include_str!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt-crl.xml" + ); + let document = Document::parse(xml).expect("the tracked Merlin document is valid XML"); + let key_info_node = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) + .expect("the Merlin document contains KeyInfo"); + let key_info = parse_key_info(key_info_node).expect("the Merlin KeyInfo is valid"); + let KeyInfoSource::X509Data(info) = &key_info.sources[0] else { + panic!("expected X509Data") + }; + let (_, issuer) = X509Certificate::from_der(include_bytes!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/ca.der" + )) + .expect("the tracked Merlin issuer is a DER certificate"); + let (_, mut crl) = CertificateRevocationList::from_der(&info.crls[0]) + .expect("the tracked Merlin CRL is valid DER"); + assert!(verify_crl_signature(&crl, &issuer)); + + crl.tbs_cert_list.signature = issuer.public_key().algorithm.clone(); + + assert_ne!(crl.tbs_cert_list.signature, crl.signature_algorithm); + assert!(!verify_crl_signature(&crl, &issuer)); + } +} diff --git a/src/xmldsig/xpath.rs b/src/xmldsig/xpath.rs index 4e692767..24a70345 100644 --- a/src/xmldsig/xpath.rs +++ b/src/xmldsig/xpath.rs @@ -716,6 +716,14 @@ impl<'d> Mirror<'d> { match namespace.name() { Some(prefix) => element.register_prefix(prefix, namespace.uri()), None => { + // XPath 1.0's namespace axis does not expose an + // `xmlns=""` undeclaration as a namespace node. + // Registering it in SXD changes canonicalized + // node-sets compared with libxml2/xmlsec. + if namespace.uri().is_empty() { + element.set_default_namespace_uri(None); + continue; + } element.set_default_namespace_uri(Some(namespace.uri())); // SXD's namespace axis enumerates only registered // prefixes and otherwise omits the default binding. diff --git a/src/xmlenc/decrypt.rs b/src/xmlenc/decrypt.rs index 7deb52d3..720810e5 100644 --- a/src/xmlenc/decrypt.rs +++ b/src/xmlenc/decrypt.rs @@ -2,29 +2,16 @@ use std::fmt; -use aes::{ - Aes128, Aes256, - cipher::{BlockModeDecrypt, KeyIvInit, block_padding::NoPadding}, -}; -use aes_gcm::{ - Aes128Gcm, Aes256Gcm, Nonce, - aead::{AeadInOut, KeyInit}, -}; -use aes_kw::{KwAes128, KwAes256}; use base64::{Engine as _, engine::general_purpose::STANDARD}; -use cbc::Decryptor; -use getrandom::SysRng; use roxmltree::{Document, ParsingOptions}; -use rsa::{Oaep, RsaPrivateKey, traits::PaddingScheme}; -use sha1::Sha1; -use sha2::{Sha256, Sha384, Sha512}; +use rsa::RsaPrivateKey; use super::parse::parse_encrypted_data_node; use super::types::XMLENC_NS; use super::{ DataEncryptionAlgorithm, DecryptedContent, EncryptedData, EncryptedDataType, EncryptedKey, - KeyTransportAlgorithm, KeyWrapAlgorithm, XmlEncError, has_single_element_with_boundary_trivia, - parse_encrypted_data, + KeyTransportAlgorithm, KeyWrapAlgorithm, OaepDigestAlgorithm, RsaOaepParameters, XmlEncError, + has_single_element_with_boundary_trivia, parse_encrypted_data, }; /// Supplies a content-encryption key for parsed XMLEnc data. @@ -32,6 +19,7 @@ pub trait DecryptionKeyResolver { /// Resolve the symmetric key for `algorithm`, optionally unwrapping `encrypted_key`. fn resolve_key( &self, + provider: &dyn crate::provider::CryptoProvider, algorithm: DataEncryptionAlgorithm, encrypted_key: Option<&EncryptedKey>, ) -> Result, XmlEncError>; @@ -49,6 +37,148 @@ pub struct DocumentDecryptionOptions<'a> { pub allow_dtd: bool, } +/// Immutable XMLEnc decryption operation context. +pub struct DecryptContext<'a> { + resolver: &'a dyn DecryptionKeyResolver, + policy: crate::policy::DecryptionPolicy, + provider: &'a dyn crate::provider::CryptoProvider, +} + +impl<'a> DecryptContext<'a> { + /// Create a context with compatibility defaults and the RustCrypto provider. + pub fn new(resolver: &'a dyn DecryptionKeyResolver) -> Self { + Self { + resolver, + policy: crate::policy::DecryptionPolicy::default(), + provider: crate::provider::default_provider(), + } + } + + /// Replace the complete immutable decryption policy snapshot. + pub fn policy(mut self, policy: crate::policy::DecryptionPolicy) -> Self { + self.policy = policy; + self + } + + /// Select the cryptographic provider for this decryption operation. + pub fn provider(mut self, provider: &'a dyn crate::provider::CryptoProvider) -> Self { + self.provider = provider; + self + } + + /// Parse and decrypt a standalone `EncryptedData` XML fragment. + pub fn decrypt(&self, xml: &str) -> Result { + let encrypted = parse_encrypted_data(xml)?; + self.decrypt_data(&encrypted) + } + + /// Decrypt an already parsed `EncryptedData` value. + pub fn decrypt_data(&self, encrypted: &EncryptedData) -> Result { + self.policy.resources.validate()?; + let algorithm = DataEncryptionAlgorithm::from_uri(&encrypted.encryption_method.algorithm)?; + if self + .policy + .data_algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(&algorithm)) + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "decryption", + algorithm: encrypted.encryption_method.algorithm.clone(), + } + .into()); + } + for encrypted_key in &encrypted.encrypted_keys { + let uri = &encrypted_key.encryption_method.algorithm; + if let Ok(transport) = KeyTransportAlgorithm::from_uri(uri) { + if self + .policy + .key_transport_algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(&transport)) + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "decryption", + algorithm: uri.clone(), + } + .into()); + } + let digest = + parse_oaep_digest(encrypted_key.encryption_method.oaep_digest.as_deref())?; + let mgf_digest = if transport == KeyTransportAlgorithm::RsaOaepMgf1p { + OaepDigestAlgorithm::Sha1 + } else { + parse_oaep_mgf_digest(encrypted_key.encryption_method.mgf_algorithm.as_deref())? + }; + for selected in [digest, mgf_digest] { + if self + .policy + .oaep_digests + .as_ref() + .is_some_and(|allowed| !allowed.contains(&selected)) + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "decryption", + algorithm: selected.uri().to_owned(), + } + .into()); + } + } + } else if let Ok(wrap) = KeyWrapAlgorithm::from_uri(uri) + && self + .policy + .key_wrap_algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(&wrap)) + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "decryption", + algorithm: uri.clone(), + } + .into()); + } + } + let ciphertext = STANDARD + .decode(&encrypted.cipher_data.value) + .map_err(|error| XmlEncError::Base64(error.to_string()))?; + validate_possible_plaintext_len( + algorithm, + ciphertext.len(), + self.policy.resources.max_encryption_plaintext_bytes, + )?; + let key = resolve_content_key( + self.provider, + algorithm, + &encrypted.encrypted_keys, + self.resolver, + )?; + validate_key_len(algorithm, &key)?; + let plaintext = self + .provider + .decrypt_data(algorithm, &key, &ciphertext) + .map_err(|error| map_data_decryption_error(algorithm, ciphertext.len(), error))?; + validate_plaintext_len( + plaintext.len(), + self.policy.resources.max_encryption_plaintext_bytes, + )?; + match encrypted.encrypted_type.as_ref() { + Some(EncryptedDataType::Element | EncryptedDataType::Content) => { + Ok(DecryptedContent::Xml(String::from_utf8(plaintext)?)) + } + Some(EncryptedDataType::Other(_)) | None => Ok(DecryptedContent::Bytes(plaintext)), + } + } + + /// Decrypt and replace one selected `EncryptedData` in a caller-owned document. + pub fn decrypt_document( + &self, + xml: &str, + encrypted_data_id: Option<&str>, + ) -> Result { + decrypt_document_with_context(xml, encrypted_data_id, self) + } +} + /// Resolver for direct, pre-shared AES content keys. #[derive(Clone)] pub struct SymmetricKeyDecryptor { @@ -74,6 +204,7 @@ impl SymmetricKeyDecryptor { impl DecryptionKeyResolver for SymmetricKeyDecryptor { fn resolve_key( &self, + _provider: &dyn crate::provider::CryptoProvider, algorithm: DataEncryptionAlgorithm, _encrypted_key: Option<&EncryptedKey>, ) -> Result, XmlEncError> { @@ -113,6 +244,7 @@ impl KekDecryptor { impl DecryptionKeyResolver for KekDecryptor { fn resolve_key( &self, + provider: &dyn crate::provider::CryptoProvider, algorithm: DataEncryptionAlgorithm, encrypted_key: Option<&EncryptedKey>, ) -> Result, XmlEncError> { @@ -122,25 +254,24 @@ impl DecryptionKeyResolver for KekDecryptor { .map_err(|error| XmlEncError::Base64(error.to_string()))?; let wrap_algorithm = KeyWrapAlgorithm::from_uri(&encrypted_key.encryption_method.algorithm)?; - if self.kek.len() != wrap_algorithm.key_len() { - return Err(XmlEncError::InvalidKekSize { - algorithm: wrap_algorithm, - expected: wrap_algorithm.key_len(), - actual: self.kek.len(), - }); - } - let mut output = vec![0_u8; wrapped.len().saturating_sub(8)]; - let key = match wrap_algorithm { - KeyWrapAlgorithm::AesKw128 => KwAes128::new_from_slice(&self.kek) - .map_err(|_| invalid_kek_size(wrap_algorithm, self.kek.len()))? - .unwrap_key(&wrapped, &mut output), - KeyWrapAlgorithm::AesKw256 => KwAes256::new_from_slice(&self.kek) - .map_err(|_| invalid_kek_size(wrap_algorithm, self.kek.len()))? - .unwrap_key(&wrapped, &mut output), - } - .map_err(|_| XmlEncError::KeyWrapIntegrity)?; - validate_key_len(algorithm, key)?; - Ok(key.to_vec()) + let key = provider + .unwrap_key(wrap_algorithm, &self.kek, &wrapped) + .map_err(|error| match error { + crate::provider::ProviderError::InvalidKeySize { expected, actual } => { + XmlEncError::InvalidKekSize { + algorithm: wrap_algorithm, + expected, + actual, + } + } + crate::provider::ProviderError::AuthenticationFailed + | crate::provider::ProviderError::InvalidInput( + crate::provider::ProviderInputError::AesKeyWrapFraming, + ) => XmlEncError::KeyWrapIntegrity, + error => XmlEncError::Provider(error), + })?; + validate_key_len(algorithm, &key)?; + Ok(key) } } @@ -154,6 +285,7 @@ impl PrivateKeyDecryptor { impl DecryptionKeyResolver for PrivateKeyDecryptor { fn resolve_key( &self, + provider: &dyn crate::provider::CryptoProvider, algorithm: DataEncryptionAlgorithm, encrypted_key: Option<&EncryptedKey>, ) -> Result, XmlEncError> { @@ -170,11 +302,13 @@ impl DecryptionKeyResolver for PrivateKeyDecryptor { KeyTransportAlgorithm::from_uri(&encrypted_key.encryption_method.algorithm)?; let key = match transport { KeyTransportAlgorithm::RsaOaepMgf1p => self.decrypt_oaep_mgf1p( + provider, encrypted_key.encryption_method.oaep_digest.as_deref(), label, &wrapped, ), KeyTransportAlgorithm::RsaOaep11 => self.decrypt_oaep11( + provider, encrypted_key.encryption_method.oaep_digest.as_deref(), encrypted_key.encryption_method.mgf_algorithm.as_deref(), label, @@ -189,112 +323,83 @@ impl DecryptionKeyResolver for PrivateKeyDecryptor { impl PrivateKeyDecryptor { fn decrypt_oaep_mgf1p( &self, + provider: &dyn crate::provider::CryptoProvider, digest: Option<&str>, label: Vec, wrapped: &[u8], ) -> Result, XmlEncError> { - // Passing SysRng through PaddingScheme keeps private-key blinding while - // preserving operating-system RNG failures as typed errors. - match digest.unwrap_or("http://www.w3.org/2000/09/xmldsig#sha1") { - "http://www.w3.org/2000/09/xmldsig#sha1" => Oaep::::new_with_label(label) - .decrypt(Some(&mut SysRng), &self.key, wrapped) - .map_err(rsa_error), - "http://www.w3.org/2001/04/xmlenc#sha256" => { - Oaep::::new_with_mgf_hash_and_label(label) - .decrypt(Some(&mut SysRng), &self.key, wrapped) - .map_err(rsa_error) - } - "http://www.w3.org/2001/04/xmlenc#sha384" - | "http://www.w3.org/2001/04/xmldsig-more#sha384" => { - Oaep::::new_with_mgf_hash_and_label(label) - .decrypt(Some(&mut SysRng), &self.key, wrapped) - .map_err(rsa_error) - } - "http://www.w3.org/2001/04/xmlenc#sha512" => { - Oaep::::new_with_mgf_hash_and_label(label) - .decrypt(Some(&mut SysRng), &self.key, wrapped) - .map_err(rsa_error) - } - unsupported => Err(XmlEncError::UnsupportedAlgorithm(unsupported.to_owned())), - } + let parameters = RsaOaepParameters { + algorithm: KeyTransportAlgorithm::RsaOaepMgf1p, + digest: parse_oaep_digest(digest)?, + mgf_digest: OaepDigestAlgorithm::Sha1, + label, + }; + recover_rsa_oaep(provider, &self.key, ¶meters, wrapped) } fn decrypt_oaep11( &self, + provider: &dyn crate::provider::CryptoProvider, digest: Option<&str>, mgf: Option<&str>, label: Vec, wrapped: &[u8], ) -> Result, XmlEncError> { - const SHA1: &str = "http://www.w3.org/2000/09/xmldsig#sha1"; - const SHA256: &str = "http://www.w3.org/2001/04/xmlenc#sha256"; - const SHA384: &str = "http://www.w3.org/2001/04/xmlenc#sha384"; - const SHA384_COMPAT: &str = "http://www.w3.org/2001/04/xmldsig-more#sha384"; - const SHA512: &str = "http://www.w3.org/2001/04/xmlenc#sha512"; - const MGF1_SHA1: &str = "http://www.w3.org/2009/xmlenc11#mgf1sha1"; - const MGF1_SHA256: &str = "http://www.w3.org/2009/xmlenc11#mgf1sha256"; - const MGF1_SHA384: &str = "http://www.w3.org/2009/xmlenc11#mgf1sha384"; - const MGF1_SHA512: &str = "http://www.w3.org/2009/xmlenc11#mgf1sha512"; - - macro_rules! decrypt_with { - ($digest:ty, $mgf:ty) => { - Oaep::<$digest, $mgf>::new_with_mgf_hash_and_label(label) - .decrypt(Some(&mut SysRng), &self.key, wrapped) - .map_err(rsa_error) - }; - } - - let digest = match digest.unwrap_or(SHA1) { - SHA384_COMPAT => SHA384, - digest => digest, + let parameters = RsaOaepParameters { + algorithm: KeyTransportAlgorithm::RsaOaep11, + digest: parse_oaep_digest(digest)?, + mgf_digest: parse_oaep_mgf_digest(mgf)?, + label, }; - match (digest, mgf.unwrap_or(MGF1_SHA1)) { - (SHA1, MGF1_SHA1) => decrypt_with!(Sha1, Sha1), - (SHA1, MGF1_SHA256) => decrypt_with!(Sha1, Sha256), - (SHA1, MGF1_SHA384) => decrypt_with!(Sha1, Sha384), - (SHA1, MGF1_SHA512) => decrypt_with!(Sha1, Sha512), - (SHA256, MGF1_SHA1) => decrypt_with!(Sha256, Sha1), - (SHA256, MGF1_SHA256) => decrypt_with!(Sha256, Sha256), - (SHA256, MGF1_SHA384) => decrypt_with!(Sha256, Sha384), - (SHA256, MGF1_SHA512) => decrypt_with!(Sha256, Sha512), - (SHA384, MGF1_SHA1) => decrypt_with!(Sha384, Sha1), - (SHA384, MGF1_SHA256) => decrypt_with!(Sha384, Sha256), - (SHA384, MGF1_SHA384) => decrypt_with!(Sha384, Sha384), - (SHA384, MGF1_SHA512) => decrypt_with!(Sha384, Sha512), - (SHA512, MGF1_SHA1) => decrypt_with!(Sha512, Sha1), - (SHA512, MGF1_SHA256) => decrypt_with!(Sha512, Sha256), - (SHA512, MGF1_SHA384) => decrypt_with!(Sha512, Sha384), - (SHA512, MGF1_SHA512) => decrypt_with!(Sha512, Sha512), - (unsupported, MGF1_SHA1 | MGF1_SHA256 | MGF1_SHA384 | MGF1_SHA512) => { - Err(XmlEncError::UnsupportedAlgorithm(unsupported.to_owned())) - } - (_, unsupported) => Err(XmlEncError::UnsupportedAlgorithm(unsupported.to_owned())), - } + recover_rsa_oaep(provider, &self.key, ¶meters, wrapped) } } -fn rsa_error(error: rsa::Error) -> XmlEncError { - match error { - rsa::Error::Rng => XmlEncError::Rng("RSA-OAEP blinding failed".into()), - error => XmlEncError::Rsa(error.to_string()), +fn parse_oaep_digest(uri: Option<&str>) -> Result { + match uri.unwrap_or("http://www.w3.org/2000/09/xmldsig#sha1") { + "http://www.w3.org/2000/09/xmldsig#sha1" => Ok(OaepDigestAlgorithm::Sha1), + "http://www.w3.org/2001/04/xmlenc#sha256" => Ok(OaepDigestAlgorithm::Sha256), + "http://www.w3.org/2001/04/xmlenc#sha384" + | "http://www.w3.org/2001/04/xmldsig-more#sha384" => Ok(OaepDigestAlgorithm::Sha384), + "http://www.w3.org/2001/04/xmlenc#sha512" => Ok(OaepDigestAlgorithm::Sha512), + unsupported => Err(XmlEncError::UnsupportedAlgorithm(unsupported.to_owned())), } } -fn invalid_kek_size(algorithm: KeyWrapAlgorithm, actual: usize) -> XmlEncError { - XmlEncError::InvalidKekSize { - algorithm, - expected: algorithm.key_len(), - actual, +fn parse_oaep_mgf_digest(uri: Option<&str>) -> Result { + match uri.unwrap_or("http://www.w3.org/2009/xmlenc11#mgf1sha1") { + "http://www.w3.org/2009/xmlenc11#mgf1sha1" => Ok(OaepDigestAlgorithm::Sha1), + "http://www.w3.org/2009/xmlenc11#mgf1sha256" => Ok(OaepDigestAlgorithm::Sha256), + "http://www.w3.org/2009/xmlenc11#mgf1sha384" => Ok(OaepDigestAlgorithm::Sha384), + "http://www.w3.org/2009/xmlenc11#mgf1sha512" => Ok(OaepDigestAlgorithm::Sha512), + unsupported => Err(XmlEncError::UnsupportedAlgorithm(unsupported.to_owned())), } } +fn recover_rsa_oaep( + provider: &dyn crate::provider::CryptoProvider, + key: &RsaPrivateKey, + parameters: &RsaOaepParameters, + wrapped: &[u8], +) -> Result, XmlEncError> { + provider + .recover_key(key, parameters, wrapped) + .map_err(|error| match error { + crate::provider::ProviderError::Random(message) => XmlEncError::Rng(message), + error @ (crate::provider::ProviderError::AuthenticationFailed + | crate::provider::ProviderError::InvalidInput(_)) => { + XmlEncError::Rsa(error.to_string()) + } + error => XmlEncError::Provider(error), + }) +} + /// Parse and decrypt a standalone `EncryptedData` XML fragment. pub fn decrypt( xml: &str, resolver: &dyn DecryptionKeyResolver, ) -> Result { - let encrypted = parse_encrypted_data(xml)?; - decrypt_data(&encrypted, resolver) + DecryptContext::new(resolver).decrypt(xml) } /// Decrypt and replace one `EncryptedData` element in a caller-owned XML document. @@ -323,18 +428,28 @@ pub fn decrypt_document_with_options( xml: &str, options: DocumentDecryptionOptions<'_>, resolver: &dyn DecryptionKeyResolver, +) -> Result { + let mut policy = crate::policy::DecryptionPolicy::default(); + policy.xml.allow_internal_dtd = options.allow_dtd; + DecryptContext::new(resolver) + .policy(policy) + .decrypt_document(xml, options.encrypted_data_id) +} + +fn decrypt_document_with_context( + xml: &str, + encrypted_data_id: Option<&str>, + context: &DecryptContext<'_>, ) -> Result { let parsing_options = || ParsingOptions { - allow_dtd: options.allow_dtd, + allow_dtd: context.policy.xml.allow_internal_dtd, entity_resolver: None, ..ParsingOptions::default() }; let document = Document::parse_with_options(xml, parsing_options())?; let mut matches = document.descendants().filter(|node| { node.has_tag_name((XMLENC_NS, "EncryptedData")) - && options - .encrypted_data_id - .is_none_or(|id| node.attribute("Id") == Some(id)) + && encrypted_data_id.is_none_or(|id| node.attribute("Id") == Some(id)) }); let selected = matches.next().ok_or(XmlEncError::EncryptedDataNotFound)?; if matches.next().is_some() { @@ -343,7 +458,7 @@ pub fn decrypt_document_with_options( let range = selected.range(); let encrypted = parse_encrypted_data_node(selected)?; - let DecryptedContent::Xml(plaintext) = decrypt_data(&encrypted, resolver)? else { + let DecryptedContent::Xml(plaintext) = context.decrypt_data(&encrypted)? else { return Err(XmlEncError::ReplacementRequiresXml); }; @@ -353,7 +468,7 @@ pub fn decrypt_document_with_options( range.end, &plaintext, encrypted.encrypted_type.as_ref(), - options.allow_dtd, + context.policy.xml.allow_internal_dtd, )?; let mut output = String::with_capacity(xml.len() - range.len() + plaintext.len()); @@ -429,27 +544,16 @@ pub fn decrypt_data( encrypted: &EncryptedData, resolver: &dyn DecryptionKeyResolver, ) -> Result { - let algorithm = DataEncryptionAlgorithm::from_uri(&encrypted.encryption_method.algorithm)?; - let key = resolve_content_key(algorithm, &encrypted.encrypted_keys, resolver)?; - validate_key_len(algorithm, &key)?; - let ciphertext = STANDARD - .decode(&encrypted.cipher_data.value) - .map_err(|error| XmlEncError::Base64(error.to_string()))?; - let plaintext = decrypt_content(algorithm, &key, &ciphertext)?; - match encrypted.encrypted_type.as_ref() { - Some(EncryptedDataType::Element | EncryptedDataType::Content) => { - Ok(DecryptedContent::Xml(String::from_utf8(plaintext)?)) - } - Some(EncryptedDataType::Other(_)) | None => Ok(DecryptedContent::Bytes(plaintext)), - } + DecryptContext::new(resolver).decrypt_data(encrypted) } fn resolve_content_key( + provider: &dyn crate::provider::CryptoProvider, algorithm: DataEncryptionAlgorithm, encrypted_keys: &[EncryptedKey], resolver: &dyn DecryptionKeyResolver, ) -> Result, XmlEncError> { - match resolver.resolve_key(algorithm, None) { + match resolver.resolve_key(provider, algorithm, None) { Ok(key) => return Ok(key), Err(XmlEncError::KeyNotFound) => {} Err(error) => return Err(error), @@ -457,7 +561,7 @@ fn resolve_content_key( let mut last_error = None; for encrypted_key in encrypted_keys { - match resolver.resolve_key(algorithm, Some(encrypted_key)) { + match resolver.resolve_key(provider, algorithm, Some(encrypted_key)) { Ok(key) => return Ok(key), Err(error) => last_error = Some(error), } @@ -477,100 +581,70 @@ fn validate_key_len(algorithm: DataEncryptionAlgorithm, key: &[u8]) -> Result<() } } -fn decrypt_content( +fn validate_possible_plaintext_len( algorithm: DataEncryptionAlgorithm, - key: &[u8], - ciphertext: &[u8], -) -> Result, XmlEncError> { - match algorithm { - DataEncryptionAlgorithm::Aes128Gcm => decrypt_gcm::(key, ciphertext), - DataEncryptionAlgorithm::Aes256Gcm => decrypt_gcm::(key, ciphertext), - DataEncryptionAlgorithm::Aes128Cbc => decrypt_cbc_128(key, ciphertext), - DataEncryptionAlgorithm::Aes256Cbc => decrypt_cbc_256(key, ciphertext), - } + ciphertext_len: usize, + maximum: usize, +) -> Result<(), XmlEncError> { + let framing = match algorithm { + // CBC always contains a 16-byte IV and at least one padding byte. + DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc => 17, + DataEncryptionAlgorithm::Aes128Gcm | DataEncryptionAlgorithm::Aes256Gcm => 28, + }; + validate_plaintext_len(ciphertext_len.saturating_sub(framing), maximum) } -fn decrypt_gcm(key: &[u8], ciphertext: &[u8]) -> Result, XmlEncError> -where - C: AeadInOut + KeyInit, -{ - const NONCE_LEN: usize = 12; - const TAG_LEN: usize = 16; - if ciphertext.len() < NONCE_LEN + TAG_LEN { - return Err(XmlEncError::DataTooShort { - algorithm: "AES-GCM", - minimum: NONCE_LEN + TAG_LEN, - actual: ciphertext.len(), - }); +fn validate_plaintext_len(actual: usize, maximum: usize) -> Result<(), XmlEncError> { + if actual <= maximum { + Ok(()) + } else { + Err(XmlEncError::PlaintextTooLarge { maximum, actual }) } - let (nonce, encrypted) = ciphertext.split_at(NONCE_LEN); - let cipher = C::new_from_slice(key).map_err(|_| XmlEncError::AeadAuthenticationFailed)?; - let mut output = encrypted.to_vec(); - let nonce = Nonce::try_from(nonce).map_err(|_| XmlEncError::AeadAuthenticationFailed)?; - cipher - .decrypt_in_place(&nonce, b"", &mut output) - .map_err(|_| XmlEncError::AeadAuthenticationFailed)?; - Ok(output) } -fn cbc_input(ciphertext: &[u8]) -> Result<(&[u8], &[u8]), XmlEncError> { - const BLOCK: usize = 16; - if ciphertext.len() < BLOCK * 2 { - return Err(XmlEncError::DataTooShort { +fn map_data_decryption_error( + algorithm: DataEncryptionAlgorithm, + ciphertext_len: usize, + error: crate::provider::ProviderError, +) -> XmlEncError { + use crate::provider::ProviderError; + + match (algorithm, error) { + ( + DataEncryptionAlgorithm::Aes128Gcm | DataEncryptionAlgorithm::Aes256Gcm, + ProviderError::AuthenticationFailed, + ) => XmlEncError::AeadAuthenticationFailed, + ( + DataEncryptionAlgorithm::Aes128Gcm | DataEncryptionAlgorithm::Aes256Gcm, + ProviderError::InvalidInput(crate::provider::ProviderInputError::AesGcmFraming), + ) => XmlEncError::DataTooShort { + algorithm: "AES-GCM", + minimum: 28, + actual: ciphertext_len, + }, + ( + DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc, + ProviderError::InvalidInput(crate::provider::ProviderInputError::AesCbcFraming), + ) if ciphertext_len < 32 => XmlEncError::DataTooShort { algorithm: "AES-CBC", - minimum: BLOCK * 2, - actual: ciphertext.len(), - }); - } - let (iv, encrypted) = ciphertext.split_at(BLOCK); - if encrypted.len() % BLOCK != 0 { - return Err(XmlEncError::InvalidCbcCiphertextLength(encrypted.len())); - } - Ok((iv, encrypted)) -} - -fn remove_cbc_padding(plaintext: &[u8]) -> Result, XmlEncError> { - const BLOCK: usize = 16; - let pad_len = *plaintext.last().ok_or(XmlEncError::DataTooShort { - algorithm: "AES-CBC", - minimum: 1, - actual: 0, - })?; - if pad_len == 0 || usize::from(pad_len) > BLOCK { - return Err(XmlEncError::InvalidPadding { + minimum: 32, + actual: ciphertext_len, + }, + ( + DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc, + ProviderError::InvalidInput(crate::provider::ProviderInputError::AesCbcFraming), + ) => XmlEncError::InvalidCbcCiphertextLength(ciphertext_len.saturating_sub(16)), + ( + DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc, + ProviderError::InvalidInput(crate::provider::ProviderInputError::XmlEncCbcPadding { + pad_len, + }), + ) => XmlEncError::InvalidPadding { pad_len, - block_size: BLOCK, - }); + block_size: 16, + }, + (_, error) => XmlEncError::Provider(error), } - Ok(plaintext[..plaintext.len() - usize::from(pad_len)].to_vec()) -} - -fn decrypt_cbc_128(key: &[u8], ciphertext: &[u8]) -> Result, XmlEncError> { - let (iv, encrypted) = cbc_input(ciphertext)?; - let mut output = encrypted.to_vec(); - let plaintext = Decryptor::::new_from_slices(key, iv) - .map_err(|_| XmlEncError::InvalidKeySize { - algorithm: DataEncryptionAlgorithm::Aes128Cbc, - expected: 16, - actual: key.len(), - })? - .decrypt_padded::(&mut output) - .map_err(|_| XmlEncError::InvalidCbcCiphertextLength(encrypted.len()))?; - remove_cbc_padding(plaintext) -} - -fn decrypt_cbc_256(key: &[u8], ciphertext: &[u8]) -> Result, XmlEncError> { - let (iv, encrypted) = cbc_input(ciphertext)?; - let mut output = encrypted.to_vec(); - let plaintext = Decryptor::::new_from_slices(key, iv) - .map_err(|_| XmlEncError::InvalidKeySize { - algorithm: DataEncryptionAlgorithm::Aes256Cbc, - expected: 32, - actual: key.len(), - })? - .decrypt_padded::(&mut output) - .map_err(|_| XmlEncError::InvalidCbcCiphertextLength(encrypted.len()))?; - remove_cbc_padding(plaintext) } #[cfg(test)] @@ -582,7 +656,9 @@ mod tests { use aes_kw::KwAes128; use base64::{Engine as _, engine::general_purpose::STANDARD}; use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng}; - use rsa::{RsaPublicKey, pkcs8::DecodePrivateKey}; + use rsa::{Oaep, RsaPublicKey, pkcs8::DecodePrivateKey}; + use sha1::Sha1; + use sha2::{Sha256, Sha384}; use super::*; @@ -594,6 +670,7 @@ mod tests { impl DecryptionKeyResolver for RecipientKeyResolver { fn resolve_key( &self, + _provider: &dyn crate::provider::CryptoProvider, _algorithm: DataEncryptionAlgorithm, encrypted_key: Option<&EncryptedKey>, ) -> Result, XmlEncError> { @@ -636,30 +713,6 @@ mod tests { )); } - #[test] - fn handles_xmlenc_cbc_padding_boundaries() { - // XMLEnc permits random padding bytes and uses only the final byte as - // the length, including the one-byte and full-block boundaries. - assert_eq!( - remove_cbc_padding(b"plaintext\x01").expect("one-byte padding must be valid"), - b"plaintext" - ); - let mut full_block = [0x5a_u8; 16]; - full_block[15] = 16; - assert_eq!( - remove_cbc_padding(&full_block).expect("full-block padding must be valid"), - Vec::::new() - ); - assert!(matches!( - remove_cbc_padding(&[0]), - Err(XmlEncError::InvalidPadding { pad_len: 0, .. }) - )); - assert!(matches!( - remove_cbc_padding(&[17]), - Err(XmlEncError::InvalidPadding { pad_len: 17, .. }) - )); - } - #[test] fn direct_symmetric_key_ignores_embedded_key_hints() { // A caller-supplied content key is authoritative for this resolver; @@ -685,7 +738,11 @@ mod tests { assert_eq!( SymmetricKeyDecryptor::new(key) - .resolve_key(DataEncryptionAlgorithm::Aes128Gcm, Some(&unrelated)) + .resolve_key( + crate::provider::default_provider(), + DataEncryptionAlgorithm::Aes128Gcm, + Some(&unrelated) + ) .expect("direct key must ignore unrelated embedded hints"), key ); @@ -753,7 +810,11 @@ mod tests { carried_key_name: None, }; let resolved = KekDecryptor::new(kek) - .resolve_key(DataEncryptionAlgorithm::Aes128Gcm, Some(&encrypted_key)) + .resolve_key( + crate::provider::default_provider(), + DataEncryptionAlgorithm::Aes128Gcm, + Some(&encrypted_key), + ) .expect("wrapped session key must resolve"); assert_eq!(resolved, session_key); } @@ -762,9 +823,36 @@ mod tests { fn rejects_truncated_gcm_and_invalid_wrapped_key() { // Framing and key-wrap integrity failures must occur before content is exposed. assert!(matches!( - decrypt_content(DataEncryptionAlgorithm::Aes128Gcm, &[0_u8; 16], &[0_u8; 27]), + crate::provider::default_provider().decrypt_data( + DataEncryptionAlgorithm::Aes128Gcm, + &[0_u8; 16], + &[0_u8; 27], + ), + Err(crate::provider::ProviderError::InvalidInput( + crate::provider::ProviderInputError::AesGcmFraming + )) + )); + let truncated = EncryptedData { + id: None, + encrypted_type: None, + key_name: None, + encryption_method: super::super::EncryptionMethod { + algorithm: DataEncryptionAlgorithm::Aes128Gcm.uri().into(), + key_size_bits: None, + oaep_digest: None, + mgf_algorithm: None, + oaep_params: None, + }, + encrypted_keys: Vec::new(), + cipher_data: super::super::CipherData { + value: STANDARD.encode([0_u8; 27]), + }, + }; + assert!(matches!( + DecryptContext::new(&SymmetricKeyDecryptor::new([0_u8; 16])).decrypt_data(&truncated), Err(XmlEncError::DataTooShort { algorithm: "AES-GCM", + actual: 27, .. }) )); @@ -786,13 +874,19 @@ mod tests { carried_key_name: None, }; assert!(matches!( - KekDecryptor::new([0_u8; 16]) - .resolve_key(DataEncryptionAlgorithm::Aes128Gcm, Some(&encrypted_key)), + KekDecryptor::new([0_u8; 16]).resolve_key( + crate::provider::default_provider(), + DataEncryptionAlgorithm::Aes128Gcm, + Some(&encrypted_key) + ), Err(XmlEncError::KeyWrapIntegrity) )); assert!(matches!( - KekDecryptor::new([0_u8; 32]) - .resolve_key(DataEncryptionAlgorithm::Aes128Gcm, Some(&encrypted_key)), + KekDecryptor::new([0_u8; 32]).resolve_key( + crate::provider::default_provider(), + DataEncryptionAlgorithm::Aes128Gcm, + Some(&encrypted_key) + ), Err(XmlEncError::InvalidKekSize { algorithm: KeyWrapAlgorithm::AesKw128, expected: 16, @@ -836,7 +930,11 @@ mod tests { carried_key_name: None, }; let resolved = PrivateKeyDecryptor::new(private_key) - .resolve_key(DataEncryptionAlgorithm::Aes128Gcm, Some(&encrypted_key)) + .resolve_key( + crate::provider::default_provider(), + DataEncryptionAlgorithm::Aes128Gcm, + Some(&encrypted_key), + ) .expect("OAEP 1.1 wrapped key must resolve"); assert_eq!(resolved, session_key); } @@ -875,7 +973,11 @@ mod tests { carried_key_name: None, }; let resolved = PrivateKeyDecryptor::new(private_key) - .resolve_key(DataEncryptionAlgorithm::Aes128Gcm, Some(&encrypted_key)) + .resolve_key( + crate::provider::default_provider(), + DataEncryptionAlgorithm::Aes128Gcm, + Some(&encrypted_key), + ) .expect("legacy OAEP URI with SHA-256 must resolve"); assert_eq!(resolved, session_key); } @@ -924,7 +1026,11 @@ mod tests { carried_key_name: None, }; let resolved = PrivateKeyDecryptor::new(private_key.clone()) - .resolve_key(DataEncryptionAlgorithm::Aes128Gcm, Some(&encrypted_key)) + .resolve_key( + crate::provider::default_provider(), + DataEncryptionAlgorithm::Aes128Gcm, + Some(&encrypted_key), + ) .expect("official XMLENC SHA-384 URI must resolve"); assert_eq!(resolved, session_key); } @@ -956,18 +1062,98 @@ mod tests { carried_key_name: None, }; assert!(matches!( - decryptor.resolve_key(DataEncryptionAlgorithm::Aes128Gcm, Some(&encrypted_key)), + decryptor.resolve_key(crate::provider::default_provider(), DataEncryptionAlgorithm::Aes128Gcm, Some(&encrypted_key)), Err(XmlEncError::UnsupportedAlgorithm(uri)) if uri == "urn:unsupported:digest" )); encrypted_key.encryption_method.oaep_digest = None; encrypted_key.encryption_method.mgf_algorithm = Some("urn:unsupported:mgf".into()); assert!(matches!( - decryptor.resolve_key(DataEncryptionAlgorithm::Aes128Gcm, Some(&encrypted_key)), + decryptor.resolve_key(crate::provider::default_provider(), DataEncryptionAlgorithm::Aes128Gcm, Some(&encrypted_key)), Err(XmlEncError::UnsupportedAlgorithm(uri)) if uri == "urn:unsupported:mgf" )); } + #[test] + fn decryption_policy_enforces_oaep_digest_and_plaintext_limits() { + // Algorithm and allocation policies are checked before key resolution + // or plaintext materialization, including the document-declared MGF. + let encrypted_key = EncryptedKey { + id: None, + recipient: None, + key_name: None, + encryption_method: super::super::EncryptionMethod { + algorithm: KeyTransportAlgorithm::RsaOaep11.uri().into(), + key_size_bits: None, + oaep_digest: Some(OaepDigestAlgorithm::Sha256.uri().into()), + mgf_algorithm: Some("http://www.w3.org/2009/xmlenc11#mgf1sha1".into()), + oaep_params: None, + }, + cipher_data: super::super::CipherData { + value: STANDARD.encode([0_u8; 256]), + }, + reference_list: None, + carried_key_name: None, + }; + let encrypted = EncryptedData { + id: None, + encrypted_type: None, + key_name: None, + encryption_method: super::super::EncryptionMethod { + algorithm: DataEncryptionAlgorithm::Aes128Gcm.uri().into(), + key_size_bits: None, + oaep_digest: None, + mgf_algorithm: None, + oaep_params: None, + }, + encrypted_keys: vec![encrypted_key], + cipher_data: super::super::CipherData { + value: STANDARD.encode([0_u8; 28]), + }, + }; + let policy = crate::policy::DecryptionPolicy { + oaep_digests: Some(std::collections::HashSet::from([ + OaepDigestAlgorithm::Sha256, + ])), + ..crate::policy::DecryptionPolicy::default() + }; + assert!(matches!( + DecryptContext::new(&SymmetricKeyDecryptor::new([0_u8; 16])) + .policy(policy) + .decrypt_data(&encrypted), + Err(XmlEncError::Policy( + crate::policy::PolicyViolation::Algorithm { .. } + )) + )); + + let ciphertext = crate::provider::default_provider() + .encrypt_data(DataEncryptionAlgorithm::Aes128Gcm, &[0_u8; 16], b"four") + .expect("test encryption must succeed"); + let bounded = EncryptedData { + encrypted_keys: Vec::new(), + cipher_data: super::super::CipherData { + value: STANDARD.encode(ciphertext), + }, + ..encrypted + }; + let policy = crate::policy::DecryptionPolicy { + resources: crate::policy::ResourcePolicy { + max_encryption_plaintext_bytes: 3, + ..crate::policy::ResourcePolicy::default() + }, + ..crate::policy::DecryptionPolicy::default() + }; + assert!(matches!( + DecryptContext::new(&SymmetricKeyDecryptor::new([0_u8; 16])) + .policy(policy) + .decrypt_data(&bounded), + Err(XmlEncError::PlaintextTooLarge { + maximum: 3, + actual: 4 + }) + )); + } + #[test] fn replaces_element_and_content_in_caller_owned_documents() { // Element plaintext replaces the encrypted node itself, while Content diff --git a/src/xmlenc/encrypt.rs b/src/xmlenc/encrypt.rs index ac0e8664..2c6e06e5 100644 --- a/src/xmlenc/encrypt.rs +++ b/src/xmlenc/encrypt.rs @@ -1,38 +1,22 @@ //! XMLEnc content encryption, key wrapping, and XML generation. -use std::fmt; +use std::{fmt, sync::Arc}; -use aes::{ - Aes128, Aes256, - cipher::{BlockModeEncrypt, KeyIvInit, block_padding::NoPadding}, -}; -use aes_gcm::{ - Aes128Gcm, Aes256Gcm, Nonce, - aead::{AeadInOut, KeyInit}, -}; -use aes_kw::{KwAes128, KwAes256}; use base64::{Engine as _, engine::general_purpose::STANDARD}; -use cbc::Encryptor; -use getrandom::{SysRng, rand_core::TryRng}; use quick_xml::{ Writer, events::{BytesEnd, BytesStart, BytesText, Event}, }; use roxmltree::{Document, Node, ParsingOptions}; -use rsa::{Oaep, RsaPublicKey, traits::PaddingScheme}; -use sha1::Sha1; -use sha2::{Sha256, Sha384, Sha512}; +use rsa::RsaPublicKey; use crate::xml::is_xml_1_0_character; -use super::types::{ - MAX_ENCRYPTION_DOCUMENT_LEN, MAX_ENCRYPTION_METADATA_LEN, MAX_ENCRYPTION_PLAINTEXT_LEN, - MAX_ENCRYPTION_RECIPIENTS, XMLDSIG_NS, XMLENC_NS, XMLENC11_NS, -}; +use super::types::{XMLDSIG_NS, XMLENC_NS, XMLENC11_NS}; use super::{ DataEncryptionAlgorithm, DocumentEncryptionOptions, EncryptedDataType, EncryptionRecipient, - EncryptionResult, KeyWrapAlgorithm, OaepDigestAlgorithm, ReplacementMode, RsaOaepParameters, - XmlEncError, has_single_element_with_boundary_trivia, + EncryptionResult, KeyWrapAlgorithm, ReplacementMode, RsaOaepParameters, XmlEncError, + has_single_element_with_boundary_trivia, }; const XML_WHITESPACE: &[char] = &[' ', '\t', '\n', '\r']; @@ -46,6 +30,8 @@ pub struct EncryptedDataBuilder { direct_key: Option>, direct_key_name: Option, recipients: Vec, + policy: crate::policy::EncryptionPolicy, + provider: Arc, } impl fmt::Debug for EncryptedDataBuilder { @@ -61,6 +47,8 @@ impl fmt::Debug for EncryptedDataBuilder { ) .field("direct_key_name", &self.direct_key_name) .field("recipients", &self.recipients) + .field("policy", &self.policy) + .field("provider", &self.provider.name()) .finish() } } @@ -75,9 +63,23 @@ impl EncryptedDataBuilder { direct_key: None, direct_key_name: None, recipients: Vec::new(), + policy: crate::policy::EncryptionPolicy::default(), + provider: Arc::new(crate::provider::RustCryptoProvider), } } + /// Replace the complete immutable encryption policy snapshot. + pub fn policy(mut self, policy: crate::policy::EncryptionPolicy) -> Self { + self.policy = policy; + self + } + + /// Select the cryptographic provider for this operation context. + pub fn provider(mut self, provider: Arc) -> Self { + self.provider = provider; + self + } + /// Set whether XML encryption covers one element or its child content. pub fn encryption_type(mut self, encrypted_type: EncryptedDataType) -> Self { self.encrypted_type = encrypted_type; @@ -120,13 +122,15 @@ impl EncryptedDataBuilder { /// Encrypt one complete XML element or an XML content fragment. pub fn encrypt_xml(&self, xml: &str) -> Result { - validate_plaintext_len(xml.len())?; + self.policy.resources.validate()?; + self.validate_plaintext_len(xml.len())?; validate_xml_plaintext(xml, &self.encrypted_type)?; self.encrypt_payload(xml.as_bytes(), Some(self.encrypted_type.clone())) } /// Encrypt opaque bytes without an XML `Type` attribute. pub fn encrypt_binary(&self, data: &[u8]) -> Result { + self.policy.resources.validate()?; self.encrypt_payload(data, None) } @@ -136,9 +140,10 @@ impl EncryptedDataBuilder { xml: &str, options: DocumentEncryptionOptions<'_>, ) -> Result { - validate_document_len(xml.len())?; + self.policy.resources.validate()?; + self.validate_document_len(xml.len())?; let parsing_options = ParsingOptions { - allow_dtd: options.allow_dtd, + allow_dtd: self.policy.xml.allow_internal_dtd && options.allow_dtd, entity_resolver: None, ..ParsingOptions::default() }; @@ -171,20 +176,25 @@ impl EncryptedDataBuilder { plaintext: &[u8], encrypted_type: Option, ) -> Result { - validate_plaintext_len(plaintext.len())?; + self.validate_plaintext_len(plaintext.len())?; self.validate_configuration()?; let content_key = if let Some(key) = &self.direct_key { validate_content_key(self.algorithm, key)?; key.clone() } else { - random_bytes(self.algorithm.key_len())? + random_bytes(self.provider.as_ref(), self.algorithm.key_len())? }; - let ciphertext = encrypt_content(self.algorithm, &content_key, plaintext)?; + let ciphertext = encrypt_content( + self.provider.as_ref(), + self.algorithm, + &content_key, + plaintext, + )?; let encrypted_keys = self .recipients .iter() - .map(|recipient| wrap_content_key(recipient, &content_key)) + .map(|recipient| wrap_content_key(self.provider.as_ref(), recipient, &content_key)) .collect::, _>>()?; let encrypted_data_xml = render_encrypted_data( self.algorithm, @@ -207,19 +217,32 @@ impl EncryptedDataBuilder { } fn validate_configuration(&self) -> Result<(), XmlEncError> { + self.policy.resources.validate()?; + if self + .policy + .data_algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(&self.algorithm)) + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "encryption", + algorithm: self.algorithm.to_string(), + } + .into()); + } if matches!(self.encrypted_type, EncryptedDataType::Other(_)) { return Err(XmlEncError::InvalidEncryptionConfig( "Other Type hints are not valid for XML encryption".into(), )); } - if self.recipients.len() > MAX_ENCRYPTION_RECIPIENTS { + if self.recipients.len() > self.policy.resources.max_encryption_recipients { return Err(XmlEncError::TooManyRecipients { - maximum: MAX_ENCRYPTION_RECIPIENTS, + maximum: self.policy.resources.max_encryption_recipients, actual: self.recipients.len(), }); } - validate_metadata("EncryptedData Id", self.id.as_deref())?; - validate_key_name("direct KeyName", self.direct_key_name.as_deref())?; + self.validate_metadata("EncryptedData Id", self.id.as_deref())?; + self.validate_key_name("direct KeyName", self.direct_key_name.as_deref())?; for recipient in &self.recipients { match recipient { EncryptionRecipient::RsaOaep { @@ -228,17 +251,46 @@ impl EncryptedDataBuilder { key_name, .. } => { - validate_metadata("EncryptedKey Recipient", recipient.as_deref())?; - validate_key_name("EncryptedKey KeyName", key_name.as_deref())?; - validate_metadata_len("OAEPparams", parameters.label.len())?; + if self + .policy + .key_transport_algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(¶meters.algorithm)) + || self.policy.oaep_digests.as_ref().is_some_and(|allowed| { + !allowed.contains(¶meters.digest) + || !allowed.contains(¶meters.mgf_digest) + }) + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "encryption", + algorithm: parameters.algorithm.uri().to_string(), + } + .into()); + } + self.validate_metadata("EncryptedKey Recipient", recipient.as_deref())?; + self.validate_key_name("EncryptedKey KeyName", key_name.as_deref())?; + self.validate_metadata_len("OAEPparams", parameters.label.len())?; } EncryptionRecipient::AesKeyWrap { + algorithm, recipient, key_name, .. } => { - validate_metadata("EncryptedKey Recipient", recipient.as_deref())?; - validate_key_name("EncryptedKey KeyName", key_name.as_deref())?; + if self + .policy + .key_wrap_algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(algorithm)) + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "encryption", + algorithm: algorithm.uri().to_string(), + } + .into()); + } + self.validate_metadata("EncryptedKey Recipient", recipient.as_deref())?; + self.validate_key_name("EncryptedKey KeyName", key_name.as_deref())?; } } } @@ -257,33 +309,85 @@ impl EncryptedDataBuilder { _ => Ok(()), } } + + fn validate_metadata( + &self, + field: &'static str, + value: Option<&str>, + ) -> Result<(), XmlEncError> { + validate_metadata( + field, + value, + self.policy.resources.max_encryption_metadata_bytes, + ) + } + + fn validate_key_name( + &self, + field: &'static str, + value: Option<&str>, + ) -> Result<(), XmlEncError> { + validate_key_name( + field, + value, + self.policy.resources.max_encryption_metadata_bytes, + ) + } + + fn validate_metadata_len(&self, field: &'static str, actual: usize) -> Result<(), XmlEncError> { + validate_metadata_len( + field, + actual, + self.policy.resources.max_encryption_metadata_bytes, + ) + } + + fn validate_plaintext_len(&self, actual: usize) -> Result<(), XmlEncError> { + validate_plaintext_len(actual, self.policy.resources.max_encryption_plaintext_bytes) + } + + fn validate_document_len(&self, actual: usize) -> Result<(), XmlEncError> { + validate_document_len(actual, self.policy.resources.max_encryption_document_bytes) + } } -fn validate_metadata(field: &'static str, value: Option<&str>) -> Result<(), XmlEncError> { +fn validate_metadata( + field: &'static str, + value: Option<&str>, + maximum: usize, +) -> Result<(), XmlEncError> { if value.is_some_and(|value| !value.chars().all(is_xml_1_0_character)) { return Err(XmlEncError::InvalidEncryptionConfig(format!( "{field} contains a character forbidden by XML 1.0" ))); } - validate_metadata_len(field, value.map_or(0, str::len)) + validate_metadata_len(field, value.map_or(0, str::len), maximum) } -fn validate_key_name(field: &'static str, value: Option<&str>) -> Result<(), XmlEncError> { +fn validate_key_name( + field: &'static str, + value: Option<&str>, + maximum: usize, +) -> Result<(), XmlEncError> { if value.is_some_and(str::is_empty) { return Err(XmlEncError::InvalidEncryptionConfig(format!( "{field} must not be empty" ))); } - validate_metadata(field, value) + validate_metadata(field, value, maximum) } -fn validate_metadata_len(field: &'static str, actual: usize) -> Result<(), XmlEncError> { - if actual <= MAX_ENCRYPTION_METADATA_LEN { +fn validate_metadata_len( + field: &'static str, + actual: usize, + maximum: usize, +) -> Result<(), XmlEncError> { + if actual <= maximum { Ok(()) } else { Err(XmlEncError::EncryptionMetadataTooLarge { field, - maximum: MAX_ENCRYPTION_METADATA_LEN, + maximum, actual, }) } @@ -306,23 +410,17 @@ struct ContentBoundaries { start_tag_end: usize, } -fn validate_plaintext_len(actual: usize) -> Result<(), XmlEncError> { - if actual <= MAX_ENCRYPTION_PLAINTEXT_LEN { +fn validate_plaintext_len(actual: usize, maximum: usize) -> Result<(), XmlEncError> { + if actual <= maximum { Ok(()) } else { - Err(XmlEncError::PlaintextTooLarge { - maximum: MAX_ENCRYPTION_PLAINTEXT_LEN, - actual, - }) + Err(XmlEncError::PlaintextTooLarge { maximum, actual }) } } -fn validate_document_len(actual: usize) -> Result<(), XmlEncError> { - if actual > MAX_ENCRYPTION_DOCUMENT_LEN { - return Err(XmlEncError::DocumentTooLarge { - maximum: MAX_ENCRYPTION_DOCUMENT_LEN, - actual, - }); +fn validate_document_len(actual: usize, maximum: usize) -> Result<(), XmlEncError> { + if actual > maximum { + return Err(XmlEncError::DocumentTooLarge { maximum, actual }); } Ok(()) } @@ -339,88 +437,26 @@ fn validate_content_key(algorithm: DataEncryptionAlgorithm, key: &[u8]) -> Resul } } -fn random_bytes(len: usize) -> Result, XmlEncError> { +fn random_bytes( + provider: &dyn crate::provider::CryptoProvider, + len: usize, +) -> Result, XmlEncError> { let mut bytes = vec![0_u8; len]; - SysRng - .try_fill_bytes(&mut bytes) - .map_err(|error| XmlEncError::Rng(error.to_string()))?; + provider.fill_random(&mut bytes)?; Ok(bytes) } fn encrypt_content( + provider: &dyn crate::provider::CryptoProvider, algorithm: DataEncryptionAlgorithm, key: &[u8], plaintext: &[u8], ) -> Result, XmlEncError> { - validate_content_key(algorithm, key)?; - match algorithm { - DataEncryptionAlgorithm::Aes128Cbc => encrypt_cbc::(key, plaintext), - DataEncryptionAlgorithm::Aes256Cbc => encrypt_cbc::(key, plaintext), - DataEncryptionAlgorithm::Aes128Gcm => encrypt_gcm::(key, plaintext), - DataEncryptionAlgorithm::Aes256Gcm => encrypt_gcm::(key, plaintext), - } -} - -fn encrypt_cbc(key: &[u8], plaintext: &[u8]) -> Result, XmlEncError> -where - C: aes::cipher::BlockCipherEncrypt + aes::cipher::KeyInit, -{ - const BLOCK: usize = 16; - let iv = random_bytes(BLOCK)?; - let pad_len = BLOCK - (plaintext.len() % BLOCK); - let mut padded = Vec::with_capacity(plaintext.len() + pad_len); - padded.extend_from_slice(plaintext); - if pad_len > 1 { - padded.extend_from_slice(&random_bytes(pad_len - 1)?); - } - padded.push(pad_len as u8); - let padded_len = padded.len(); - Encryptor::::new_from_slices(key, &iv) - .map_err(|_| XmlEncError::InvalidKeySize { - algorithm: if key.len() == 16 { - DataEncryptionAlgorithm::Aes128Cbc - } else { - DataEncryptionAlgorithm::Aes256Cbc - }, - expected: key.len(), - actual: key.len(), - })? - .encrypt_padded::(&mut padded, padded_len) - .map_err(|error| XmlEncError::XmlSerialize(error.to_string()))?; - let mut output = Vec::with_capacity(BLOCK + padded.len()); - output.extend_from_slice(&iv); - output.extend_from_slice(&padded); - Ok(output) -} - -fn encrypt_gcm(key: &[u8], plaintext: &[u8]) -> Result, XmlEncError> -where - C: AeadInOut + KeyInit, -{ - const NONCE_LEN: usize = 12; - let nonce = random_bytes(NONCE_LEN)?; - let cipher = C::new_from_slice(key).map_err(|_| XmlEncError::InvalidKeySize { - algorithm: if key.len() == 16 { - DataEncryptionAlgorithm::Aes128Gcm - } else { - DataEncryptionAlgorithm::Aes256Gcm - }, - expected: key.len(), - actual: key.len(), - })?; - let mut encrypted = plaintext.to_vec(); - let nonce_value = Nonce::try_from(nonce.as_slice()) - .map_err(|error| XmlEncError::XmlSerialize(error.to_string()))?; - cipher - .encrypt_in_place(&nonce_value, b"", &mut encrypted) - .map_err(|_| XmlEncError::AeadAuthenticationFailed)?; - let mut output = Vec::with_capacity(NONCE_LEN + encrypted.len()); - output.extend_from_slice(&nonce); - output.extend_from_slice(&encrypted); - Ok(output) + Ok(provider.encrypt_data(algorithm, key, plaintext)?) } fn wrap_content_key( + provider: &dyn crate::provider::CryptoProvider, recipient: &EncryptionRecipient, content_key: &[u8], ) -> Result { @@ -435,7 +471,7 @@ fn wrap_content_key( oaep: Some(parameters.clone()), recipient: recipient.clone(), key_name: key_name.clone(), - ciphertext: wrap_rsa_oaep(public_key, parameters, content_key)?, + ciphertext: wrap_rsa_oaep(provider, public_key, parameters, content_key)?, }), EncryptionRecipient::AesKeyWrap { kek, @@ -443,121 +479,33 @@ fn wrap_content_key( recipient, key_name, } => { - if kek.len() != algorithm.key_len() { - return Err(XmlEncError::InvalidKekSize { - algorithm: *algorithm, - expected: algorithm.key_len(), - actual: kek.len(), - }); - } - let mut output = vec![0_u8; content_key.len() + 8]; - let wrapped = match algorithm { - KeyWrapAlgorithm::AesKw128 => KwAes128::new_from_slice(kek) - .map_err(|_| invalid_kek_size(*algorithm, kek.len()))? - .wrap_key(content_key, &mut output), - KeyWrapAlgorithm::AesKw256 => KwAes256::new_from_slice(kek) - .map_err(|_| invalid_kek_size(*algorithm, kek.len()))? - .wrap_key(content_key, &mut output), - } - .map_err(|_| XmlEncError::KeyWrapIntegrity)?; + let wrapped = provider.wrap_key(*algorithm, kek, content_key)?; Ok(WrappedKey { algorithm_uri: algorithm.uri(), oaep: None, recipient: recipient.clone(), key_name: key_name.clone(), - ciphertext: wrapped.to_vec(), + ciphertext: wrapped, }) } } } fn wrap_rsa_oaep( + provider: &dyn crate::provider::CryptoProvider, public_key: &RsaPublicKey, parameters: &RsaOaepParameters, content_key: &[u8], ) -> Result, XmlEncError> { - if parameters.algorithm == super::KeyTransportAlgorithm::RsaOaepMgf1p - && parameters.mgf_digest != OaepDigestAlgorithm::Sha1 - { - return Err(XmlEncError::InvalidEncryptionConfig( - "legacy rsa-oaep-mgf1p requires MGF1-SHA1".into(), - )); - } - let mut rng = SysRng; - macro_rules! encrypt_with { - ($digest:ty, $mgf:ty) => { - // Call `PaddingScheme` directly: it accepts `TryCryptoRng`, so a - // `SysRng` failure returns `rsa::Error::Rng` for the mapping below - // instead of entering RSA's infallible `CryptoRng` convenience API. - Oaep::<$digest, $mgf>::new_with_mgf_hash_and_label(parameters.label.clone()).encrypt( - &mut rng, - public_key, - content_key, - ) - }; - } - let result = match (parameters.digest, parameters.mgf_digest) { - (OaepDigestAlgorithm::Sha1, OaepDigestAlgorithm::Sha1) => { - encrypt_with!(Sha1, Sha1) - } - (OaepDigestAlgorithm::Sha1, OaepDigestAlgorithm::Sha256) => { - encrypt_with!(Sha1, Sha256) - } - (OaepDigestAlgorithm::Sha1, OaepDigestAlgorithm::Sha384) => { - encrypt_with!(Sha1, Sha384) - } - (OaepDigestAlgorithm::Sha1, OaepDigestAlgorithm::Sha512) => { - encrypt_with!(Sha1, Sha512) - } - (OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha1) => { - encrypt_with!(Sha256, Sha1) - } - (OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha256) => { - encrypt_with!(Sha256, Sha256) - } - (OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha384) => { - encrypt_with!(Sha256, Sha384) - } - (OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha512) => { - encrypt_with!(Sha256, Sha512) - } - (OaepDigestAlgorithm::Sha384, OaepDigestAlgorithm::Sha1) => { - encrypt_with!(Sha384, Sha1) - } - (OaepDigestAlgorithm::Sha384, OaepDigestAlgorithm::Sha256) => { - encrypt_with!(Sha384, Sha256) - } - (OaepDigestAlgorithm::Sha384, OaepDigestAlgorithm::Sha384) => { - encrypt_with!(Sha384, Sha384) - } - (OaepDigestAlgorithm::Sha384, OaepDigestAlgorithm::Sha512) => { - encrypt_with!(Sha384, Sha512) - } - (OaepDigestAlgorithm::Sha512, OaepDigestAlgorithm::Sha1) => { - encrypt_with!(Sha512, Sha1) - } - (OaepDigestAlgorithm::Sha512, OaepDigestAlgorithm::Sha256) => { - encrypt_with!(Sha512, Sha256) - } - (OaepDigestAlgorithm::Sha512, OaepDigestAlgorithm::Sha384) => { - encrypt_with!(Sha512, Sha384) - } - (OaepDigestAlgorithm::Sha512, OaepDigestAlgorithm::Sha512) => { - encrypt_with!(Sha512, Sha512) - } - }; - result.map_err(|error| match error { - rsa::Error::Rng => XmlEncError::Rng("RSA-OAEP random generation failed".into()), - error => XmlEncError::RsaEncrypt(error.to_string()), - }) -} - -fn invalid_kek_size(algorithm: KeyWrapAlgorithm, actual: usize) -> XmlEncError { - XmlEncError::InvalidKekSize { - algorithm, - expected: algorithm.key_len(), - actual, - } + provider + .transport_key(public_key, parameters, content_key) + .map_err(|error| match error { + crate::provider::ProviderError::Random(message) => XmlEncError::Rng(message), + crate::provider::ProviderError::InvalidInput(reason) => { + XmlEncError::InvalidEncryptionConfig(reason.to_string()) + } + error => XmlEncError::RsaEncrypt(error.to_string()), + }) } fn render_encrypted_data( @@ -802,13 +750,20 @@ fn replace_range(xml: &str, range: std::ops::Range, replacement: &str) -> #[cfg(test)] mod tests { + use getrandom::SysRng; use getrandom::rand_core::UnwrapErr; use rsa::{RsaPrivateKey, RsaPublicKey}; use super::*; + use crate::hard_limits::{ + ENCRYPTION_DOCUMENT_BYTE_CEILING as MAX_ENCRYPTION_DOCUMENT_LEN, + ENCRYPTION_METADATA_BYTE_CEILING as MAX_ENCRYPTION_METADATA_LEN, + ENCRYPTION_PLAINTEXT_BYTE_CEILING as MAX_ENCRYPTION_PLAINTEXT_LEN, + ENCRYPTION_RECIPIENT_CEILING as MAX_ENCRYPTION_RECIPIENTS, + }; use crate::xmlenc::{ - KekDecryptor, PrivateKeyDecryptor, SymmetricKeyDecryptor, decrypt, decrypt_document, - parse_encrypted_data, + KekDecryptor, OaepDigestAlgorithm, PrivateKeyDecryptor, SymmetricKeyDecryptor, decrypt, + decrypt_document, parse_encrypted_data, }; #[test] @@ -942,9 +897,15 @@ mod tests { .expect_err("missing key source must fail"); assert!(matches!(no_key, XmlEncError::InvalidEncryptionConfig(_))); - assert!(validate_plaintext_len(MAX_ENCRYPTION_PLAINTEXT_LEN).is_ok()); + assert!( + validate_plaintext_len(MAX_ENCRYPTION_PLAINTEXT_LEN, MAX_ENCRYPTION_PLAINTEXT_LEN,) + .is_ok() + ); assert!(matches!( - validate_plaintext_len(MAX_ENCRYPTION_PLAINTEXT_LEN + 1), + validate_plaintext_len( + MAX_ENCRYPTION_PLAINTEXT_LEN + 1, + MAX_ENCRYPTION_PLAINTEXT_LEN, + ), Err(XmlEncError::PlaintextTooLarge { .. }) )); @@ -1009,6 +970,60 @@ mod tests { )); } + #[test] + fn document_dtd_requires_policy_and_per_call_opt_in() { + // Internal DTD parsing is a two-party decision: operation policy sets + // the ceiling and the call site must opt in for this document. + let document = "]>"; + let mut policy = crate::policy::EncryptionPolicy::default(); + policy.xml.allow_internal_dtd = true; + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key([0_u8; 16]) + .policy(policy.clone()) + .encrypt_document( + document, + DocumentEncryptionOptions { + element_id: None, + allow_dtd: true, + }, + ) + .expect("both DTD controls should permit parsing"); + assert!(matches!( + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key([0_u8; 16]) + .policy(policy) + .encrypt_document(document, DocumentEncryptionOptions::default()), + Err(XmlEncError::XmlParse(_)) + )); + } + + #[test] + fn invalid_resource_policy_is_rejected_at_every_entry_point() { + // Entry points must reject an invalid snapshot before parsing or using + // any caller-selected limit derived from it. + let mut policy = crate::policy::EncryptionPolicy::default(); + policy.resources.max_encryption_plaintext_bytes = + crate::hard_limits::ENCRYPTION_PLAINTEXT_BYTE_CEILING + 1; + let builder = || { + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key([0_u8; 16]) + .policy(policy.clone()) + }; + + assert!(matches!( + builder().encrypt_xml(""), + Err(XmlEncError::Policy(_)) + )); + assert!(matches!( + builder().encrypt_binary(b"x"), + Err(XmlEncError::Policy(_)) + )); + assert!(matches!( + builder().encrypt_document("", DocumentEncryptionOptions::default()), + Err(XmlEncError::Policy(_)) + )); + } + #[test] fn element_plaintext_enforces_replacement_node_contract() { // Element ciphertext must be safe for the reciprocal document replacement: diff --git a/src/xmlenc/mod.rs b/src/xmlenc/mod.rs index 09842413..b59a5ea5 100644 --- a/src/xmlenc/mod.rs +++ b/src/xmlenc/mod.rs @@ -20,8 +20,9 @@ mod parse; mod types; pub use decrypt::{ - DecryptionKeyResolver, DocumentDecryptionOptions, KekDecryptor, PrivateKeyDecryptor, - SymmetricKeyDecryptor, decrypt, decrypt_data, decrypt_document, decrypt_document_with_options, + DecryptContext, DecryptionKeyResolver, DocumentDecryptionOptions, KekDecryptor, + PrivateKeyDecryptor, SymmetricKeyDecryptor, decrypt, decrypt_data, decrypt_document, + decrypt_document_with_options, }; pub use encrypt::EncryptedDataBuilder; pub use parse::parse_encrypted_data; diff --git a/src/xmlenc/types.rs b/src/xmlenc/types.rs index ca10da33..30a6bfd6 100644 --- a/src/xmlenc/types.rs +++ b/src/xmlenc/types.rs @@ -12,22 +12,8 @@ pub const XMLENC11_NS: &str = "http://www.w3.org/2009/xmlenc11#"; pub const XMLDSIG_NS: &str = "http://www.w3.org/2000/09/xmldsig#"; /// Maximum normalized base64 text accepted from a `CipherValue`. -pub const MAX_CIPHER_VALUE_BASE64_LEN: usize = 16 * 1024 * 1024; -/// Maximum plaintext accepted by the encryption API. -/// -/// The limit leaves room for CBC/GCM framing while guaranteeing that the -/// resulting base64 `CipherValue` fits the parser's input bound. -pub const MAX_ENCRYPTION_PLAINTEXT_LEN: usize = (MAX_CIPHER_VALUE_BASE64_LEN / 4 * 3) - 32; -/// Maximum caller-owned XML document size accepted for node encryption. -/// -/// This separately bounds parser work while leaving room around a maximum-size -/// selected plaintext element or content fragment. -pub const MAX_ENCRYPTION_DOCUMENT_LEN: usize = MAX_CIPHER_VALUE_BASE64_LEN; -/// Maximum number of independently wrapped copies of one content key. -pub const MAX_ENCRYPTION_RECIPIENTS: usize = 64; -/// Maximum byte length of one caller-controlled XML metadata value. -pub const MAX_ENCRYPTION_METADATA_LEN: usize = 4 * 1024; - +pub const MAX_CIPHER_VALUE_BASE64_LEN: usize = + crate::hard_limits::ENCRYPTION_CIPHER_VALUE_BASE64_BYTE_CEILING; /// The `Type` attribute on an `EncryptedData` element. #[derive(Debug, Clone, PartialEq, Eq)] pub enum EncryptedDataType { @@ -40,7 +26,7 @@ pub enum EncryptedDataType { } /// Supported content-encryption algorithms. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DataEncryptionAlgorithm { /// AES-128 in CBC mode with XMLEnc padding. Aes128Cbc, @@ -130,7 +116,7 @@ impl KeyWrapAlgorithm { } /// Supported asymmetric session-key transport algorithms. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum KeyTransportAlgorithm { /// XML Encryption 1.0 OAEP with SHA-1 and MGF1-SHA-1. RsaOaepMgf1p, @@ -139,7 +125,7 @@ pub enum KeyTransportAlgorithm { } /// Supported symmetric key-wrap algorithms. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum KeyWrapAlgorithm { /// RFC 3394 AES key wrap with a 128-bit KEK. AesKw128, @@ -148,7 +134,7 @@ pub enum KeyWrapAlgorithm { } /// Digest algorithms accepted by RSA-OAEP encryption. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum OaepDigestAlgorithm { /// SHA-1, retained for legacy XMLEnc OAEP interoperability. Sha1, @@ -368,7 +354,10 @@ pub struct EncryptionResult { pub struct DocumentEncryptionOptions<'a> { /// Select an element by `Id`, `ID`, or `id`; `None` selects the document root. pub element_id: Option<&'a str>, - /// Permit an internal DTD subset while parsing the caller's document. + /// Request internal-DTD parsing for this call. + /// + /// The operation policy must also permit internal DTDs; either control can + /// deny parsing, so a permissive caller option cannot weaken policy. pub allow_dtd: bool, } @@ -452,6 +441,14 @@ pub enum DecryptedContent { #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum XmlEncError { + /// The compiled encryption or decryption policy rejected an operation input. + #[error("XML Encryption policy violation: {0}")] + Policy(#[from] crate::policy::PolicyViolation), + + /// The selected cryptographic provider rejected or failed an operation. + #[error("cryptographic provider error: {0}")] + Provider(#[from] crate::provider::ProviderError), + /// XML document parsing failed. #[error("XML parsing error: {0}")] XmlParse(#[from] roxmltree::Error), diff --git a/tests/common/xmlsec1.rs b/tests/common/xmlsec1.rs new file mode 100644 index 00000000..24250897 --- /dev/null +++ b/tests/common/xmlsec1.rs @@ -0,0 +1,45 @@ +use std::ffi::OsString; +use std::process::Command; + +pub const REQUIRED_VERSION: (u16, u16, u16) = (1, 3, 13); + +pub fn command() -> Command { + let binary = std::env::var_os("XMLSEC1_BIN").unwrap_or_else(|| OsString::from("xmlsec1")); + Command::new(binary) +} + +pub fn version_supports_interop(version: &str) -> bool { + let mut tokens = version.split_whitespace(); + if tokens.next() != Some("xmlsec1") { + return false; + } + let Some(version) = tokens.next() else { + return false; + }; + let mut components = version.split('.'); + let parsed = ( + components + .next() + .and_then(|value| value.parse::().ok()), + components + .next() + .and_then(|value| value.parse::().ok()), + components + .next() + .and_then(|value| value.parse::().ok()), + ); + match parsed { + (Some(major), Some(minor), Some(patch)) if components.next().is_none() => { + (major, minor, patch) >= REQUIRED_VERSION + } + _ => false, + } +} + +pub fn is_available() -> bool { + let Ok(output) = command().arg("--version").output() else { + return false; + }; + output.status.success() + && std::str::from_utf8(&output.stdout).is_ok_and(version_supports_interop) +} diff --git a/tests/donor_full_verification_suite.rs b/tests/donor_full_verification_suite.rs index 75abf85d..bff03568 100644 --- a/tests/donor_full_verification_suite.rs +++ b/tests/donor_full_verification_suite.rs @@ -1,42 +1,30 @@ -//! Donor full verification suite for ROADMAP task P1-025. -//! -//! This suite tracks pass/fail/skip accounting across donor vectors and -//! enforces that all supported donor vectors verify end-to-end. +//! End-to-end verification for the supported Aleksey donor vectors. use std::{ path::{Path, PathBuf}, time::{Duration, SystemTime}, }; +use xml_sec::policy::{KeyTrustPolicy, VerificationPolicy}; use xml_sec::xmldsig::{ - DefaultKeyResolver, DsigError, DsigStatus, KeyResolverConfig, ParseError, SignatureAlgorithm, - VerificationKey, VerifyContext, + DefaultKeyResolver, DsigStatus, KeyResolverConfig, SignatureAlgorithm, VerificationKey, + VerifyContext, }; -#[derive(Clone, Copy)] -enum SkipProbe { - WeakRsaKey, - UnsupportedSignatureAlgorithm, -} - #[derive(Clone, Copy)] enum Expectation { - ValidEmbedded, - ValidNamed { + Embedded, + Named { key_name: &'static str, key_path: &'static str, algorithm: SignatureAlgorithm, }, - ValidSelected { + Selected { certificate_paths: &'static [&'static str], }, - ValidChain { + Chain { trust_anchor_path: &'static str, }, - Skip { - reason: &'static str, - probe: SkipProbe, - }, } struct VectorCase { @@ -69,7 +57,7 @@ fn cases() -> Vec { VectorCase { name: "aleksey-rsa-sha1", xml_path: "tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloped-sha1-rsa-sha1.xml", - expectation: Expectation::ValidNamed { + expectation: Expectation::Named { key_name: "TestKeyName-rsa-4096", key_path: "tests/fixtures/keys/rsa/rsa-4096-pubkey.pem", algorithm: SignatureAlgorithm::RsaSha1, @@ -78,22 +66,22 @@ fn cases() -> Vec { VectorCase { name: "aleksey-rsa-sha256", xml_path: "tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.xml", - expectation: Expectation::ValidEmbedded, + expectation: Expectation::Embedded, }, VectorCase { name: "aleksey-rsa-sha384", xml_path: "tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha384-rsa-sha384.xml", - expectation: Expectation::ValidEmbedded, + expectation: Expectation::Embedded, }, VectorCase { name: "aleksey-rsa-sha512", xml_path: "tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha512-rsa-sha512.xml", - expectation: Expectation::ValidEmbedded, + expectation: Expectation::Embedded, }, VectorCase { name: "aleksey-ecdsa-p256-sha256", xml_path: "tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloped-sha256-ecdsa-sha256.xml", - expectation: Expectation::ValidNamed { + expectation: Expectation::Named { key_name: "TestKeyName-ec-prime256v1", key_path: "tests/fixtures/keys/ec/ec-prime256v1-pubkey.pem", algorithm: SignatureAlgorithm::EcdsaP256Sha256, @@ -102,7 +90,7 @@ fn cases() -> Vec { VectorCase { name: "aleksey-ecdsa-p521-sha384", xml_path: "tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloped-sha384-ecdsa-sha384.xml", - expectation: Expectation::ValidNamed { + expectation: Expectation::Named { key_name: "TestKeyName-ec-prime521v1", key_path: "tests/fixtures/keys/ec/ec-prime521v1-pubkey.pem", algorithm: SignatureAlgorithm::EcdsaP384Sha384, @@ -111,7 +99,7 @@ fn cases() -> Vec { VectorCase { name: "aleksey-rsa-sha512-x509-digest", xml_path: "tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloped-x509-digest-sha512.xml", - expectation: Expectation::ValidSelected { + expectation: Expectation::Selected { certificate_paths: &[ "tests/fixtures/keys/rsa/rsa-4096-cert.pem", "tests/fixtures/keys/ca2cert.pem", @@ -122,109 +110,34 @@ fn cases() -> Vec { VectorCase { name: "aleksey-rsa-sha1-x509-chain-tofu", xml_path: "tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-rsa-x509chain.xml", - expectation: Expectation::ValidEmbedded, + expectation: Expectation::Embedded, }, VectorCase { name: "aleksey-rsa-sha1-x509-chain-anchored", xml_path: "tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-rsa-x509chain.xml", - expectation: Expectation::ValidChain { + expectation: Expectation::Chain { trust_anchor_path: "tests/fixtures/keys/cacert.pem", }, }, - // Merlin "basic signatures" required by P1-025. - // These are tracked explicitly as skips until P2/P4 capabilities exist. - VectorCase { - name: "merlin-enveloped-dsa", - xml_path: "tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloped-dsa.xml", - expectation: Expectation::Skip { - reason: "DSA signature method is not implemented yet (planned P4-009)", - probe: SkipProbe::UnsupportedSignatureAlgorithm, - }, - }, - VectorCase { - name: "merlin-enveloping-rsa-keyvalue", - xml_path: "tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-rsa.xml", - expectation: Expectation::Skip { - reason: "RSAKeyValue resolves but its legacy 1024-bit modulus is below policy", - probe: SkipProbe::WeakRsaKey, - }, - }, - VectorCase { - name: "merlin-x509-crt", - xml_path: "tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt.xml", - expectation: Expectation::Skip { - reason: "DSA signature method is not implemented yet (planned P4-009); X509 KeyInfo resolution is not implemented yet (planned P2-009)", - probe: SkipProbe::UnsupportedSignatureAlgorithm, - }, - }, - VectorCase { - name: "merlin-x509-crt-crl", - xml_path: "tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt-crl.xml", - expectation: Expectation::Skip { - reason: "DSA signature method is not implemented yet (planned P4-009); X509/CRL KeyInfo resolution is not implemented yet (planned P2-009/P2-005)", - probe: SkipProbe::UnsupportedSignatureAlgorithm, - }, - }, - VectorCase { - name: "merlin-x509-is", - xml_path: "tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-is.xml", - expectation: Expectation::Skip { - reason: "DSA signature method is not implemented yet (planned P4-009); X509IssuerSerial resolution is not implemented yet (planned P2-009)", - probe: SkipProbe::UnsupportedSignatureAlgorithm, - }, - }, - VectorCase { - name: "merlin-x509-ski", - xml_path: "tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-ski.xml", - expectation: Expectation::Skip { - reason: "DSA signature method is not implemented yet (planned P4-009); X509SKI resolution is not implemented yet (planned P2-009)", - probe: SkipProbe::UnsupportedSignatureAlgorithm, - }, - }, - VectorCase { - name: "merlin-x509-sn", - xml_path: "tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-sn.xml", - expectation: Expectation::Skip { - reason: "DSA signature method is not implemented yet (planned P4-009); X509SubjectName resolution is not implemented yet (planned P2-009)", - probe: SkipProbe::UnsupportedSignatureAlgorithm, - }, - }, ] } #[test] -fn donor_full_verification_suite_tracks_pass_fail_skip_counts() { +fn donor_full_verification_suite_accepts_every_supported_case() { let root = project_root(); let mut passed = 0usize; let mut failed = Vec::::new(); - let mut skipped = Vec::::new(); + let mut compatibility_policy = VerificationPolicy::default(); + compatibility_policy.key_trust.allow_legacy_rsa_sha1 = true; for case in cases() { - match case.expectation { - Expectation::ValidEmbedded => { - let xml = read_fixture(&root.join(case.xml_path)); - let resolver = DefaultKeyResolver::default(); - match VerifyContext::new().key_resolver(&resolver).verify(&xml) { - Ok(result) if matches!(result.status, DsigStatus::Valid) => { - passed += 1; - } - Ok(result) => { - failed.push(format!( - "{}: expected Valid, got {:?}", - case.name, result.status - )); - } - Err(err) => { - failed.push(format!("{}: verification error {err}", case.name)); - } - } - } - Expectation::ValidNamed { + let resolver = match case.expectation { + Expectation::Embedded => DefaultKeyResolver::default(), + Expectation::Named { key_name, key_path, algorithm, } => { - let xml = read_fixture(&root.join(case.xml_path)); let mut config = KeyResolverConfig::default(); config.named_keys.insert( key_name.into(), @@ -235,98 +148,44 @@ fn donor_full_verification_suite_tracks_pass_fail_skip_counts() { name: Some(key_name.into()), }, ); - let resolver = DefaultKeyResolver::new(config); - match VerifyContext::new().key_resolver(&resolver).verify(&xml) { - Ok(result) if matches!(result.status, DsigStatus::Valid) => passed += 1, - Ok(result) => failed.push(format!( - "{}: expected Valid, got {:?}", - case.name, result.status - )), - Err(err) => { - failed.push(format!("{}: verification error {err}", case.name)); - } - } + DefaultKeyResolver::new(config) } - Expectation::ValidSelected { certificate_paths } => { - let xml = read_fixture(&root.join(case.xml_path)); - let resolver = DefaultKeyResolver::new(KeyResolverConfig { - trusted_certs: certificate_paths + Expectation::Selected { certificate_paths } => { + DefaultKeyResolver::new(KeyResolverConfig { + lookup_certs: certificate_paths .iter() .map(|path| read_pem_der(&root.join(path), "CERTIFICATE")) .collect(), ..KeyResolverConfig::default() - }); - match VerifyContext::new().key_resolver(&resolver).verify(&xml) { - Ok(result) if matches!(result.status, DsigStatus::Valid) => passed += 1, - Ok(result) => failed.push(format!( - "{}: expected Valid, got {:?}", - case.name, result.status - )), - Err(err) => { - failed.push(format!("{}: verification error {err}", case.name)); - } - } + }) } - Expectation::ValidChain { trust_anchor_path } => { - let xml = read_fixture(&root.join(case.xml_path)); - let resolver = DefaultKeyResolver::new(KeyResolverConfig { + Expectation::Chain { trust_anchor_path } => { + DefaultKeyResolver::new(KeyResolverConfig { trusted_certs: vec![read_pem_der(&root.join(trust_anchor_path), "CERTIFICATE")], - verify_chains: true, - // 2027-01-15 UTC, inside the donor chain's 2026-2126 validity window. - verification_time: Some( - SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000), - ), - ..KeyResolverConfig::default() - }); - match VerifyContext::new().key_resolver(&resolver).verify(&xml) { - Ok(result) if matches!(result.status, DsigStatus::Valid) => passed += 1, - Ok(result) => failed.push(format!( - "{}: expected Valid, got {:?}", - case.name, result.status - )), - Err(err) => { - failed.push(format!("{}: verification error {err}", case.name)); - } - } - } - Expectation::Skip { reason, probe } => { - let xml = read_fixture(&root.join(case.xml_path)); - roxmltree::Document::parse(&xml) - .unwrap_or_else(|err| panic!("{}: fixture XML must parse: {err}", case.name)); - match probe { - SkipProbe::WeakRsaKey => match VerifyContext::new() - .key_resolver(&DefaultKeyResolver::default()) - .verify(&xml) - { - Err(DsigError::Crypto( - xml_sec::xmldsig::SignatureVerificationError::InvalidKeyDer, - )) => {} - Ok(result) => failed.push(format!( - "{}: expected weak RSA key error for skipped vector, got {:?}", - case.name, result.status - )), - Err(err) => failed.push(format!( - "{}: expected weak RSA key error for skipped vector, got {err}", - case.name - )), + trust: KeyTrustPolicy { + verify_x509_chains: true, + // 2027-01-15 UTC, inside the donor chain's 2026-2126 validity window. + verification_time: Some( + SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000), + ), + ..KeyTrustPolicy::default() }, - SkipProbe::UnsupportedSignatureAlgorithm => match VerifyContext::new().verify(&xml) - { - Err(DsigError::ParseSignedInfo(ParseError::UnsupportedAlgorithm { - .. - })) => {} - Ok(result) => failed.push(format!( - "{}: expected unsupported signature algorithm error for skipped vector, got {:?}", - case.name, result.status - )), - Err(err) => failed.push(format!( - "{}: expected unsupported signature algorithm error for skipped vector, got {err}", - case.name - )), - }, - } - skipped.push(format!("{}: {}", case.name, reason)); + ..KeyResolverConfig::default() + }) } + }; + let xml = read_fixture(&root.join(case.xml_path)); + match VerifyContext::new() + .policy(compatibility_policy.clone()) + .key_resolver(&resolver) + .verify(&xml) + { + Ok(result) if matches!(result.status, DsigStatus::Valid) => passed += 1, + Ok(result) => failed.push(format!( + "{}: expected Valid, got {:?}", + case.name, result.status + )), + Err(err) => failed.push(format!("{}: verification error {err}", case.name)), } } @@ -337,19 +196,5 @@ fn donor_full_verification_suite_tracks_pass_fail_skip_counts() { failed.join("\n") ); - let expected_skipped = vec![ - "merlin-enveloped-dsa: DSA signature method is not implemented yet (planned P4-009)", - "merlin-enveloping-rsa-keyvalue: RSAKeyValue resolves but its legacy 1024-bit modulus is below policy", - "merlin-x509-crt: DSA signature method is not implemented yet (planned P4-009); X509 KeyInfo resolution is not implemented yet (planned P2-009)", - "merlin-x509-crt-crl: DSA signature method is not implemented yet (planned P4-009); X509/CRL KeyInfo resolution is not implemented yet (planned P2-009/P2-005)", - "merlin-x509-is: DSA signature method is not implemented yet (planned P4-009); X509IssuerSerial resolution is not implemented yet (planned P2-009)", - "merlin-x509-ski: DSA signature method is not implemented yet (planned P4-009); X509SKI resolution is not implemented yet (planned P2-009)", - "merlin-x509-sn: DSA signature method is not implemented yet (planned P4-009); X509SubjectName resolution is not implemented yet (planned P2-009)", - ]; - - // P1-025 minimum expected accounting: - // - all supported aleksey RSA/ECDSA vectors pass - // - unsupported/deferred merlin vectors are tracked as skips with explicit reasons assert_eq!(passed, 9, "unexpected pass count"); - assert_eq!(skipped, expected_skipped, "unexpected skip inventory"); } diff --git a/tests/donor_negative_vectors.rs b/tests/donor_negative_vectors.rs index 26463fc6..a8cb17bb 100644 --- a/tests/donor_negative_vectors.rs +++ b/tests/donor_negative_vectors.rs @@ -12,10 +12,11 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD; use roxmltree::Document; use x509_parser::prelude::{FromDer, X509Certificate}; +use xml_sec::policy::PolicyViolation; use xml_sec::xmldsig::{ DsigError, DsigStatus, FailureReason, KeyInfoSource, ParseError, SignatureAlgorithm, - SignatureVerificationError, VerificationKey, VerifyContext, X509ChainError, X509ChainOptions, - X509DataInfo, parse_key_info, verify_signature_with_pem_key, verify_x509_certificate_chain, + VerificationKey, VerifyContext, X509ChainError, X509ChainOptions, X509DataInfo, parse_key_info, + verify_signature_with_pem_key, verify_x509_certificate_chain, }; const PHAOS_DIR: &str = "tests/fixtures/xmldsig/phaos-xmldsig-three"; @@ -73,7 +74,13 @@ fn phaos_bad_digest_reports_reference_mismatch_before_key_use() { // is advisory. The unrelated strong key is never used because digest // validation fails first, proving the exact fail-fast boundary. let xml = read_vector("signature-rsa-enveloped-bad-digest-val.xml"); - let result = verify_signature_with_pem_key(&xml, STRONG_RSA_PUBLIC_KEY, false) + let mut policy = xml_sec::policy::VerificationPolicy::default(); + policy.key_trust.allow_legacy_rsa_sha1 = true; + let key = phaos_verification_key(); + let result = VerifyContext::new() + .policy(policy) + .key(&key) + .verify(&xml) .expect("bad DigestValue must be a completed invalid verification"); assert_eq!( @@ -102,8 +109,8 @@ fn phaos_bad_signature_artifact_fails_on_its_unsupported_md5_reference() { #[test] fn phaos_valid_baseline_rejects_legacy_rsa_key_policy() { - // References in the historical positive vector are valid, but its - // 1024-bit RSA key is below the crate's 2048-bit verification minimum. + // References in the historical positive vector are valid, but RSA-SHA1 is + // rejected by the default verification policy before backend key handling. let xml = read_vector("signature-rsa-enveloped.xml"); let key = phaos_verification_key(); let error = VerifyContext::new() @@ -113,7 +120,10 @@ fn phaos_valid_baseline_rejects_legacy_rsa_key_policy() { assert!(matches!( error, - DsigError::Crypto(SignatureVerificationError::InvalidKeyDer) + DsigError::Policy(PolicyViolation::Algorithm { + operation: "verification", + .. + }) )); } diff --git a/tests/fixtures/xmldsig/README.md b/tests/fixtures/xmldsig/README.md index 0bdeeed8..d54304b0 100644 --- a/tests/fixtures/xmldsig/README.md +++ b/tests/fixtures/xmldsig/README.md @@ -2,6 +2,9 @@ This directory contains the XMLDSig test documents used by integration tests. They are checked into the repository so CI never depends on a local donor clone. +The current compatibility oracle is the xmlsec1 1.3.13 development snapshot at +commit `5fdd47dc35753438bdc38b6e96c1a3805c67a483`; upstream had bumped the +version but had not published a release tag when this snapshot was pinned. ## Importing Vectors @@ -23,13 +26,14 @@ fixture provenance and CI coverage difficult to audit. Core xmlsec1-generated XMLDSig vectors used by the signing and verification pipeline tests. They cover RSA SHA-1/SHA-256/SHA-384/SHA-512, ECDSA P-256 and -P-384, X.509 KeyInfo, and template signing. +P-384, SHA-256/SHA-512 X.509 digest selectors, X.509 KeyInfo, and template +signing. ### `merlin-xmldsig-twenty-three` -W3C/Merlin basic signature vectors. Some files intentionally remain outside -the supported algorithm set, such as DSA, and are accounted for as skips or -fail-closed cases by the donor verification suite. +W3C/Merlin basic signature vectors. DSA-SHA1 and HMAC-SHA1 are supported for +legacy verification, including XMLDSig's permitted HMAC truncation. Unsupported +DSA and HMAC variants remain fail-closed. ### `xmldsig11-interop-2012` @@ -46,7 +50,7 @@ Currently verified as valid: Currently fail-closed: -- HMAC algorithms. +- HMAC algorithms other than HMAC-SHA1. - SHA-224 digest or signature algorithms. - P-521 KeyValue resolution. - `KeyInfoReference` dereference. @@ -57,8 +61,9 @@ Currently fail-closed: XMLDSig Second Edition errata vectors. They exercise HMAC-SHA1, external URI references, XPath transforms, and Canonical XML 1.1. XPath and C14N 1.1 are -implemented; documents that additionally require HMAC, an external resource, -or an unsupported key source remain explicitly classified as fail-closed. +implemented; HMAC-SHA1 is supported for verification, while documents that +require another HMAC variant, an unavailable external resource, or an +unsupported key source remain explicitly classified as fail-closed. ### `merlin-xpath-filter2` diff --git a/tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloped-x509-digest-sha256.xml b/tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloped-x509-digest-sha256.xml new file mode 100644 index 00000000..5436ff54 --- /dev/null +++ b/tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloped-x509-digest-sha256.xml @@ -0,0 +1,46 @@ + + + + Hello, World! + + + + + + + + + + not(ancestor-or-self::dsig:Signature) + + + + SsyGDfQDqAg9cuEzSIJDsrp8cSWGzoRqH8E3atXJ4Dw= + + + IlzeEjrSo0bjBM6Cqma9zl63bd0yZHUyZqxJh/29SZ83W35pwCKJFFs3CqIvgK6K +WLKXcL0tW5INPFovZL6wvLNk9wpOgayqkRUppZReAvkq5BxIWloXPl+ymK4sdHec +yAZ9RKgVbFLDZv2emLH03atwTCbAejSwzzgCAiJVZhXDLJFwoBPFjZGhbTcCE/h+ +xW1BLA7DpB94Q8bOmIxeM1SNyTZdGtN0tqIzUnOAC2+eT3nogOZN5bOqPOW065bB +IgvGSmoXqMicYynqO7oPGl+ehqxGwD+R7ipiETaQEvRbt+cRLklGbhApAw0Uyp1M +BzU/OSuOSgqibjGT5QA8e80t+pONoRUxfkp+vYL+kn66qk84dZndZ2HTfEKDnoAE +Txoq4c46Of/Dk8zywRnnzg6nUeNQg5NYXqZIQO4ysI+K+CrRhqPD5PGxfin/VRNg +pOQpNHMdS+Zk47CpWFuL92Plp4yDB58nufbZEY7KnjQ7TV9ArNWZj0dkBQekOJc7 +34aFsqyuyEPsRB03ZiBpT51W/dRxSkSu7/k6qJdi39qBt0m4NVU1sFMGpUw3Fdd2 +TDGf+U3yTUyqky9mIMeWjpirstKeKf6723BF8Kvj3/GPOwJ2NmuYD7UtQyH9Awx8 +jnE3gnDSzNvqxi7sgDK4WLgHvQzCsvYAFsEHl0LeX7I= + + + + fZd23DD+/7HSo72ZyFMENaMmbxjDF2SfThmux0P6qTY= + + + f8KWWGMregazVv77Mw49A/Oicjd5+wKvabdY2YfCGJM= + + + YRUR3UCYtsvTFvFnU9UHFRrZo9imcTVPdMfw8BpVKQk= + + + + + diff --git a/tests/fixtures/xmldsig/external-data/xml-stylesheet-2005 b/tests/fixtures/xmldsig/external-data/xml-stylesheet-2005 new file mode 100644 index 00000000..de8e119b --- /dev/null +++ b/tests/fixtures/xmldsig/external-data/xml-stylesheet-2005 @@ -0,0 +1,341 @@ + + + +Associating Style Sheets with XML documents + + + + +
+W3C +

Associating Style Sheets with XML documents
Version 1.0

+

W3C Recommendation 29 June 1999

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

+Abstract +

+ +

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

+ +

+Status of this document +

+ +

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

+ +

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

+ +

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

+ +

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

+ +

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

+ +

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

+ +

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

+ + +

+Table of contents +

1 The xml-stylesheet processing instruction +
+

Appendices

A References +
B Rationale +
+
+ +

+1 The xml-stylesheet processing instruction

+ +

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

+ +

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

+ +

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

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

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

+ +

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

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

The following pseudo attributes are defined

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

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

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

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

+ +

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

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

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

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

would be equivalent to:

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

+A References

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

+B Rationale

+ +

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

+ +

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

+ +

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

+ + + + + + diff --git a/tests/fixtures/xmldsig/external-data/xml-stylesheet-2005.b64 b/tests/fixtures/xmldsig/external-data/xml-stylesheet-2005.b64 new file mode 100644 index 00000000..eb9a11ab --- /dev/null +++ b/tests/fixtures/xmldsig/external-data/xml-stylesheet-2005.b64 @@ -0,0 +1,274 @@ +PCFET0NUWVBFIGh0bWwgUFVCTElDICItLy9XM0MvL0RURCBIVE1MIDQuMCBUcmFu +c2l0aW9uYWwvL0VOIj4KPGh0bWw+CjxoZWFkPgo8dGl0bGU+QXNzb2NpYXRpbmcg +U3R5bGUgU2hlZXRzIHdpdGggWE1MIGRvY3VtZW50czwvdGl0bGU+CjxsaW5rIHJl +bD0ic3R5bGVzaGVldCIgdHlwZT0idGV4dC9jc3MiIGhyZWY9Imh0dHA6Ly93d3cu +dzMub3JnL1N0eWxlU2hlZXRzL1RSL1czQy1SRUMiPgo8c3R5bGUgdHlwZT0idGV4 +dC9jc3MiPmNvZGUgeyBmb250LWZhbWlseTogbW9ub3NwYWNlIH08L3N0eWxlPgo8 +L2hlYWQ+Cjxib2R5Pgo8ZGl2IGNsYXNzPSJoZWFkIj4KPGEgaHJlZj0iaHR0cDov +L3d3dy53My5vcmcvIj48aW1nIHNyYz0iaHR0cDovL3d3dy53My5vcmcvSWNvbnMv +V1dXL3czY19ob21lIiBhbHQ9IlczQyIgaGVpZ2h0PSI0OCIgd2lkdGg9IjcyIj48 +L2E+CjxoMT5Bc3NvY2lhdGluZyBTdHlsZSBTaGVldHMgd2l0aCBYTUwgZG9jdW1l +bnRzPGJyPlZlcnNpb24gMS4wPC9oMT4KPGgyPlczQyBSZWNvbW1lbmRhdGlvbiAy +OSBKdW5lIDE5OTk8L2gyPgo8ZGw+CjxkdD5UaGlzIHZlcnNpb246PC9kdD4KPGRk +Pgo8YSBocmVmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzA2L1JFQy14bWwtc3R5 +bGVzaGVldC0xOTk5MDYyOSI+aHR0cDovL3d3dy53My5vcmcvMTk5OS8wNi9SRUMt +eG1sLXN0eWxlc2hlZXQtMTk5OTA2Mjk8L2E+Cjxicj4KPC9kZD4KPGR0PkxhdGVz +dCB2ZXJzaW9uOjwvZHQ+CjxkZD4KPGEgaHJlZj0iaHR0cDovL3d3dy53My5vcmcv +VFIveG1sLXN0eWxlc2hlZXQiPmh0dHA6Ly93d3cudzMub3JnL1RSL3htbC1zdHls +ZXNoZWV0PC9hPgo8YnI+CjwvZGQ+CjxkdD5QcmV2aW91cyB2ZXJzaW9uOjwvZHQ+ +CjxkZD4KPGEgaHJlZj0iaHR0cDovL3d3dy53My5vcmcvVFIvMTk5OS94bWwtc3R5 +bGVzaGVldC0xOTk5MDQyOCI+aHR0cDovL3d3dy53My5vcmcvVFIvMTk5OS94bWwt +c3R5bGVzaGVldC0xOTk5MDQyODwvYT4KPGJyPgo8L2RkPgo8ZHQ+RWRpdG9yOjwv +ZHQ+CjxkZD4KCkphbWVzIENsYXJrCjxhIGhyZWY9Im1haWx0bzpqamNAamNsYXJr +LmNvbSI+Jmx0O2pqY0BqY2xhcmsuY29tJmd0OzwvYT4KPGJyPgo8L2RkPgo8L2Rs +Pgo8cCBjbGFzcz0iY29weXJpZ2h0Ij4KPGEgaHJlZj0iaHR0cDovL3d3dy53My5v +cmcvQ29uc29ydGl1bS9MZWdhbC9pcHItbm90aWNlLmh0bWwjQ29weXJpZ2h0Ij4K +CQlDb3B5cmlnaHQ8L2E+ICZuYnNwOyZjb3B5OyZuYnNwOyAxOTk5IDxhIGhyZWY9 +Imh0dHA6Ly93d3cudzMub3JnIj5XM0M8L2E+CgkJKDxhIGhyZWY9Imh0dHA6Ly93 +d3cubGNzLm1pdC5lZHUiPk1JVDwvYT4sCgkJPGEgaHJlZj0iaHR0cDovL3d3dy5p +bnJpYS5mci8iPklOUklBPC9hPiwKCQk8YSBocmVmPSJodHRwOi8vd3d3LmtlaW8u +YWMuanAvIj5LZWlvPC9hPiApLCBBbGwgUmlnaHRzIFJlc2VydmVkLiBXM0MKCQk8 +YSBocmVmPSJodHRwOi8vd3d3LnczLm9yZy9Db25zb3J0aXVtL0xlZ2FsL2lwci1u +b3RpY2UuaHRtbCNMZWdhbCBEaXNjbGFpbWVyIj5saWFiaWxpdHksPC9hPjxhIGhy +ZWY9Imh0dHA6Ly93d3cudzMub3JnL0NvbnNvcnRpdW0vTGVnYWwvaXByLW5vdGlj +ZS5odG1sI1czQyBUcmFkZW1hcmtzIj50cmFkZW1hcms8L2E+LAoJCTxhIGhyZWY9 +Imh0dHA6Ly93d3cudzMub3JnL0NvbnNvcnRpdW0vTGVnYWwvY29weXJpZ2h0LWRv +Y3VtZW50cy5odG1sIj5kb2N1bWVudCB1c2UgPC9hPmFuZAoJCTxhIGhyZWY9Imh0 +dHA6Ly93d3cudzMub3JnL0NvbnNvcnRpdW0vTGVnYWwvY29weXJpZ2h0LXNvZnR3 +YXJlLmh0bWwiPnNvZnR3YXJlIGxpY2Vuc2luZyA8L2E+cnVsZXMgYXBwbHkuCgk8 +L3A+CjxociB0aXRsZT0iU2VwYXJhdG9yIGZvciBoZWFkZXIiPgo8L2Rpdj4KPGgy +Pgo8YSBuYW1lPSJhYnN0cmFjdCI+QWJzdHJhY3Q8L2E+CjwvaDI+Cgo8cD5UaGlz +IGRvY3VtZW50IGFsbG93cyBhIHN0eWxlIHNoZWV0IHRvIGJlIGFzc29jaWF0ZWQg +d2l0aCBhbiBYTUwKZG9jdW1lbnQgYnkgaW5jbHVkaW5nIG9uZSBvciBtb3JlIHBy +b2Nlc3NpbmcgaW5zdHJ1Y3Rpb25zIHdpdGggYQp0YXJnZXQgb2YgPGNvZGU+eG1s +LXN0eWxlc2hlZXQ8L2NvZGU+IGluIHRoZSBkb2N1bWVudCdzIHByb2xvZy48L3A+ +Cgo8aDI+CjxhIG5hbWU9InN0YXR1cyI+U3RhdHVzIG9mIHRoaXMgZG9jdW1lbnQ8 +L2E+CjwvaDI+Cgo8cD5UaGlzIGRvY3VtZW50IGhhcyBiZWVuIHJldmlld2VkIGJ5 +IFczQyBNZW1iZXJzIGFuZCBvdGhlciBpbnRlcmVzdGVkCnBhcnRpZXMgYW5kIGhh +cyBiZWVuIGVuZG9yc2VkIGJ5IHRoZSBEaXJlY3RvciBhcyBhIFczQyA8YSBocmVm +PSJodHRwOi8vd3d3LnczLm9yZy9Db25zb3J0aXVtL1Byb2Nlc3MvI1JlY3NXM0Mi +PlJlY29tbWVuZGF0aW9uPC9hPi4gSXQKaXMgYSBzdGFibGUgZG9jdW1lbnQgYW5k +IG1heSBiZSB1c2VkIGFzIHJlZmVyZW5jZSBtYXRlcmlhbCBvciBjaXRlZCBhcwph +IG5vcm1hdGl2ZSByZWZlcmVuY2UgZnJvbSBvdGhlciBkb2N1bWVudHMuIFczQydz +IHJvbGUgaW4gbWFraW5nIHRoZQpSZWNvbW1lbmRhdGlvbiBpcyB0byBkcmF3IGF0 +dGVudGlvbiB0byB0aGUgc3BlY2lmaWNhdGlvbiBhbmQgdG8KcHJvbW90ZSBpdHMg +d2lkZXNwcmVhZCBkZXBsb3ltZW50LiBUaGlzIGVuaGFuY2VzIHRoZSBmdW5jdGlv +bmFsaXR5IGFuZAppbnRlcm9wZXJhYmlsaXR5IG9mIHRoZSBXZWIuPC9wPgoKPHA+ +VGhlIGxpc3Qgb2Yga25vd24gZXJyb3JzIGluIHRoaXMgc3BlY2lmaWNhdGlvbnMg +aXMgYXZhaWxhYmxlIGF0CjxhIGhyZWY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkv +MDYvUkVDLXhtbC1zdHlsZXNoZWV0LTE5OTkwNjI5L2VycmF0YSI+aHR0cDovL3d3 +dy53My5vcmcvVFIvMTk5OS94bWwtc3R5bGVzaGVldC0xOTk5MDYyOS9lcnJhdGE8 +L2E+LjwvcD4KCjxwPkNvbW1lbnRzIG9uIHRoaXMgc3BlY2lmaWNhdGlvbiBtYXkg +YmUgc2VudCB0byAmbHQ7PGEgaHJlZj0ibWFpbHRvOnd3dy14bWwtc3R5bGVzaGVl +dC1jb21tZW50c0B3My5vcmciPnd3dy14bWwtc3R5bGVzaGVldC1jb21tZW50c0B3 +My5vcmc8L2E+Jmd0Oy4gVGhlIGFyY2hpdmUgb2YgcHVibGljCmNvbW1lbnRzIGlz +IGF2YWlsYWJsZSBhdCA8YSBocmVmPSJodHRwOi8vd3d3LnczLm9yZy9BcmNoaXZl +cy9QdWJsaWMvd3d3LXhtbC1zdHlsZXNoZWV0LWNvbW1lbnRzIj5odHRwOi8vdzMu +b3JnL0FyY2hpdmVzL1B1YmxpYy93d3cteG1sLXN0eWxlc2hlZXQtY29tbWVudHM8 +L2E+LjwvcD4KCjxwPkEgbGlzdCBvZiBjdXJyZW50IFczQyBSZWNvbW1lbmRhdGlv +bnMgYW5kIG90aGVyIHRlY2huaWNhbCBkb2N1bWVudHMKY2FuIGJlIGZvdW5kIGF0 +IDxhIGhyZWY9Imh0dHA6Ly93d3cudzMub3JnL1RSIj5odHRwOi8vd3d3LnczLm9y +Zy9UUjwvYT4uPC9wPgoKPHA+VGhlIFdvcmtpbmcgR3JvdXAgZXhwZWN0cyBhZGRp +dGlvbmFsIG1lY2hhbmlzbXMgZm9yIGxpbmtpbmcgc3R5bGUKc2hlZXRzIHRvIFhN +TCBkb2N1bWVudCB0byBiZSBkZWZpbmVkIGluIGEgZnV0dXJlIHNwZWNpZmljYXRp +b24uPC9wPgoKPHA+VGhlIHVzZSBvZiBYTUwgcHJvY2Vzc2luZyBpbnN0cnVjdGlv +bnMgaW4gdGhpcyBzcGVjaWZpY2F0aW9uIHNob3VsZApub3QgYmUgdGFrZW4gYXMg +YSBwcmVjZWRlbnQuICBUaGUgVzNDIGRvZXMgbm90IGFudGljaXBhdGUgcmVjb21t +ZW5kaW5nCnRoZSB1c2Ugb2YgcHJvY2Vzc2luZyBpbnN0cnVjdGlvbnMgaW4gYW55 +IGZ1dHVyZSBzcGVjaWZpY2F0aW9uLiAgVGhlCjxhIGhyZWY9IiNyYXRpb25hbGUi +PlJhdGlvbmFsZTwvYT4gZXhwbGFpbnMgd2h5IHRoZXkgd2VyZSB1c2VkIGluCnRo +aXMgc3BlY2lmaWNhdGlvbi48L3A+Cgo8cD5UaGlzIGRvY3VtZW50IHdhcyBwcm9k +dWNlZCBhcyBwYXJ0IG9mIHRoZSA8YSBocmVmPSJodHRwOi8vd3d3LnczLm9yZy9Y +TUwvQWN0aXZpdHkiPlczQyBYTUwgQWN0aXZpdHk8L2E+LjwvcD4KCgo8aDI+Cjxh +IG5hbWU9ImNvbnRlbnRzIj5UYWJsZSBvZiBjb250ZW50czwvYT4KPC9oMj4xIDxh +IGhyZWY9IiNUaGUgeG1sLXN0eWxlc2hlZXQgcHJvY2Vzc2luZyBpbnN0cnVjdGlv +biI+VGhlIHhtbC1zdHlsZXNoZWV0IHByb2Nlc3NpbmcgaW5zdHJ1Y3Rpb248L2E+ +Cjxicj4KPGgzPkFwcGVuZGljZXM8L2gzPkEgPGEgaHJlZj0iI1JlZmVyZW5jZXMi +PlJlZmVyZW5jZXM8L2E+Cjxicj5CIDxhIGhyZWY9IiNyYXRpb25hbGUiPlJhdGlv +bmFsZTwvYT4KPGJyPgo8aHI+Cgo8aDI+CjxhIG5hbWU9IlRoZSB4bWwtc3R5bGVz +aGVldCBwcm9jZXNzaW5nIGluc3RydWN0aW9uIj48L2E+MSBUaGUgPGNvZGU+eG1s +LXN0eWxlc2hlZXQ8L2NvZGU+IHByb2Nlc3NpbmcgaW5zdHJ1Y3Rpb248L2gyPgoK +PHA+U3R5bGUgU2hlZXRzIGNhbiBiZSBhc3NvY2lhdGVkIHdpdGggYW4gWE1MPGEg +aHJlZj0iI1hNTCI+W1hNTDEwXTwvYT4KZG9jdW1lbnQgYnkgdXNpbmcgYSBwcm9j +ZXNzaW5nIGluc3RydWN0aW9uIHdob3NlIHRhcmdldCBpcwo8Y29kZT54bWwtc3R5 +bGVzaGVldDwvY29kZT4uICBUaGlzIHByb2Nlc3NpbmcgaW5zdHJ1Y3Rpb24gZm9s +bG93cyB0aGUKYmVoYXZpb3VyIG9mIHRoZSBIVE1MIDQuMCA8Y29kZT4mbHQ7TElO +SwpSRUw9InN0eWxlc2hlZXQiJmd0OzwvY29kZT48YSBocmVmPSIjSFRNTCI+W0hU +TUw0MF08L2E+LjwvcD4KCjxwPlRoZSA8Y29kZT54bWwtc3R5bGVzaGVldDwvY29k +ZT4gcHJvY2Vzc2luZyBpbnN0cnVjdGlvbiBpcyBwYXJzZWQgaW4KdGhlIHNhbWUg +d2F5IGFzIGEgc3RhcnQtdGFnLCB3aXRoIHRoZSBleGNlcHRpb24gdGhhdCBlbnRp +dGllcyBvdGhlcgp0aGFuIHByZWRlZmluZWQgZW50aXRpZXMgbXVzdCBub3QgYmUg +cmVmZXJlbmNlZC48L3A+Cgo8cD5UaGUgZm9sbG93aW5nIGdyYW1tYXIgaXMgZ2l2 +ZW4gdXNpbmcgdGhlIHNhbWUgbm90YXRpb24gYXMgdGhlCmdyYW1tYXIgaW4gdGhl +IFhNTCBSZWNvbW1lbmRhdGlvbjxhIGhyZWY9IiNYTUwiPltYTUwxMF08L2E+LiAg +U3ltYm9scyBpbiB0aGUKZ3JhbW1hciB0aGF0IGFyZSBub3QgZGVmaW5lZCBoZXJl +IGFyZSBkZWZpbmVkIGluIHRoZSBYTUwKUmVjb21tZW5kYXRpb24uPC9wPgoKPGg1 +PnhtbC1zdHlsZXNoZWV0IHByb2Nlc3NpbmcgaW5zdHJ1Y3Rpb248L2g1Pgo8dGFi +bGUgY2xhc3M9InNjcmFwIj4KPHRib2R5Pgo8dHIgdmFsaWduPSJiYXNlbGluZSI+ +Cjx0ZD4KPGEgbmFtZT0iTlQtU3R5bGVTaGVldFBJIj48L2E+WzFdJm5ic3A7Jm5i +c3A7Jm5ic3A7PC90ZD4KPHRkPlN0eWxlU2hlZXRQSTwvdGQ+Cjx0ZD4mbmJzcDsm +bmJzcDsmbmJzcDs6Oj0mbmJzcDsmbmJzcDsmbmJzcDs8L3RkPgo8dGQ+JyZsdDs/ +eG1sLXN0eWxlc2hlZXQnICg8YSBocmVmPSJodHRwOi8vd3d3LnczLm9yZy9UUi9S +RUMteG1sI05ULVMiPlM8L2E+IDxhIGhyZWY9IiNOVC1Qc2V1ZG9BdHQiPlBzZXVk +b0F0dDwvYT4pKiA8YSBocmVmPSJodHRwOi8vd3d3LnczLm9yZy9UUi9SRUMteG1s +I05ULVMiPlM8L2E+PyAnPyZndDsnPC90ZD4KPHRkPgo8L3RkPgo8L3RyPgo8dHIg +dmFsaWduPSJiYXNlbGluZSI+Cjx0ZD4KPGEgbmFtZT0iTlQtUHNldWRvQXR0Ij48 +L2E+WzJdJm5ic3A7Jm5ic3A7Jm5ic3A7PC90ZD4KPHRkPlBzZXVkb0F0dDwvdGQ+ +Cjx0ZD4mbmJzcDsmbmJzcDsmbmJzcDs6Oj0mbmJzcDsmbmJzcDsmbmJzcDs8L3Rk +Pgo8dGQ+CjxhIGhyZWY9Imh0dHA6Ly93d3cudzMub3JnL1RSL1JFQy14bWwjTlQt +TmFtZSI+TmFtZTwvYT4gPGEgaHJlZj0iaHR0cDovL3d3dy53My5vcmcvVFIvUkVD +LXhtbCNOVC1TIj5TPC9hPj8gJz0nIDxhIGhyZWY9Imh0dHA6Ly93d3cudzMub3Jn +L1RSL1JFQy14bWwjTlQtUyI+UzwvYT4/IDxhIGhyZWY9IiNOVC1Qc2V1ZG9BdHRW +YWx1ZSI+UHNldWRvQXR0VmFsdWU8L2E+CjwvdGQ+Cjx0ZD4KPC90ZD4KPC90cj4K +PHRyIHZhbGlnbj0iYmFzZWxpbmUiPgo8dGQ+CjxhIG5hbWU9Ik5ULVBzZXVkb0F0 +dFZhbHVlIj48L2E+WzNdJm5ic3A7Jm5ic3A7Jm5ic3A7PC90ZD4KPHRkPlBzZXVk +b0F0dFZhbHVlPC90ZD4KPHRkPiZuYnNwOyZuYnNwOyZuYnNwOzo6PSZuYnNwOyZu +YnNwOyZuYnNwOzwvdGQ+Cjx0ZD4oJyInIChbXiImbHQ7JmFtcDtdIHwgPGEgaHJl +Zj0iaHR0cDovL3d3dy53My5vcmcvVFIvUkVDLXhtbCNOVC1DaGFyUmVmIj5DaGFy +UmVmPC9hPiB8IDxhIGhyZWY9IiNOVC1QcmVkZWZFbnRpdHlSZWYiPlByZWRlZkVu +dGl0eVJlZjwvYT4pKiAnIic8L3RkPgo8dGQ+CjwvdGQ+CjwvdHI+Cjx0ciB2YWxp +Z249ImJhc2VsaW5lIj4KPHRkPgo8L3RkPgo8dGQ+CjwvdGQ+Cjx0ZD4KPC90ZD4K +PHRkPnwgIiciIChbXicmbHQ7JmFtcDtdIHwgPGEgaHJlZj0iaHR0cDovL3d3dy53 +My5vcmcvVFIvUkVDLXhtbCNOVC1DaGFyUmVmIj5DaGFyUmVmPC9hPiB8IDxhIGhy +ZWY9IiNOVC1QcmVkZWZFbnRpdHlSZWYiPlByZWRlZkVudGl0eVJlZjwvYT4pKiAi +JyIpPC90ZD4KPHRkPgo8L3RkPgo8L3RyPgo8dHIgdmFsaWduPSJiYXNlbGluZSI+ +Cjx0ZD4KPC90ZD4KPHRkPgo8L3RkPgo8dGQ+CjwvdGQ+Cjx0ZD4tICg8YSBocmVm +PSJodHRwOi8vd3d3LnczLm9yZy9UUi9SRUMteG1sI05ULUNoYXIiPkNoYXI8L2E+ +KiAnPyZndDsnIDxhIGhyZWY9Imh0dHA6Ly93d3cudzMub3JnL1RSL1JFQy14bWwj +TlQtQ2hhciI+Q2hhcjwvYT4qKTwvdGQ+Cjx0ZD4KPC90ZD4KPC90cj4KPHRyIHZh +bGlnbj0iYmFzZWxpbmUiPgo8dGQ+CjxhIG5hbWU9Ik5ULVByZWRlZkVudGl0eVJl +ZiI+PC9hPls0XSZuYnNwOyZuYnNwOyZuYnNwOzwvdGQ+Cjx0ZD5QcmVkZWZFbnRp +dHlSZWY8L3RkPgo8dGQ+Jm5ic3A7Jm5ic3A7Jm5ic3A7Ojo9Jm5ic3A7Jm5ic3A7 +Jm5ic3A7PC90ZD4KPHRkPicmYW1wO2FtcDsnIHwgJyZhbXA7bHQ7JyB8ICcmYW1w +O2d0OycgfCAnJmFtcDtxdW90OycgfCAnJmFtcDthcG9zOyc8L3RkPgo8dGQ+Cjwv +dGQ+CjwvdHI+CjwvdGJvZHk+CjwvdGFibGU+Cgo8cD5JbiA8YSBocmVmPSIjTlQt +UHNldWRvQXR0VmFsdWUiPlBzZXVkb0F0dFZhbHVlPC9hPiwgYSA8YSBocmVmPSJo +dHRwOi8vd3d3LnczLm9yZy9UUi9SRUMteG1sI05ULUNoYXJSZWYiPkNoYXJSZWY8 +L2E+IG9yIGEgPGEgaHJlZj0iI05ULVByZWRlZkVudGl0eVJlZiI+UHJlZGVmRW50 +aXR5UmVmPC9hPiBpcyBpbnRlcnByZXRlZCBpbiB0aGUKc2FtZSBtYW5uZXIgYXMg +aW4gYSBub3JtYWwgWE1MIGF0dHJpYnV0ZSB2YWx1ZS4gIFRoZSBhY3R1YWwgdmFs +dWUgb2YKdGhlIHBzZXVkby1hdHRyaWJ1dGUgaXMgdGhlIHZhbHVlIGFmdGVyIGVh +Y2ggcmVmZXJlbmNlIGlzIHJlcGxhY2VkIGJ5CnRoZSBjaGFyYWN0ZXIgaXQgcmVm +ZXJlbmNlcy4gIFRoaXMgcmVwbGFjZW1lbnQgaXMgbm90IHBlcmZvcm1lZAphdXRv +bWF0aWNhbGx5IGJ5IGFuIFhNTCBwcm9jZXNzb3IuPC9wPgoKPHA+VGhlIDxjb2Rl +PnhtbC1zdHlsZXNoZWV0PC9jb2RlPiBwcm9jZXNzaW5nIGluc3RydWN0aW9uIGlz +IGFsbG93ZWQKb25seSBpbiB0aGUgcHJvbG9nIG9mIGFuIFhNTCBkb2N1bWVudC4g +VGhlIHN5bnRheCBvZiBYTUwgY29uc3RyYWlucwp3aGVyZSBwcm9jZXNzaW5nIGlu +c3RydWN0aW9ucyBhcmUgYWxsb3dlZCBpbiB0aGUgcHJvbG9nOyB0aGUKPGNvZGU+ +eG1sLXN0eWxlc2hlZXQ8L2NvZGU+IHByb2Nlc3NpbmcgaW5zdHJ1Y3Rpb24gaXMg +YWxsb3dlZCBhbnl3aGVyZQppbiB0aGUgcHJvbG9nIHRoYXQgbWVldHMgdGhlc2Ug +Y29uc3RyYWludHMuPC9wPgoKPGJsb2NrcXVvdGU+CjxiPk5PVEU6IDwvYj5JZiB0 +aGUgPGNvZGU+eG1sLXN0eWxlc2hlZXQ8L2NvZGU+IHByb2Nlc3NpbmcgaW5zdHJ1 +Y3Rpb24Kb2NjdXJzIGluIHRoZSBleHRlcm5hbCBEVEQgc3Vic2V0IG9yIGluIGEg +cGFyYW1ldGVyIGVudGl0eSwgaXQgaXMKcG9zc2libGUgdGhhdCBpdCBtYXkgbm90 +IGJlIHByb2Nlc3NlZCBieSBhIG5vbi12YWxpZGF0aW5nIFhNTApwcm9jZXNzb3Ig +KHNlZSA8YSBocmVmPSIjWE1MIj5bWE1MMTBdPC9hPikuPC9ibG9ja3F1b3RlPgoK +PHA+VGhlIGZvbGxvd2luZyBwc2V1ZG8gYXR0cmlidXRlcyBhcmUgZGVmaW5lZDwv +cD4KCjxwcmU+aHJlZiBDREFUQSAjUkVRVUlSRUQKdHlwZSBDREFUQSAjUkVRVUlS +RUQKdGl0bGUgQ0RBVEEgI0lNUExJRUQKbWVkaWEgQ0RBVEEgI0lNUExJRUQKY2hh +cnNldCBDREFUQSAjSU1QTElFRAphbHRlcm5hdGUgKHllc3xubykgIm5vIjwvcHJl +PgoKPHA+VGhlIHNlbWFudGljcyBvZiB0aGUgcHNldWRvLWF0dHJpYnV0ZXMgYXJl +IGV4YWN0bHkgYXMgd2l0aAo8Y29kZT4mbHQ7TElOSyBSRUw9InN0eWxlc2hlZXQi +Jmd0OzwvY29kZT4gaW4gSFRNTCA0LjAsIHdpdGggdGhlCmV4Y2VwdGlvbiBvZiB0 +aGUgPGNvZGU+YWx0ZXJuYXRlPC9jb2RlPiBwc2V1ZG8tYXR0cmlidXRlLiAgSWYK +PGNvZGU+YWx0ZXJuYXRlPSJ5ZXMiPC9jb2RlPiBpcyBzcGVjaWZpZWQsIHRoZW4g +dGhlIHByb2Nlc3NpbmcKaW5zdHJ1Y3Rpb24gaGFzIHRoZSBzZW1hbnRpY3Mgb2Yg +PGNvZGU+Jmx0O0xJTksgUkVMPSJhbHRlcm5hdGUKc3R5bGVzaGVldCImZ3Q7PC9j +b2RlPiBpbnN0ZWFkIG9mIDxjb2RlPiZsdDtMSU5LClJFTD0ic3R5bGVzaGVldCIm +Z3Q7PC9jb2RlPi48L3A+Cgo8YmxvY2txdW90ZT4KPGI+Tk9URTogPC9iPlNpbmNl +IHRoZSB2YWx1ZSBvZiB0aGUgPGNvZGU+aHJlZjwvY29kZT4gYXR0cmlidXRlIGlz +IGEgVVJJCnJlZmVyZW5jZSwgaXQgbWF5IGJlIGEgcmVsYXRpdmUgVVJJIGFuZCBp +dCBtYXkgY29udGFpbiBhIGZyYWdtZW50CmlkZW50aWZpZXIuIEluIHBhcnRpY3Vs +YXIgdGhlIFVSSSByZWZlcmVuY2UgbWF5IGNvbnRhaW4gb25seSBhCmZyYWdtZW50 +IGlkZW50aWZpZXIuICBTdWNoIGEgVVJJIHJlZmVyZW5jZSBpcyBhIHJlZmVyZW5j +ZSB0byBhIHBhcnQgb2YKdGhlIGRvY3VtZW50IGNvbnRhaW5pbmcgdGhlIDxjb2Rl +PnhtbC1zdHlsZXNoZWV0PC9jb2RlPiBwcm9jZXNzaW5nCmluc3RydWN0aW9uIChz +ZWUgPGEgaHJlZj0iI1JGQzIzOTYiPltSRkMyMzk2XTwvYT4pLiBUaGUgY29uc2Vx +dWVuY2UgaXMgdGhhdCB0aGUKPGNvZGU+eG1sLXN0eWxlc2hlZXQ8L2NvZGU+IHBy +b2Nlc3NpbmcgaW5zdHJ1Y3Rpb24gYWxsb3dzIHN0eWxlIHNoZWV0cwp0byBiZSBl +bWJlZGRlZCBpbiB0aGUgc2FtZSBkb2N1bWVudCBhcyB0aGUgPGNvZGU+eG1sLXN0 +eWxlc2hlZXQ8L2NvZGU+CnByb2Nlc3NpbmcgaW5zdHJ1Y3Rpb24uPC9ibG9ja3F1 +b3RlPgoKPHA+SW4gc29tZSBjYXNlcywgc3R5bGUgc2hlZXRzIG1heSBiZSBsaW5r +ZWQgd2l0aCBhbiBYTUwgZG9jdW1lbnQgYnkKbWVhbnMgZXh0ZXJuYWwgdG8gdGhl +IGRvY3VtZW50LiBGb3IgZXhhbXBsZSwgZWFybGllciB2ZXJzaW9ucyBvZiBIVFRQ +CjxhIGhyZWY9IiNSRkMyMDY4Ij5bUkZDMjA2OF08L2E+IChzZWN0aW9uIDE5LjYu +Mi40KSBhbGxvd2VkIHN0eWxlIHNoZWV0cyB0byBiZQphc3NvY2lhdGVkIHdpdGgg +WE1MIGRvY3VtZW50cyBieSBtZWFucyBvZiB0aGUgPGNvZGU+TGluazwvY29kZT4K +aGVhZGVyLiAgQW55IGxpbmtzIHRvIHN0eWxlIHNoZWV0cyB0aGF0IGFyZSBzcGVj +aWZpZWQgZXh0ZXJuYWxseSB0byB0aGUKZG9jdW1lbnQgYXJlIGNvbnNpZGVyZWQg +dG8gb2NjdXIgYmVmb3JlIHRoZSBsaW5rcyBzcGVjaWZpZWQgYnkgdGhlCjxjb2Rl +PnhtbC1zdHlsZXNoZWV0PC9jb2RlPiBwcm9jZXNzaW5nIGluc3RydWN0aW9ucy4g +IFRoaXMgaXMgdGhlIHNhbWUKYXMgaW4gSFRNTCA0LjAgKHNlZSA8YSBocmVmPSJo +dHRwOi8vd3d3LnczLm9yZy9UUi9SRUMtaHRtbDQwL3ByZXNlbnQvc3R5bGVzLmh0 +bWwjaC0xNC42Ij5zZWN0aW9uCjE0LjY8L2E+KS48L3A+Cgo8cD5IZXJlIGFyZSBz +b21lIGV4YW1wbGVzIGZyb20gSFRNTCA0LjAgd2l0aCB0aGUgY29ycmVzcG9uZGlu +Zwpwcm9jZXNzaW5nIGluc3RydWN0aW9uOjwvcD4KCjxwcmU+Jmx0O0xJTksgaHJl +Zj0ibXlzdHlsZS5jc3MiIHJlbD0ic3R5bGUgc2hlZXQiIHR5cGU9InRleHQvY3Nz +IiZndDsKJmx0Oz94bWwtc3R5bGVzaGVldCBocmVmPSJteXN0eWxlLmNzcyIgdHlw +ZT0idGV4dC9jc3MiPyZndDsKCiZsdDtMSU5LIGhyZWY9Im15c3R5bGUuY3NzIiB0 +aXRsZT0iQ29tcGFjdCIgcmVsPSJzdHlsZXNoZWV0Igp0eXBlPSJ0ZXh0L2NzcyIm +Z3Q7CiZsdDs/eG1sLXN0eWxlc2hlZXQgaHJlZj0ibXlzdHlsZS5jc3MiIHRpdGxl +PSJDb21wYWN0IiB0eXBlPSJ0ZXh0L2NzcyI/Jmd0OwoKJmx0O0xJTksgaHJlZj0i +bXlzdHlsZS5jc3MiIHRpdGxlPSJNZWRpdW0iIHJlbD0iYWx0ZXJuYXRlIHN0eWxl +c2hlZXQiCnR5cGU9InRleHQvY3NzIiZndDsKJmx0Oz94bWwtc3R5bGVzaGVldCBh +bHRlcm5hdGU9InllcyIgaHJlZj0ibXlzdHlsZS5jc3MiIHRpdGxlPSJNZWRpdW0i +CnR5cGU9InRleHQvY3NzIj8mZ3Q7PC9wcmU+Cgo8cD5NdWx0aXBsZSA8Y29kZT54 +bWwtc3R5bGVzaGVldDwvY29kZT4gcHJvY2Vzc2luZyBpbnN0cnVjdGlvbnMgYXJl +CmFsc28gYWxsb3dlZCB3aXRoIGV4YWN0bHkgdGhlIHNhbWUgc2VtYW50aWNzIGFz +IHdpdGggPGNvZGU+TElOSwpSRUw9InN0eWxlc2hlZXQiPC9jb2RlPi4gRm9yIGV4 +YW1wbGUsPC9wPgoKPHByZT4mbHQ7TElOSyByZWw9ImFsdGVybmF0ZSBzdHlsZXNo +ZWV0IiB0aXRsZT0iY29tcGFjdCIgaHJlZj0ic21hbGwtYmFzZS5jc3MiCnR5cGU9 +InRleHQvY3NzIiZndDsKJmx0O0xJTksgcmVsPSJhbHRlcm5hdGUgc3R5bGVzaGVl +dCIgdGl0bGU9ImNvbXBhY3QiIGhyZWY9InNtYWxsLWV4dHJhcy5jc3MiCnR5cGU9 +InRleHQvY3NzIiZndDsKJmx0O0xJTksgcmVsPSJhbHRlcm5hdGUgc3R5bGVzaGVl +dCIgdGl0bGU9ImJpZyBwcmludCIgaHJlZj0iYmlncHJpbnQuY3NzIgp0eXBlPSJ0 +ZXh0L2NzcyImZ3Q7CiZsdDtMSU5LIHJlbD0ic3R5bGVzaGVldCIgaHJlZj0iY29t +bW9uLmNzcyIgdHlwZT0idGV4dC9jc3MiJmd0OzwvcHJlPgoKPHA+d291bGQgYmUg +ZXF1aXZhbGVudCB0bzo8L3A+Cgo8cHJlPiZsdDs/eG1sLXN0eWxlc2hlZXQgYWx0 +ZXJuYXRlPSJ5ZXMiIHRpdGxlPSJjb21wYWN0IiBocmVmPSJzbWFsbC1iYXNlLmNz +cyIKdHlwZT0idGV4dC9jc3MiPyZndDsKJmx0Oz94bWwtc3R5bGVzaGVldCBhbHRl +cm5hdGU9InllcyIgdGl0bGU9ImNvbXBhY3QiIGhyZWY9InNtYWxsLWV4dHJhcy5j +c3MiCnR5cGU9InRleHQvY3NzIj8mZ3Q7CiZsdDs/eG1sLXN0eWxlc2hlZXQgYWx0 +ZXJuYXRlPSJ5ZXMiIHRpdGxlPSJiaWcgcHJpbnQiIGhyZWY9ImJpZ3ByaW50LmNz +cyIKdHlwZT0idGV4dC9jc3MiPyZndDsKJmx0Oz94bWwtc3R5bGVzaGVldCBocmVm +PSJjb21tb24uY3NzIiB0eXBlPSJ0ZXh0L2NzcyI/Jmd0OzwvcHJlPgoKCgo8aHIg +dGl0bGU9IlNlcGFyYXRvciBmcm9tIGZvb3RlciI+Cgo8aDI+CjxhIG5hbWU9IlJl +ZmVyZW5jZXMiPjwvYT5BIFJlZmVyZW5jZXM8L2gyPgoKPGRsPgoKPGR0Pgo8YSBu +YW1lPSJIVE1MIj5IVE1MNDA8L2E+CjwvZHQ+CjxkZD5Xb3JsZCBXaWRlIFdlYgpD +b25zb3J0aXVtLiA8aT5IVE1MIDQuMCBTcGVjaWZpY2F0aW9uLjwvaT4gVzNDIFJl +Y29tbWVuZGF0aW9uLiBTZWUKPGEgaHJlZj0iaHR0cDovL3d3dy53My5vcmcvVFIv +UkVDLWh0bWw0MCI+aHR0cDovL3d3dy53My5vcmcvVFIvUkVDLWh0bWw0MDwvYT4K +PC9kZD4KCjxkdD4KPGEgbmFtZT0iUkZDMjA2OCI+UkZDMjA2ODwvYT4KPC9kdD4K +PGRkPlIuIEZpZWxkaW5nLCBKLiBHZXR0eXMsIEouIE1vZ3VsLApILiBGcnlzdHlr +IE5pZWxzZW4sIGFuZCBULiBCZXJuZXJzLUxlZS4gIDxpPkh5cGVydGV4dCBUcmFu +c2ZlcgpQcm90b2NvbCAtLSBIVFRQLzEuMS48L2k+LiBJRVRGIFJGQyAyMDY4LiBT +ZWUgPGEgaHJlZj0iaHR0cDovL3d3dy5pZXRmLm9yZy9yZmMvcmZjMjA2OC50eHQi +Pmh0dHA6Ly93d3cuaWV0Zi5vcmcvcmZjL3JmYzIwNjgudHh0PC9hPi48L2RkPgoK +PGR0Pgo8YSBuYW1lPSJSRkMyMzk2Ij5SRkMyMzk2PC9hPgo8L2R0Pgo8ZGQ+VC4g +QmVybmVycy1MZWUsIFIuIEZpZWxkaW5nLCBhbmQKTC4gTWFzaW50ZXIuICA8aT5V +bmlmb3JtIFJlc291cmNlIElkZW50aWZpZXJzIChVUkkpOiBHZW5lcmljClN5bnRh +eDwvaT4uIElFVEYgUkZDIDIzOTYuIFNlZSA8YSBocmVmPSJodHRwOi8vd3d3Lmll +dGYub3JnL3JmYy9yZmMyMzk2LnR4dCI+aHR0cDovL3d3dy5pZXRmLm9yZy9yZmMv +cmZjMjM5Ni50eHQ8L2E+LjwvZGQ+Cgo8ZHQ+CjxhIG5hbWU9IlhNTCI+WE1MMTA8 +L2E+CjwvZHQ+CjxkZD5Xb3JsZCBXaWRlIFdlYiBDb25zb3J0aXVtLiA8aT5FeHRl +bnNpYmxlCk1hcmt1cCBMYW5ndWFnZSAoWE1MKSAxLjAuPC9pPiBXM0MgUmVjb21t +ZW5kYXRpb24uIFNlZSA8YSBocmVmPSJodHRwOi8vd3d3LnczLm9yZy9UUi8xOTk4 +L1JFQy14bWwtMTk5ODAyMTAiPmh0dHA6Ly93d3cudzMub3JnL1RSLzE5OTgvUkVD +LXhtbC0xOTk4MDIxMDwvYT4KPC9kZD4KCjwvZGw+CgoKCgo8aDI+CjxhIG5hbWU9 +InJhdGlvbmFsZSI+PC9hPkIgUmF0aW9uYWxlPC9oMj4KCjxwPlRoZXJlIHdhcyBh +biB1cmdlbnQgcmVxdWlyZW1lbnQgZm9yIGEgc3BlY2lmaWNhdGlvbiBmb3Igc3R5 +bGUgc2hlZXQKbGlua2luZyB0aGF0IGNvdWxkIGJlIGNvbXBsZXRlZCBpbiB0aW1l +IGZvciB0aGUgbmV4dCByZWxlYXNlIGZyb20KbWFqb3IgYnJvd3NlciB2ZW5kb3Jz +LiAgT25seSBieSBjaG9vc2luZyBhIHNpbXBsZSBtZWNoYW5pc20gY2xvc2VseQpi +YXNlZCBvbiBhIHByb3ZlbiBleGlzdGluZyBtZWNoYW5pc20gY291bGQgdGhlIHNw +ZWNpZmljYXRpb24gYmUKY29tcGxldGVkIGluIHRpbWUgdG8gbWVldCB0aGlzIHJl +cXVpcmVtZW50LjwvcD4KCjxwPlVzZSBvZiBhIHByb2Nlc3NpbmcgaW5zdHJ1Y3Rp +b24gYXZvaWRzIHBvbGx1dGluZyB0aGUgbWFpbiBkb2N1bWVudApzdHJ1Y3R1cmUg +d2l0aCBhcHBsaWNhdGlvbiBzcGVjaWZpYyBwcm9jZXNzaW5nIGluZm9ybWF0aW9u +LjwvcD4KCjxwPlRoZSBtZWNoYW5pc20gY2hvc2VuIGZvciB0aGlzIHZlcnNpb24g +b2YgdGhlIHNwZWNpZmljYXRpb24gaXMgbm90IGEKY29uc3RyYWludCBvbiB0aGUg +YWRkaXRpb25hbCBtZWNoYW5pc21zIHBsYW5uZWQgZm9yIGZ1dHVyZSB2ZXJzaW9u +cy4KVGhlcmUgaXMgbm8gZXhwZWN0YXRpb24gdGhhdCB0aGVzZSB3aWxsIHVzZSBw +cm9jZXNzaW5nIGluc3RydWN0aW9uczsKaW5kZWVkIHRoZXkgbWF5IG5vdCBpbmNs +dWRlIHRoZSBsaW5raW5nIGluZm9ybWF0aW9uIGluIHRoZSBzb3VyY2UKZG9jdW1l +bnQuPC9wPgoKCgoKPC9ib2R5Pgo8L2h0bWw+Cg== diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/badb.der b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/badb.der new file mode 100644 index 00000000..2d0dec68 Binary files /dev/null and b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/badb.der differ diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/badb.pem b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/badb.pem new file mode 100644 index 00000000..0221d206 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/badb.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDTjCCAw6gAwIBAgIGAOz5IWdKMAkGByqGSM44BAMwdjELMAkGA1UEBhMCSUUx +DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll +cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEdMBsGA1UEAxMUQW5vdGhlciBUcmFu +c2llbnQgQ0EwHhcNMDIwNDAyMjM1OTU3WhcNMTIwNDAyMjI1OTQ2WjBmMQswCQYD +VQQGEwJJRTEPMA0GA1UECBMGRHVibGluMSQwIgYDVQQKExtCYWx0aW1vcmUgVGVj +aG5vbG9naWVzIEx0ZC4xETAPBgNVBAsTCFgvU2VjdXJlMQ0wCwYDVQQDEwRCYWRi +MIIBtjCCASsGByqGSM44BAEwggEeAoGBAISKsEonjNGgHs/uh+9YKgnwZ8Bt3T7u +yQBJW9dxpMF0cPUXz4dFbSFY4QyW8igCLswpOa+eHHEYsWvE0Nr1lcKHUPXq7u41 +JJwHNq1RAFeZiU6wa+1FL3v1/T1rAgzepV7xS4iafz4vxdHMlfwgKfoyKfq6JU1z +oVM/ahI5xWDDAhUAmEv6eIJrB4KN0fPRABPx3NHYclkCgYAlhuYZ/AzPta7+bE5C +QasmSVzc8uM/e+LN7ABlEXwQRk6QfZBcX8TbePNE8ZFng4Uft/QzAOUxALET7kKA +ek4Jeytpzc0XYCYyuGJATm4F9ZY1pAJ5yQmUmwvDYdlaZJ4ldGzO/R57Evngn/G4 +tqjjoi0sx3jq7czvDwdGHnky0AOBhAACgYATQutuLkVzLAWmxY7yUNr12h3oXy54 +Bq1CfurLlhfiraKcFqe6QB6DvfEbh+4e/GeQIPI3y+dP/zkvrbdjN6l74mCueWTI +dyn+wrhsvHbx6sb8YiElOKE7xnM1Nv8jOgcOR1NwJinjKqPv+stIdDENExfx6Ubz +8hrtRueuFP3b36M6MDgwDgYDVR0PAQH/BAQDAgeAMBEGA1UdDgQKBAiAtARqytE1 +qDATBgNVHSMEDDAKgAiKHFYwWjISfTAJBgcqhkjOOAQDAy8AMCwCFFKTrj8PpVIm +Yzp9a4bruXQS6ZvQAhQ1kT4Tac5xe7Gu8fu4RlzNTm911A== +-----END CERTIFICATE----- diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/balor.der b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/balor.der new file mode 100644 index 00000000..806d59d7 Binary files /dev/null and b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/balor.der differ diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/balor.pem b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/balor.pem new file mode 100644 index 00000000..edc1748a --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/balor.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDTzCCAw+gAwIBAgIGAOz5IaxHMAkGByqGSM44BAMwdjELMAkGA1UEBhMCSUUx +DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll +cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEdMBsGA1UEAxMUQW5vdGhlciBUcmFu +c2llbnQgQ0EwHhcNMDIwNDAzMDAwMDE1WhcNMTIwNDAyMjI1OTQ2WjBnMQswCQYD +VQQGEwJJRTEPMA0GA1UECBMGRHVibGluMSQwIgYDVQQKExtCYWx0aW1vcmUgVGVj +aG5vbG9naWVzIEx0ZC4xETAPBgNVBAsTCFgvU2VjdXJlMQ4wDAYDVQQDEwVCYWxv +cjCCAbYwggErBgcqhkjOOAQBMIIBHgKBgQCEirBKJ4zRoB7P7ofvWCoJ8GfAbd0+ +7skASVvXcaTBdHD1F8+HRW0hWOEMlvIoAi7MKTmvnhxxGLFrxNDa9ZXCh1D16u7u +NSScBzatUQBXmYlOsGvtRS979f09awIM3qVe8UuImn8+L8XRzJX8ICn6Min6uiVN +c6FTP2oSOcVgwwIVAJhL+niCaweCjdHz0QAT8dzR2HJZAoGAJYbmGfwMz7Wu/mxO +QkGrJklc3PLjP3vizewAZRF8EEZOkH2QXF/E23jzRPGRZ4OFH7f0MwDlMQCxE+5C +gHpOCXsrac3NF2AmMrhiQE5uBfWWNaQCeckJlJsLw2HZWmSeJXRszv0eexL54J/x +uLao46ItLMd46u3M7w8HRh55MtADgYQAAoGAbueMW9xlSwsHNyM3j1KFYeM2yUon +KtIVOMFc4VmNFE14ldDEldIK/8072nA2fCJvWfhTTC5DOAjzvSmH8sw2cgCLuo72 +K39mC5aDx3/US5x+WwiDqYiVQbrir09mHdnjGnRRPWTjmA4AM3PBOCNi8VykODIB +r9sgc3UAV+b8jl+jOjA4MA4GA1UdDwEB/wQEAwIHgDARBgNVHQ4ECgQIg+4EbbfC +EBMwEwYDVR0jBAwwCoAIihxWMFoyEn0wCQYHKoZIzjgEAwMvADAsAhRDxoNOoKQC +6qpfb4Eh4YrYxHnwnwIUZKOfYeB62qVk0Mpd4V/zHNWC360= +-----END CERTIFICATE----- diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/bres.pem b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/bres.pem new file mode 100644 index 00000000..18a0966c --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/bres.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDTjCCAw6gAwIBAgIGAOz5Id5/MAkGByqGSM44BAMwdjELMAkGA1UEBhMCSUUx +DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll +cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEdMBsGA1UEAxMUQW5vdGhlciBUcmFu +c2llbnQgQ0EwHhcNMDIwNDAzMDAwMDI4WhcNMTIwNDAyMjI1OTQ2WjBmMQswCQYD +VQQGEwJJRTEPMA0GA1UECBMGRHVibGluMSQwIgYDVQQKExtCYWx0aW1vcmUgVGVj +aG5vbG9naWVzIEx0ZC4xETAPBgNVBAsTCFgvU2VjdXJlMQ0wCwYDVQQDEwRCcmVz +MIIBtjCCASsGByqGSM44BAEwggEeAoGBAISKsEonjNGgHs/uh+9YKgnwZ8Bt3T7u +yQBJW9dxpMF0cPUXz4dFbSFY4QyW8igCLswpOa+eHHEYsWvE0Nr1lcKHUPXq7u41 +JJwHNq1RAFeZiU6wa+1FL3v1/T1rAgzepV7xS4iafz4vxdHMlfwgKfoyKfq6JU1z +oVM/ahI5xWDDAhUAmEv6eIJrB4KN0fPRABPx3NHYclkCgYAlhuYZ/AzPta7+bE5C +QasmSVzc8uM/e+LN7ABlEXwQRk6QfZBcX8TbePNE8ZFng4Uft/QzAOUxALET7kKA +ek4Jeytpzc0XYCYyuGJATm4F9ZY1pAJ5yQmUmwvDYdlaZJ4ldGzO/R57Evngn/G4 +tqjjoi0sx3jq7czvDwdGHnky0AOBhAACgYBgvDFxw1U6Ou2G6P/+347Jfk2wPB1/ +atr4p3JUVLuT0ExZG6np+rKiXmcBbYKbAhMY37zVkroR9bwo+NgaJGubQ4ex5Y1X +N2Q5gIHNhNfKr8G4LPVqWGxf/lFPDYxX3ezqBJPpJCJTREX7s6Hp/VTV2SpQlySv ++GRcFKJFPlhD9aM6MDgwDgYDVR0PAQH/BAQDAgeAMBEGA1UdDgQKBAiC+5gx0MHL +hTATBgNVHSMEDDAKgAiKHFYwWjISfTAJBgcqhkjOOAQDAy8AMCwCFDTcM5i61uqq +/aveERhOJ6NG/LubAhREVDtAeNbTEywXr4O7KvEEvFLUjg== +-----END CERTIFICATE----- diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/ca.der b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/ca.der new file mode 100644 index 00000000..00861d03 Binary files /dev/null and b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/ca.der differ diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/ca.pem b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/ca.pem new file mode 100644 index 00000000..4e6d5766 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/ca.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDWjCCAxqgAwIBAgIGAOz5ITo8MAkGByqGSM44BAMwdjELMAkGA1UEBhMCSUUx +DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll +cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEdMBsGA1UEAxMUQW5vdGhlciBUcmFu +c2llbnQgQ0EwHhcNMDIwNDAyMjM1OTQ2WhcNMTIwNDAyMjI1OTQ2WjB2MQswCQYD +VQQGEwJJRTEPMA0GA1UECBMGRHVibGluMSQwIgYDVQQKExtCYWx0aW1vcmUgVGVj +aG5vbG9naWVzIEx0ZC4xETAPBgNVBAsTCFgvU2VjdXJlMR0wGwYDVQQDExRBbm90 +aGVyIFRyYW5zaWVudCBDQTCCAbYwggErBgcqhkjOOAQBMIIBHgKBgQCEirBKJ4zR +oB7P7ofvWCoJ8GfAbd0+7skASVvXcaTBdHD1F8+HRW0hWOEMlvIoAi7MKTmvnhxx +GLFrxNDa9ZXCh1D16u7uNSScBzatUQBXmYlOsGvtRS979f09awIM3qVe8UuImn8+ +L8XRzJX8ICn6Min6uiVNc6FTP2oSOcVgwwIVAJhL+niCaweCjdHz0QAT8dzR2HJZ +AoGAJYbmGfwMz7Wu/mxOQkGrJklc3PLjP3vizewAZRF8EEZOkH2QXF/E23jzRPGR +Z4OFH7f0MwDlMQCxE+5CgHpOCXsrac3NF2AmMrhiQE5uBfWWNaQCeckJlJsLw2HZ +WmSeJXRszv0eexL54J/xuLao46ItLMd46u3M7w8HRh55MtADgYQAAoGADpGA7hzl +zqaxtr6U+w86qQmoDJhIPMGAUG65aFhGDLm410IzA30J4DYEd9gpnG7lNF+AeHQq +rpvUN+H0CB0eSxiElFRiV+x+oYUN/p1v/mbKXb4H1+mT7XTi5G/k9Kw5e8UbNgDC +Ij/2uewSMd5y+jkWUUUXlwYbqt5pOZZhmtejNjA0MA4GA1UdDwEB/wQEAwICBDAP +BgNVHRMECDAGAQH/AgEAMBEGA1UdDgQKBAiKHFYwWjISfTAJBgcqhkjOOAQDAy8A +MCwCFDI9WLFVplIMf5ta+kB2s/BHBzm9AhQTczFDTX/7sawplNpLfzu5i/g+qA== +-----END CERTIFICATE----- diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh-cert.der b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh-cert.der new file mode 100644 index 00000000..2109edfa Binary files /dev/null and b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh-cert.der differ diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh-cert.pem b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh-cert.pem new file mode 100644 index 00000000..049721f1 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh-cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDTzCCAw6gAwIBAgIGAOz5IcSmMAkGByqGSM44BAMwdjELMAkGA1UEBhMCSUUx +DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll +cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEdMBsGA1UEAxMUQW5vdGhlciBUcmFu +c2llbnQgQ0EwHhcNMDIwNDAzMDAwMDIxWhcNMTIwNDAyMjI1OTQ2WjBmMQswCQYD +VQQGEwJJRTEPMA0GA1UECBMGRHVibGluMSQwIgYDVQQKExtCYWx0aW1vcmUgVGVj +aG5vbG9naWVzIEx0ZC4xETAPBgNVBAsTCFgvU2VjdXJlMQ0wCwYDVQQDEwRMdWdo +MIIBtjCCASsGByqGSM44BAEwggEeAoGBAISKsEonjNGgHs/uh+9YKgnwZ8Bt3T7u +yQBJW9dxpMF0cPUXz4dFbSFY4QyW8igCLswpOa+eHHEYsWvE0Nr1lcKHUPXq7u41 +JJwHNq1RAFeZiU6wa+1FL3v1/T1rAgzepV7xS4iafz4vxdHMlfwgKfoyKfq6JU1z +oVM/ahI5xWDDAhUAmEv6eIJrB4KN0fPRABPx3NHYclkCgYAlhuYZ/AzPta7+bE5C +QasmSVzc8uM/e+LN7ABlEXwQRk6QfZBcX8TbePNE8ZFng4Uft/QzAOUxALET7kKA +ek4Jeytpzc0XYCYyuGJATm4F9ZY1pAJ5yQmUmwvDYdlaZJ4ldGzO/R57Evngn/G4 +tqjjoi0sx3jq7czvDwdGHnky0AOBhAACgYBIdlgw5JS5w1C4a5zQVul03YLFTkaX +6RxbTYsDcnb0SyegrcKQ5y7MgaeDTUVIzCe6Q1WNjvT1fLwWmygpNVUUOZKEJT3p +kSB+8/7IrGM+IWUTxkyIwasgsmrQnV/a+CSRFVDzZQKJFzcdCfZmK0yxh2NrPMiQ +ogOgroVjgLrlE6M6MDgwDgYDVR0PAQH/BAQDAgeAMBEGA1UdDgQKBAiMWQ6+Iv7t +UDATBgNVHSMEDDAKgAiKHFYwWjISfTAJBgcqhkjOOAQDAzAAMC0CFQCE72yE3Jte +0ltPp3yWpePyMp0RJgIUdB+bQ5BzY7G332mPCCH7dNa1Y0Q= +-----END CERTIFICATE----- diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh.der b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh.der new file mode 100644 index 00000000..3b1193ab Binary files /dev/null and b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh.der differ diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh.pem b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh.pem new file mode 100644 index 00000000..e0d1e959 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/lugh.pem @@ -0,0 +1,12 @@ +-----BEGIN PUBLIC KEY----- +MIIBtjCCASsGByqGSM44BAEwggEeAoGBAISKsEonjNGgHs/uh+9YKgnwZ8Bt3T7u +yQBJW9dxpMF0cPUXz4dFbSFY4QyW8igCLswpOa+eHHEYsWvE0Nr1lcKHUPXq7u41 +JJwHNq1RAFeZiU6wa+1FL3v1/T1rAgzepV7xS4iafz4vxdHMlfwgKfoyKfq6JU1z +oVM/ahI5xWDDAhUAmEv6eIJrB4KN0fPRABPx3NHYclkCgYAlhuYZ/AzPta7+bE5C +QasmSVzc8uM/e+LN7ABlEXwQRk6QfZBcX8TbePNE8ZFng4Uft/QzAOUxALET7kKA +ek4Jeytpzc0XYCYyuGJATm4F9ZY1pAJ5yQmUmwvDYdlaZJ4ldGzO/R57Evngn/G4 +tqjjoi0sx3jq7czvDwdGHnky0AOBhAACgYBIdlgw5JS5w1C4a5zQVul03YLFTkaX +6RxbTYsDcnb0SyegrcKQ5y7MgaeDTUVIzCe6Q1WNjvT1fLwWmygpNVUUOZKEJT3p +kSB+8/7IrGM+IWUTxkyIwasgsmrQnV/a+CSRFVDzZQKJFzcdCfZmK0yxh2NrPMiQ +ogOgroVjgLrlEw== +-----END PUBLIC KEY----- diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/macha.der b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/macha.der new file mode 100644 index 00000000..484ddc26 Binary files /dev/null and b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/macha.der differ diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/macha.pem b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/macha.pem new file mode 100644 index 00000000..2402a12f --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/macha.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDUDCCAw+gAwIBAgIGAOz5IXv6MAkGByqGSM44BAMwdjELMAkGA1UEBhMCSUUx +DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll +cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEdMBsGA1UEAxMUQW5vdGhlciBUcmFu +c2llbnQgQ0EwHhcNMDIwNDAzMDAwMDAzWhcNMTIwNDAyMjI1OTQ2WjBnMQswCQYD +VQQGEwJJRTEPMA0GA1UECBMGRHVibGluMSQwIgYDVQQKExtCYWx0aW1vcmUgVGVj +aG5vbG9naWVzIEx0ZC4xETAPBgNVBAsTCFgvU2VjdXJlMQ4wDAYDVQQDEwVNYWNo +YTCCAbYwggErBgcqhkjOOAQBMIIBHgKBgQCEirBKJ4zRoB7P7ofvWCoJ8GfAbd0+ +7skASVvXcaTBdHD1F8+HRW0hWOEMlvIoAi7MKTmvnhxxGLFrxNDa9ZXCh1D16u7u +NSScBzatUQBXmYlOsGvtRS979f09awIM3qVe8UuImn8+L8XRzJX8ICn6Min6uiVN +c6FTP2oSOcVgwwIVAJhL+niCaweCjdHz0QAT8dzR2HJZAoGAJYbmGfwMz7Wu/mxO +QkGrJklc3PLjP3vizewAZRF8EEZOkH2QXF/E23jzRPGRZ4OFH7f0MwDlMQCxE+5C +gHpOCXsrac3NF2AmMrhiQE5uBfWWNaQCeckJlJsLw2HZWmSeJXRszv0eexL54J/x +uLao46ItLMd46u3M7w8HRh55MtADgYQAAoGAXenEaP4SIoG3ukTjtqT8TOKddzyb +dd8epOpGDnPemC6hmsjkbfNDrKEdbsb9AKhb0pp2HKWxNPzPACJ65LMgrtTPY/6f +NLxB1/o+J1dJR7nehKF9WjwDjAJJ6f9Wc4OwJP7B7DlwWzhaMMNOzmASAUU/AoeL +WTuMfjA3O+6hm6ijOjA4MA4GA1UdDwEB/wQEAwIHgDARBgNVHQ4ECgQIizPsQXmT +yPowEwYDVR0jBAwwCoAIihxWMFoyEn0wCQYHKoZIzjgEAwMwADAtAhUAiT4zE8AB +6veOzVcWxkyYFwHcnFsCFDorkHKzPCnWkmpuDY39GvfKEYBA +-----END CERTIFICATE----- diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/merlin.der b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/merlin.der new file mode 100644 index 00000000..a72fc7f0 Binary files /dev/null and b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/merlin.der differ diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/merlin.pem b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/merlin.pem new file mode 100644 index 00000000..7efe8e08 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/merlin.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDSzCCAwugAwIBAgIGAOz46fwJMAkGByqGSM44BAMwbjELMAkGA1UEBhMCSUUx +DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll +cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEVMBMGA1UEAxMMVHJhbnNpZW50IENB +MB4XDTAyMDQwMjIyNTkyNVoXDTEyMDQwMjIxNTkyNVowbjELMAkGA1UEBhMCSUUx +DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll +cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEVMBMGA1UEAxMMVHJhbnNpZW50IENB +MIIBtzCCASwGByqGSM44BAEwggEfAoGBAN3jngL6pxMhaVvrk0oK3Y+2C42k5Kch +3nChSKC7vEGTZBk0CNXIiEwR9JanyJHQh0ovH4lAtw06tyfRbCXn+GFbQxeyaVLx +0zkKrau2YMeigvFsZM+q0AsTq+xdAKTmIvPcy0aHuDJAxnursdPlrcjk0KFSBjUw +w1BV61EDWy6xAhUAhDLcFK0GO/Hz1arxOOvsgM/VLyUCgYEAnnx7hbdWozGbtnFg +nbFnopfRl7XRacpkPJRGf5P2IUgVspEUSUoN6i1fDBfBg43zKt7dlEaQL7b5+JTZ +t3MhZNPosxsgxVuT7Ts/g5k7EnpdYv0a5hw5Bw29fjbGHfgM8d2rhd2Ui0xHbk0D +451nhLxVWulviOSPhzKKvXrbySADgYQAAoGAfag+HCABIJadDD9Aarhgc2QR3Lp7 +PpMOh0lAwLiIsvkO4UlbeOS0IJC8bcqLjM1fVw6FGSaxmq+4y1ag2m9k6IdE0Qh5 +NxB/xFkmdwqXFRIJVp44OeUygB47YK76NmUIYG3DdfiPPU3bqzjvtOtETiCHvo25 +4D6UjwPpYErXRUajNjA0MA4GA1UdDwEB/wQEAwICBDAPBgNVHRMECDAGAQH/AgEA +MBEGA1UdDgQKBAiDhj5AdjLikzAJBgcqhkjOOAQDAy8AMCwCFELu0nuweqW7Wf0s +gk/CAGGL0BGKAhRNdgQGr5iyZKoH4oqPm0VJ9TjXLg== +-----END CERTIFICATE----- + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/morigu.pem b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/morigu.pem new file mode 100644 index 00000000..c1fd6eb5 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/morigu.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDUDCCAxCgAwIBAgIGAOz5IVHTMAkGByqGSM44BAMwdjELMAkGA1UEBhMCSUUx +DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll +cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEdMBsGA1UEAxMUQW5vdGhlciBUcmFu +c2llbnQgQ0EwHhcNMDIwNDAyMjM1OTUyWhcNMTIwNDAyMjI1OTQ2WjBoMQswCQYD +VQQGEwJJRTEPMA0GA1UECBMGRHVibGluMSQwIgYDVQQKExtCYWx0aW1vcmUgVGVj +aG5vbG9naWVzIEx0ZC4xETAPBgNVBAsTCFgvU2VjdXJlMQ8wDQYDVQQDEwZNb3Jp +Z3UwggG2MIIBKwYHKoZIzjgEATCCAR4CgYEAhIqwSieM0aAez+6H71gqCfBnwG3d +Pu7JAElb13GkwXRw9RfPh0VtIVjhDJbyKAIuzCk5r54ccRixa8TQ2vWVwodQ9eru +7jUknAc2rVEAV5mJTrBr7UUve/X9PWsCDN6lXvFLiJp/Pi/F0cyV/CAp+jIp+rol +TXOhUz9qEjnFYMMCFQCYS/p4gmsHgo3R89EAE/Hc0dhyWQKBgCWG5hn8DM+1rv5s +TkJBqyZJXNzy4z974s3sAGURfBBGTpB9kFxfxNt480TxkWeDhR+39DMA5TEAsRPu +QoB6Tgl7K2nNzRdgJjK4YkBObgX1ljWkAnnJCZSbC8Nh2VpkniV0bM79HnsS+eCf +8bi2qOOiLSzHeOrtzO8PB0YeeTLQA4GEAAKBgH1NBJ9Az5TwY4tDE0dPYVHHABt+ +yLspnT3k9G6YWUMFhZ/+3RuqEPjnKrPfUoXTTJGIACgPU3/PkqwrPVD0JMdpOcnZ +LHiJ/P7QRQeMwDRoBrs7genB1bDd4pSJrEUcjrkA5uRrIj2Z5fL+UuLiLGPO2rM7 +BNQRIq3QFPdX++NuozowODAOBgNVHQ8BAf8EBAMCB4AwEQYDVR0OBAoECIK7Ljjh ++EsfMBMGA1UdIwQMMAqACIocVjBaMhJ9MAkGByqGSM44BAMDLwAwLAIUEJJCOHw8 +ppxoRyz3s+Vmb4NKIfMCFDgJoZn9zh/3WoYNBURODwLvyBOy +-----END CERTIFICATE----- diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/nemain.der b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/nemain.der new file mode 100644 index 00000000..f4b62ae6 Binary files /dev/null and b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/nemain.der differ diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/nemain.pem b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/nemain.pem new file mode 100644 index 00000000..b681a5c2 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/certs/nemain.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDUDCCAxCgAwIBAgIGAOz5IZDHMAkGByqGSM44BAMwdjELMAkGA1UEBhMCSUUx +DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll +cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEdMBsGA1UEAxMUQW5vdGhlciBUcmFu +c2llbnQgQ0EwHhcNMDIwNDAzMDAwMDA4WhcNMTIwNDAyMjI1OTQ2WjBoMQswCQYD +VQQGEwJJRTEPMA0GA1UECBMGRHVibGluMSQwIgYDVQQKExtCYWx0aW1vcmUgVGVj +aG5vbG9naWVzIEx0ZC4xETAPBgNVBAsTCFgvU2VjdXJlMQ8wDQYDVQQDEwZOZW1h +aW4wggG2MIIBKwYHKoZIzjgEATCCAR4CgYEAhIqwSieM0aAez+6H71gqCfBnwG3d +Pu7JAElb13GkwXRw9RfPh0VtIVjhDJbyKAIuzCk5r54ccRixa8TQ2vWVwodQ9eru +7jUknAc2rVEAV5mJTrBr7UUve/X9PWsCDN6lXvFLiJp/Pi/F0cyV/CAp+jIp+rol +TXOhUz9qEjnFYMMCFQCYS/p4gmsHgo3R89EAE/Hc0dhyWQKBgCWG5hn8DM+1rv5s +TkJBqyZJXNzy4z974s3sAGURfBBGTpB9kFxfxNt480TxkWeDhR+39DMA5TEAsRPu +QoB6Tgl7K2nNzRdgJjK4YkBObgX1ljWkAnnJCZSbC8Nh2VpkniV0bM79HnsS+eCf +8bi2qOOiLSzHeOrtzO8PB0YeeTLQA4GEAAKBgHzbc/0aTzXwKKeT85kjCq2HD4WY +nZC9DOck02gNhNbEgN+wGeUPDSQM/vhmxVeoK3ptVA/sU8arBW8V+AdrU/9hJr0v +nEiqgt9WQLHUhnMJiXTMLcS7XHeIVcwh/iRjD61HUp1cby9UMHZRsW6Ys8rUi0Zn +/1KrtpTwZJuNwsYIozowODAOBgNVHQ8BAf8EBAMCB4AwEQYDVR0OBAoECIX9dMSn +0pyIMBMGA1UdIwQMMAqACIocVjBaMhJ9MAkGByqGSM44BAMDLwAwLAIUFRYkL6qD +NZWtKU03+WYBiGEGSoECFEtRGI19WHg+sT9fBfGKfo8NnJX4 +-----END CERTIFICATE----- diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloped-dsa.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloped-dsa.tmpl new file mode 100644 index 00000000..ba499417 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloped-dsa.tmpl @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + TestKeyName-dsa-1024 + + + + + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-b64-dsa.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-b64-dsa.tmpl new file mode 100644 index 00000000..fc9d34c1 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-b64-dsa.tmpl @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + TestKeyName-dsa-1024 + + + + c29tZSB0ZXh0 + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-b64-dsa.xml b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-b64-dsa.xml new file mode 100644 index 00000000..4e924b0e --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-b64-dsa.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + N6pjx3OY2VRHMmLhoAV8HmMu2nc= + + + + KgAeq8e0yUNfFz+mFlZ3QgyQNMciV+Z3BoDQDvQNker7pazEnJmOIA== + + + + +

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

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

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

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

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

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

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

+ + hDLcFK0GO/Hz1arxOOvsgM/VLyU= + + + nnx7hbdWozGbtnFgnbFnopfRl7XRacpkPJRGf5P2IUgVspEUSUoN6i1fDBfBg43z + Kt7dlEaQL7b5+JTZt3MhZNPosxsgxVuT7Ts/g5k7EnpdYv0a5hw5Bw29fjbGHfgM + 8d2rhd2Ui0xHbk0D451nhLxVWulviOSPhzKKvXrbySA= + + + cfYpihpAQeepbNFS4MAbQRhdXpDi5wLrwxE5hIvoYqo1L8BQVu8fY1TFAPtoae1i + Bg/GIJyP3iLfyuBJaDvJJLP30wBH9i/s5J3656PevpOVdTfi777Fi9Gj6y/ib2Vv + +OZfJkkp4L50+p5TUhPmQLJtREsgtl+tnIOyJT++G9U= + +
+
+
+
diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-keyname.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-keyname.tmpl new file mode 100644 index 00000000..add078f2 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-keyname.tmpl @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + TestKeyName-dsa-1024 + + + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-keyname.xml b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-keyname.xml new file mode 100644 index 00000000..a7c60a3d --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-keyname.xml @@ -0,0 +1,17 @@ + + + + + + + + 60NvZvtdTB+7UnlLp/H24p7h4bs= + + + + JkJ3GplEU0iDbqSv7ZOXhvv3zeM1KmP+CLphhoc+NPYqpGYQiW6O6w== + + + Lugh + + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-retrievalmethod-rawx509crt.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-retrievalmethod-rawx509crt.tmpl new file mode 100644 index 00000000..064a953e --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-retrievalmethod-rawx509crt.tmpl @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-retrievalmethod-rawx509crt.xml b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-retrievalmethod-rawx509crt.xml new file mode 100644 index 00000000..30620184 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-retrievalmethod-rawx509crt.xml @@ -0,0 +1,17 @@ + + + + + + + + 60NvZvtdTB+7UnlLp/H24p7h4bs= + + + + SNB5FI193RFXoG2j8Z9bXWgW7BMPICqNob4Hjh08oou4tkhGxz4+pg== + + + + + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt-crl.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt-crl.tmpl new file mode 100644 index 00000000..7b8d170d --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt-crl.tmpl @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + TestKeyName-dsa-1024 + + + + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt.tmpl new file mode 100644 index 00000000..7b8d170d --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt.tmpl @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + TestKeyName-dsa-1024 + + + + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-is.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-is.tmpl new file mode 100644 index 00000000..7b8d170d --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-is.tmpl @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + TestKeyName-dsa-1024 + + + + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-ski.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-ski.tmpl new file mode 100644 index 00000000..7b8d170d --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-ski.tmpl @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + TestKeyName-dsa-1024 + + + + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-sn.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-sn.tmpl new file mode 100644 index 00000000..7b8d170d --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-sn.tmpl @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + TestKeyName-dsa-1024 + + + + diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature.tmpl b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature.tmpl new file mode 100644 index 00000000..0e2d0781 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature.tmpl @@ -0,0 +1,252 @@ + + + + + + +]> + + + foo + bar + + + + + + + + + + + + + + + + + + + + + self::text() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ancestor-or-self::dsig:SignedInfo + and + count(ancestor-or-self::dsig:Reference | + here()/ancestor::dsig:Reference[1]) > + count(ancestor-or-self::dsig:Reference) + or + count(ancestor-or-self::node() | + id('notaries')) = + count(ancestor-or-self::node()) + + + + + + + + + + + + + + + ancestor-or-self::dsig:X509Data + + + + + + I am the text. + SSBhbSB0aGUgdGV4dC4= + + + + + + + + + + + + + + + + + + + + + + Notaries + + + + + + + + +
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + + + 192.168.21.138 + + + + + + +MIIFqjCCBJKgAwIBAgIUdzXuSH9oYtrxs5VtlhzLD6bzT1AwDQYJKoZIhvcNAQEL +BQAwgbYxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMT0wOwYDVQQK +EzRYTUwgU2VjdXJpdHkgTGlicmFyeSAoaHR0cDovL3d3dy5hbGVrc2V5LmNvbS94 +bWxzZWMpMRgwFgYDVQQLEw9TZWNvbmQgbGV2ZWwgQ0ExFjAUBgNVBAMTDUFsZWtz +ZXkgU2FuaW4xITAfBgkqhkiG9w0BCQEWEnhtbHNlY0BhbGVrc2V5LmNvbTAgFw0y +NjAzMDgyMjEzMTBaGA8yMTI2MDIxMjIyMTMxMFowfTELMAkGA1UEBhMCVVMxEzAR +BgNVBAgTCkNhbGlmb3JuaWExPTA7BgNVBAoTNFhNTCBTZWN1cml0eSBMaWJyYXJ5 +IChodHRwOi8vd3d3LmFsZWtzZXkuY29tL3htbHNlYykxGjAYBgNVBAMTEVRlc3Qg +S2V5IGRzYS0xMDI0MIIBtjCCASsGByqGSM44BAEwggEeAoGBAIXYS5F9OLq7vXyX +vPx4EY5UKcDS+nXaVDFwppOgO5DxHw8ZDronBwAYUMMJrNsakb17IMyQvuJDR0FP +HLxyAQXrWjXXiR7tbwG5oC2/N/H33iU6qcHcxk9Xp6DKaiNZXVgOmwuiD4xDQm0n +lAMeFRP1TIlvouaQB6s6+RGwPD81AhUApYJ8h4jXgfdyWtN+hFTj4bub068CgYBq +/BjSSH5vUQaZZshI2BdEu8N7R4Ecy5OJYcPytvfj6zSTR/N+4PRnDCAHXXyGsYi0 +FB3SDcdgIn+MfJUOx1KRNXhp2AK/F6QVfgp8J6TgFonsHAJNlsjZJ06QLwAVs0Tv +yVuEZcePakDLsGwfFsRIWLT0oeZ5wmm59tQ1AY881wOBhAACgYBgSc1I6UqJiCj4 +MDiNQ1s+rVJHG0emMr7sELrqaxmQrgzEs5NBfFE6e4doXfVfz1A+OXDW4vmx0YFD +vXOy9KHgFQpBViUf7P4c9/BERiIvL7rWuTNkW/g2O9ssCHhwq3Ifs51ScfGjjdpb +gsEtdrCzxiQondH71HAXvLcy9g2XgqOCAVAwggFMMAwGA1UdEwQFMAMBAf8wLAYJ +YIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRlMB0GA1Ud +DgQWBBSja1vEKoR/s6T/Ybtp6LvtXuA2SDCB7gYDVR0jBIHmMIHjgBTRfResRUKK +jvmwFyXVPHKYnYg6JaGBtKSBsTCBrjELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNh +bGlmb3JuaWExPTA7BgNVBAoTNFhNTCBTZWN1cml0eSBMaWJyYXJ5IChodHRwOi8v +d3d3LmFsZWtzZXkuY29tL3htbHNlYykxEDAOBgNVBAsTB1Jvb3QgQ0ExFjAUBgNV +BAMTDUFsZWtzZXkgU2FuaW4xITAfBgkqhkiG9w0BCQEWEnhtbHNlY0BhbGVrc2V5 +LmNvbYIUdzXuSH9oYtrxs5VtlhzLD6bzT08wDQYJKoZIhvcNAQELBQADggEBAJA2 +6Gg+tjwHN2LOFLGf0H/L9EGOsVd766W9WlSMd9o4Scu7CpPxjlxIiZ1Me4PqNA9B +yOpn0+etG4C2ZYx8uC05NaqqwsONDyCbDIQY65DoHgmN1UykWtFo7+7107C6d2Dt +Sx9NK/s8+khLHCKk+zcCSlITHqo9jGqkeHJ/N7D1YY7J4tigDnQLK0JDYP86GwVm +Lntj2aOW8tlTuT/e2SFfcjaeAbY8nw1j6Xe3cI/IsMQIKkZPDSpi1vpbQh2Wp2VJ +qfD/c9NgPET/AhJ8M6+2dAQ2odRwBRknPhbyFKHuRbUS5M29h0AaRM7JzgajieZH +YGsyfg9XjjhqrWyi+OM= + + + +
+
+ bar + + + + + +
+ diff --git a/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature.xml b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature.xml new file mode 100644 index 00000000..504fbe11 --- /dev/null +++ b/tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature.xml @@ -0,0 +1,269 @@ + + + + + + +]> + + + foo + bar + + + + + + + + 60NvZvtdTB+7UnlLp/H24p7h4bs= + + + + + + + 60NvZvtdTB+7UnlLp/H24p7h4bs= + + + + + + self::text() + + + + + zyjp8GJOX69990Kkqw8ioPXGExk= + + + + + + ancestor-or-self::dsig:SignedInfo + and + count(ancestor-or-self::dsig:Reference | + here()/ancestor::dsig:Reference[1]) > + count(ancestor-or-self::dsig:Reference) + or + count(ancestor-or-self::node() | + id('notaries')) = + count(ancestor-or-self::node()) + + + + + tQiE3GUKiBenPyp3J0Ei6rJMFv4= + + + + + + + zyjp8GJOX69990Kkqw8ioPXGExk= + + + + qg4HFwsN+/WX32uH85WlJU9l45k= + + + + ETlEI3y7hvvAtMe9wQSz7LhbHEE= + + + + + + + J/O0HhdaPXxx49fgGWMESL09GpA= + + + + + + + + J/O0HhdaPXxx49fgGWMESL09GpA= + + + + + + + J/O0HhdaPXxx49fgGWMESL09GpA= + + + + + + + + MkL9CX8yeABBth1RChyPx58Ls8w= + + + + yamSIokKmjA3hB/s3Fu07wDO3vM= + + + + + + + yamSIokKmjA3hB/s3Fu07wDO3vM= + + + + yamSIokKmjA3hB/s3Fu07wDO3vM= + + + + + + + 419CYgyTWOTGYGBhzieWklNf7Bk= + + + + VzK45P9Ksjqq5oXlKQpkGgB2CNY= + + + + 7/9fR+NIDz9owc1Lfsxu1JBr8uo= + + + + qURlo3LSq4TWQtygBZJ0iXQ9E14= + + + + WvZUJAJ/3QNqzQvwne2vvy7U5Pck8ZZ5UTa6pIwR7GE+PoGi6A1kyw== + + + + + + + ancestor-or-self::dsig:X509Data + + + + + + I am the text. + SSBhbSB0aGUgdGV4dC4= + + + + + + + + 60NvZvtdTB+7UnlLp/H24p7h4bs= + + + + qURlo3LSq4TWQtygBZJ0iXQ9E14= + + + + + + + + + + Notaries + + + + + + + + +
+ +
+ + +
+
+
+ +
+ + c7wq5XKos6RqNVJyFy7/fl6+sAs= +
+
+
+ + + + 192.168.21.138 + + + + + + + CN=Merlin Hughes,OU=X/Secure,O=Baltimore Technologies Ltd.,ST=Dublin,C=IE + + + + CN=Transient CA,OU=X/Secure,O=Baltimore Technologies Ltd.,ST=Dublin,C=IE + + 1017788370348 + + + MIIDUDCCAxCgAwIBAgIGAOz46g2sMAkGByqGSM44BAMwbjELMAkGA1UEBhMCSUUx + DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll + cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEVMBMGA1UEAxMMVHJhbnNpZW50IENB + MB4XDTAyMDQwMjIyNTkzMFoXDTEyMDQwMjIxNTkyNVowbzELMAkGA1UEBhMCSUUx + DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll + cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEWMBQGA1UEAxMNTWVybGluIEh1Z2hl + czCCAbcwggEsBgcqhkjOOAQBMIIBHwKBgQDd454C+qcTIWlb65NKCt2PtguNpOSn + Id5woUigu7xBk2QZNAjVyIhMEfSWp8iR0IdKLx+JQLcNOrcn0Wwl5/hhW0MXsmlS + 8dM5Cq2rtmDHooLxbGTPqtALE6vsXQCk5iLz3MtGh7gyQMZ7q7HT5a3I5NChUgY1 + MMNQVetRA1susQIVAIQy3BStBjvx89Wq8Tjr7IDP1S8lAoGBAJ58e4W3VqMxm7Zx + YJ2xZ6KX0Ze10WnKZDyURn+T9iFIFbKRFElKDeotXwwXwYON8yre3ZRGkC+2+fiU + 2bdzIWTT6LMbIMVbk+07P4OZOxJ6XWL9GuYcOQcNvX42xh34DPHdq4XdlItMR25N + A+OdZ4S8VVrpb4jkj4cyir1628kgA4GEAAKBgHH2KYoaQEHnqWzRUuDAG0EYXV6Q + 4ucC68MROYSL6GKqNS/AUFbvH2NUxQD7aGntYgYPxiCcj94i38rgSWg7ySSz99MA + R/Yv7OSd+uej3r6TlXU34u++xYvRo+sv4m9lb/jmXyZJKeC+dPqeU1IT5kCybURL + ILZfrZyDsiU/vhvVozowODAOBgNVHQ8BAf8EBAMCB4AwEQYDVR0OBAoECIatY7SE + lXEOMBMGA1UdIwQMMAqACIOGPkB2MuKTMAkGByqGSM44BAMDLwAwLAIUSvT02iQj + Q5da4Wpe0Bvs7GuCcVsCFCEcQpbjUfnxXFXNWiFyQ49ZrWqn + + + MIIDSzCCAwugAwIBAgIGAOz46fwJMAkGByqGSM44BAMwbjELMAkGA1UEBhMCSUUx + DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll + cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEVMBMGA1UEAxMMVHJhbnNpZW50IENB + MB4XDTAyMDQwMjIyNTkyNVoXDTEyMDQwMjIxNTkyNVowbjELMAkGA1UEBhMCSUUx + DzANBgNVBAgTBkR1YmxpbjEkMCIGA1UEChMbQmFsdGltb3JlIFRlY2hub2xvZ2ll + cyBMdGQuMREwDwYDVQQLEwhYL1NlY3VyZTEVMBMGA1UEAxMMVHJhbnNpZW50IENB + MIIBtzCCASwGByqGSM44BAEwggEfAoGBAN3jngL6pxMhaVvrk0oK3Y+2C42k5Kch + 3nChSKC7vEGTZBk0CNXIiEwR9JanyJHQh0ovH4lAtw06tyfRbCXn+GFbQxeyaVLx + 0zkKrau2YMeigvFsZM+q0AsTq+xdAKTmIvPcy0aHuDJAxnursdPlrcjk0KFSBjUw + w1BV61EDWy6xAhUAhDLcFK0GO/Hz1arxOOvsgM/VLyUCgYEAnnx7hbdWozGbtnFg + nbFnopfRl7XRacpkPJRGf5P2IUgVspEUSUoN6i1fDBfBg43zKt7dlEaQL7b5+JTZ + t3MhZNPosxsgxVuT7Ts/g5k7EnpdYv0a5hw5Bw29fjbGHfgM8d2rhd2Ui0xHbk0D + 451nhLxVWulviOSPhzKKvXrbySADgYQAAoGAfag+HCABIJadDD9Aarhgc2QR3Lp7 + PpMOh0lAwLiIsvkO4UlbeOS0IJC8bcqLjM1fVw6FGSaxmq+4y1ag2m9k6IdE0Qh5 + NxB/xFkmdwqXFRIJVp44OeUygB47YK76NmUIYG3DdfiPPU3bqzjvtOtETiCHvo25 + 4D6UjwPpYErXRUajNjA0MA4GA1UdDwEB/wQEAwICBDAPBgNVHRMECDAGAQH/AgEA + MBEGA1UdDgQKBAiDhj5AdjLikzAJBgcqhkjOOAQDAy8AMCwCFELu0nuweqW7Wf0s + gk/CAGGL0BGKAhRNdgQGr5iyZKoH4oqPm0VJ9TjXLg== + + + +
+
+ bar + + + + + +
+ diff --git a/tests/fixtures/xmlenc/README.md b/tests/fixtures/xmlenc/README.md index 14d5e689..86ee9d41 100644 --- a/tests/fixtures/xmlenc/README.md +++ b/tests/fixtures/xmlenc/README.md @@ -4,6 +4,9 @@ These fixtures are tracked so decryption interoperability tests do not depend on network access or a local xmlsec1 checkout. They were imported from the `xmlsec_1_3_12` tag of [lsh123/xmlsec](https://github.com/lsh123/xmlsec/tree/xmlsec_1_3_12/tests). +The pinned 1.3.13 development snapshot at commit +`5fdd47dc35753438bdc38b6e96c1a3805c67a483` contains no changes to these +fixture bytes; reciprocal CLI tests run against that newer snapshot. Imported donor artifacts are kept byte-for-byte, including upstream wording and spelling. Repository-specific clarifications belong in this wrapper rather @@ -57,8 +60,9 @@ algorithms, Diffie-Hellman agreement, or deliberately malformed metadata. ## Importing Vectors -Point the repository helper at an xmlsec1 1.3.12 test checkout and pass paths -under the destination corpus. A directory argument imports its complete tree: +Point the repository helper at the pinned xmlsec1 1.3.13 development checkout +and pass paths under the destination corpus. A directory argument imports its +complete tree: ```sh XMLSEC_DONOR_ROOT=/path/to/xmlsec/tests \ diff --git a/tests/fixtures_smoke.rs b/tests/fixtures_smoke.rs index a95a2d00..a6163838 100644 --- a/tests/fixtures_smoke.rs +++ b/tests/fixtures_smoke.rs @@ -178,7 +178,7 @@ fn fixture_file_count_matches_expected() { let expected = [ ("keys", 24), ("c14n", 41), - ("xmldsig", 81), + ("xmldsig", 127), ("saml", 2), ("xmlenc", 482), ]; @@ -193,6 +193,41 @@ fn fixture_file_count_matches_expected() { } } +#[test] +fn merlin_xmldsig_snapshot_contains_complete_interop_inputs() { + // These files cover the distinct detached, HMAC, key-resolution, and CRL paths. + let required = [ + "xmldsig/merlin-xmldsig-twenty-three/signature.xml", + "xmldsig/merlin-xmldsig-twenty-three/signature-external-dsa.xml", + "xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1-80.xml", + "xmldsig/merlin-xmldsig-twenty-three/signature-retrievalmethod-rawx509crt.xml", + "xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt-crl.xml", + "xmldsig/merlin-xmldsig-twenty-three/certs/ca.pem", + "xmldsig/merlin-xmldsig-twenty-three/certs/balor.der", + "xmldsig/external-data/xml-stylesheet-2005", + "xmldsig/external-data/xml-stylesheet-2005.b64", + ]; + + for relative_path in required { + let path = fixtures_dir().join(relative_path); + assert!(path.is_file(), "missing Merlin fixture: {}", path.display()); + } +} + +#[test] +fn merlin_xmldsig_snapshot_normalizes_non_fixture_donor_artifacts() { + // The importer removes stale donor prose and gives the historical `-40` + // vector a local name matching its actual XMLDSig-compliant 80-bit output. + let dir = fixtures_dir().join("xmldsig/merlin-xmldsig-twenty-three"); + assert!(!dir.join("Readme.txt").exists()); + assert!(!dir.join("signature-enveloping-hmac-sha1-40.xml").exists()); + assert!(!dir.join("signature-enveloping-hmac-sha1-40.tmpl").exists()); + + let fixture = fs::read_to_string(dir.join("signature-enveloping-hmac-sha1-80.xml")) + .expect("normalized Merlin HMAC fixture must be readable"); + assert!(fixture.contains("80")); +} + // ─── Helpers ──────────────────────────────────────────────────────────────── /// Assert that a file exists and contains the expected PEM header marker. diff --git a/tests/install_xmlsec1.rs b/tests/install_xmlsec1.rs new file mode 100644 index 00000000..1f81d2ae --- /dev/null +++ b/tests/install_xmlsec1.rs @@ -0,0 +1,222 @@ +#![cfg(unix)] + +//! Integration coverage for the pinned xmlsec1 installation workflow. + +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +struct TestDirectory(PathBuf); + +impl TestDirectory { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "xml-sec-install-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock must follow the Unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(&path).expect("temporary test directory must be creatable"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + + fn tool(&self, name: &str, source: &str) { + let path = self.path().join("tools").join(name); + std::fs::write(&path, source).expect("fake tool must be writable"); + let mut permissions = std::fs::metadata(&path) + .expect("fake tool metadata must be readable") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions).expect("fake tool must be executable"); + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).expect("temporary test directory must be removable"); + } +} + +struct InstallHarness { + root: TestDirectory, + tools: PathBuf, + prefix: PathBuf, +} + +impl InstallHarness { + fn new() -> Self { + Self::with_previous_install(true) + } + + fn without_previous_install() -> Self { + Self::with_previous_install(false) + } + + fn with_previous_install(has_previous_install: bool) -> Self { + let root = TestDirectory::new(); + let tools = root.path().join("tools"); + let prefix = root.path().join("xmlsec-prefix"); + + if has_previous_install { + std::fs::create_dir_all(prefix.join("bin")) + .expect("old installation must be creatable"); + std::fs::write(prefix.join("sentinel"), "previous installation") + .expect("old installation sentinel must be writable"); + } + std::fs::create_dir_all(&tools).expect("fake tool directory must be creatable"); + + root.tool( + "git", + "#!/bin/sh\nif [ \"$1\" = \"init\" ]; then mkdir -p \"$2\"; exit 0; fi\n[ \"$1\" = \"-C\" ] || exit 2\nsource=$2\nshift 2\ncommand=$1\nshift\ncase \"$command\" in\n remote) exit 0 ;;\n fetch)\n for argument in \"$@\"; do requested=$argument; done\n printf '%s\\n' \"${GIT_REPORTED_COMMIT:-$requested}\" > \"$GIT_FETCHED_COMMIT_FILE\"\n printf '#!/bin/sh\\nexit 0\\n' > \"$source/autogen.sh\"\n chmod +x \"$source/autogen.sh\"\n ;;\n rev-parse) cat \"$GIT_FETCHED_COMMIT_FILE\" ;;\n checkout) exit 0 ;;\n *) exit 2 ;;\nesac\n", + ); + root.tool("nproc", "#!/bin/sh\nprintf '1\\n'\n"); + root.tool( + "make", + "#!/bin/sh\nfor arg in \"$@\"; do\n case \"$arg\" in DESTDIR=*) dest=${arg#DESTDIR=} ;; esac\ndone\nif [ -n \"${dest:-}\" ]; then\n mkdir -p \"$dest$XMLSEC1_PREFIX/bin\"\n printf '#!/bin/sh\\nprintf \"%%s\\\\n\" \"${XMLSEC1_SMOKE_OUTPUT-xmlsec1 1.3.13 (openssl)}\"\\nexit \"${XMLSEC1_SMOKE_EXIT:-0}\"\\n' > \"$dest$XMLSEC1_PREFIX/bin/xmlsec1\"\n chmod +x \"$dest$XMLSEC1_PREFIX/bin/xmlsec1\"\nfi\n", + ); + root.tool( + "mv", + "#!/bin/sh\ncount=0\n[ ! -f \"$MV_COUNT_FILE\" ] || count=$(cat \"$MV_COUNT_FILE\")\ncount=$((count + 1))\nprintf '%s\\n' \"$count\" > \"$MV_COUNT_FILE\"\n[ \"${MV_FAIL_ON:-0}\" -ne \"$count\" ] || exit 23\nexec /bin/mv \"$@\"\n", + ); + + Self { + root, + tools, + prefix, + } + } + + fn run( + &self, + mv_fail_on: Option, + reported_commit: Option<&str>, + smoke_exit: Option, + smoke_output: Option<&str>, + ) -> std::process::ExitStatus { + let inherited_path = std::env::var_os("PATH").expect("test process must have PATH"); + let path = std::env::join_paths( + std::iter::once(self.tools.clone()).chain(std::env::split_paths(&inherited_path)), + ) + .expect("test PATH must be joinable"); + let mut command = Command::new("bash"); + command + .arg("scripts/install-xmlsec1.sh") + .env("XMLSEC1_PREFIX", &self.prefix) + .env( + "GIT_FETCHED_COMMIT_FILE", + self.root.path().join("fetched-commit"), + ) + .env("MV_COUNT_FILE", self.root.path().join("mv-count")) + .env("PATH", path); + if let Some(mv_fail_on) = mv_fail_on { + command.env("MV_FAIL_ON", mv_fail_on.to_string()); + } + if let Some(reported_commit) = reported_commit { + command.env("GIT_REPORTED_COMMIT", reported_commit); + } + if let Some(smoke_exit) = smoke_exit { + command.env("XMLSEC1_SMOKE_EXIT", smoke_exit.to_string()); + } + if let Some(smoke_output) = smoke_output { + command.env("XMLSEC1_SMOKE_OUTPUT", smoke_output); + } + command.status().expect("installation script must run") + } +} + +#[test] +fn failed_install_replacement_restores_previous_xmlsec() { + // The staged directory move is the commit point. A failure there must + // leave the previously working installation intact rather than letting + // EXIT cleanup delete its backup. + let harness = InstallHarness::new(); + let status = harness.run(Some(2), None, None, None); + + assert!( + !status.success(), + "injected staged move failure must propagate" + ); + assert_eq!( + std::fs::read_to_string(harness.prefix.join("sentinel")) + .expect("previous installation must be restored"), + "previous installation" + ); +} + +#[test] +fn installer_rejects_source_revision_mismatch() { + // Artifact compression is not source identity. The installer must reject + // a fetch whose resolved Git object differs from the pinned commit. + let harness = InstallHarness::new(); + let status = harness.run( + None, + Some("0000000000000000000000000000000000000000"), + None, + None, + ); + + assert!( + !status.success(), + "mismatched source revision must fail closed" + ); + assert_eq!( + std::fs::read_to_string(harness.prefix.join("sentinel")) + .expect("failed source verification must preserve the previous installation"), + "previous installation" + ); +} + +#[test] +fn failed_first_install_removes_promoted_prefix() { + // A failed smoke test must not leave an executable plus source marker that + // a later invocation could mistake for a validated installation. + let harness = InstallHarness::without_previous_install(); + let status = harness.run(None, None, Some(17), None); + + assert!(!status.success(), "injected smoke failure must propagate"); + assert!( + !harness.prefix.exists(), + "failed first installation must remove its promoted prefix" + ); +} + +#[test] +fn malformed_version_output_restores_previous_installation() { + // Exit status alone is not source identity: a successful binary with an + // unexpected version must not replace the previously validated install. + let harness = InstallHarness::new(); + for output in ["", "xmlsec1", "xmlsec1 1.3.12", "other 1.3.13"] { + let status = harness.run(None, None, None, Some(output)); + + assert!( + !status.success(), + "unexpected version output {output:?} must fail closed" + ); + assert_eq!( + std::fs::read_to_string(harness.prefix.join("sentinel")) + .expect("version mismatch must restore the previous installation"), + "previous installation" + ); + assert!(!harness.prefix.join(".xmlsec-source-commit").exists()); + } +} + +#[test] +fn exact_version_output_commits_the_new_installation() { + let harness = InstallHarness::new(); + let status = harness.run(None, None, None, Some("xmlsec1 1.3.13 (openssl)")); + + assert!(status.success(), "the pinned version must pass validation"); + assert!(!harness.prefix.join("sentinel").exists()); + assert_eq!( + std::fs::read_to_string(harness.prefix.join(".xmlsec-source-commit")) + .expect("successful validation must write the source marker"), + "5fdd47dc35753438bdc38b6e96c1a3805c67a483\n" + ); +} diff --git a/tests/merlin_interop.rs b/tests/merlin_interop.rs new file mode 100644 index 00000000..cd737100 --- /dev/null +++ b/tests/merlin_interop.rs @@ -0,0 +1,545 @@ +//! End-to-end coverage for the upstream Merlin XMLDSig interoperability corpus. + +use std::{ + collections::HashMap, + path::PathBuf, + time::{Duration, SystemTime}, +}; + +use x509_parser::prelude::{FromDer, X509Certificate}; +use xml_sec::policy::KeyTrustPolicy; +use xml_sec::xmldsig::{ + DefaultKeyResolver, DsigError, DsigStatus, FailureReason, HmacSha1VerificationKey, + KeyResolutionError, KeyResolverConfig, ParseError, SignatureAlgorithm, UriTypeSet, + VerificationKey, VerifyContext, X509ChainError, XPathHereSemantics, +}; + +const MERLIN: &str = "tests/fixtures/xmldsig/merlin-xmldsig-twenty-three"; +const DONOR_EXTERNAL: &str = "tests/fixtures/xmldsig/external-data"; +const VERIFY_2005: u64 = 1_104_580_800; + +fn chain_policy(check_crls: bool) -> KeyTrustPolicy { + KeyTrustPolicy { + verify_x509_chains: true, + check_crls, + verification_time: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(VERIFY_2005)), + ..KeyTrustPolicy::default() + } +} + +fn root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn bytes(path: &str) -> Vec { + std::fs::read(root().join(path)).unwrap_or_else(|error| panic!("read {path}: {error}")) +} + +fn xml(name: &str) -> String { + String::from_utf8(bytes(&format!("{MERLIN}/{name}.xml"))).expect("fixture is UTF-8") +} + +fn cert(name: &str) -> Vec { + let path = format!("{MERLIN}/certs/{name}"); + let data = bytes(&path); + if name.ends_with(".der") { + return data; + } + let (rest, pem) = x509_parser::pem::parse_x509_pem(&data).expect("certificate PEM"); + assert!(rest.iter().all(u8::is_ascii_whitespace)); + pem.contents +} + +fn verification_key(name: &str, algorithm: SignatureAlgorithm) -> VerificationKey { + let der = cert(name); + let (rest, certificate) = X509Certificate::from_der(&der).expect("certificate DER"); + assert!(rest.is_empty()); + VerificationKey { + algorithm, + public_key_bytes: certificate.public_key().raw.to_vec(), + certificate_der: Some(der), + name: None, + } +} + +fn external_resources() -> HashMap> { + HashMap::from([ + ( + "http://www.w3.org/TR/xml-stylesheet".into(), + bytes(&format!("{DONOR_EXTERNAL}/xml-stylesheet-2005")), + ), + ( + "http://www.w3.org/Signature/2002/04/xml-stylesheet.b64".into(), + bytes(&format!("{DONOR_EXTERNAL}/xml-stylesheet-2005.b64")), + ), + ( + "tests/merlin-xmldsig-twenty-three/certs/balor.der".into(), + cert("balor.der"), + ), + ]) +} + +fn assert_valid( + name: &str, + result: Result, +) { + let result = result.unwrap_or_else(|error| panic!("{name}: {error}")); + assert_eq!(result.status, DsigStatus::Valid, "{name}"); + assert!( + result + .signed_info_references + .iter() + .all(|reference| reference.status == DsigStatus::Valid), + "{name}: SignedInfo reference failure" + ); +} + +#[test] +fn verifies_all_merlin_documents_with_upstream_expectations() { + // Every signed document used by xmlsec's Merlin runner is classified here. + let default = DefaultKeyResolver::default(); + for name in [ + "signature-enveloped-dsa", + "signature-enveloping-dsa", + "signature-enveloping-b64-dsa", + ] { + assert_valid( + name, + VerifyContext::new() + .key_resolver(&default) + .verify(&xml(name)), + ); + } + let legacy_rsa = DefaultKeyResolver::default(); + let mut legacy_policy = xml_sec::policy::VerificationPolicy::default(); + legacy_policy.key_trust.allow_legacy_rsa_sha1 = true; + assert_valid( + "signature-enveloping-rsa", + VerifyContext::new() + .policy(legacy_policy) + .key_resolver(&legacy_rsa) + .verify(&xml("signature-enveloping-rsa")), + ); + + let hmac = HmacSha1VerificationKey::new(b"secret".to_vec()).expect("valid HMAC key"); + assert_valid( + "signature-enveloping-hmac-sha1", + VerifyContext::new() + .key(&hmac) + .verify(&xml("signature-enveloping-hmac-sha1")), + ); + let truncated_hmac = HmacSha1VerificationKey::new(b"secret".to_vec()) + .expect("valid HMAC key") + .with_output_length_bits(80) + .expect("valid XMLDSig truncation"); + assert_valid( + "signature-enveloping-hmac-sha1-80", + VerifyContext::new() + .key(&truncated_hmac) + .verify(&xml("signature-enveloping-hmac-sha1-80")), + ); + + let resources = external_resources(); + for name in ["signature-external-dsa", "signature-external-b64-dsa"] { + assert_valid( + name, + VerifyContext::new() + .key_resolver(&default) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&xml(name)), + ); + } + + let mut named = KeyResolverConfig::default(); + named.named_keys.insert( + "Lugh".into(), + verification_key("lugh-cert.pem", SignatureAlgorithm::DsaSha1), + ); + let named = DefaultKeyResolver::new(named); + assert_valid( + "signature-keyname", + VerifyContext::new() + .key_resolver(&named) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&xml("signature-keyname")), + ); + + for (name, selected) in [ + ("signature-x509-crt", None), + ("signature-x509-sn", Some("badb.pem")), + ("signature-x509-is", Some("macha.pem")), + ("signature-x509-ski", Some("nemain.pem")), + ] { + let lookup_certs = selected.into_iter().map(cert).collect(); + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + lookup_certs, + trusted_certs: vec![cert("ca.pem")], + trust: chain_policy(false), + ..KeyResolverConfig::default() + }); + assert_valid( + name, + VerifyContext::new() + .key_resolver(&resolver) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&xml(name)), + ); + } + + let retrieval = DefaultKeyResolver::new(KeyResolverConfig { + lookup_certs: vec![cert("balor.pem")], + trusted_certs: vec![cert("ca.pem")], + trust: chain_policy(false), + ..KeyResolverConfig::default() + }); + assert_valid( + "signature-retrievalmethod-rawx509crt", + VerifyContext::new() + .key_resolver(&retrieval) + .allowed_uri_types(UriTypeSet::ALL) + .allowed_retrieval_method_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&xml("signature-retrievalmethod-rawx509crt")), + ); + + // The upstream runner's newer detached resource also mismatches the old + // digest. Use the signed 2005 bytes and a certificate-valid timestamp so + // this assertion reaches and proves the embedded CRL decision itself. + let revoked_resources = external_resources(); + let revoked = DefaultKeyResolver::new(KeyResolverConfig { + trusted_certs: vec![cert("ca.pem")], + trust: chain_policy(true), + ..KeyResolverConfig::default() + }); + let revoked_error = VerifyContext::new() + .key_resolver(&revoked) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&revoked_resources) + .verify(&xml("signature-x509-crt-crl")) + .expect_err("the donor CRL revokes the signing certificate"); + // Merlin's CA restricts KeyUsage to keyCertSign, so RFC 5280 requires + // rejecting its CRL before trusting the listed revoked serial. Dedicated + // chain tests cover the Revoked result for an authorized cRLSign issuer. + assert!( + matches!( + revoked_error, + DsigError::KeyResolution(KeyResolutionError::Chain(X509ChainError::InvalidKeyUsage { + position: 1, + required: "cRLSign" + })) + ), + "unexpected revoked-vector error: {revoked_error:?}" + ); + + let complex = DefaultKeyResolver::new(KeyResolverConfig { + trusted_certs: vec![cert("merlin.pem")], + trust: KeyTrustPolicy { + verification_time: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(VERIFY_2005)), + ..KeyTrustPolicy::default() + }, + ..KeyResolverConfig::default() + }); + let result = VerifyContext::new() + .key_resolver(&complex) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .process_manifests(true) + .store_pre_digest(true) + .allow_internal_dtd(true) + .xpath_here_semantics(XPathHereSemantics::XmlSecLegacy) + .verify(&xml("signature")) + .expect("complex signature pipeline"); + assert_eq!(result.status, DsigStatus::Valid); + let expected_signed_info_uris = [ + "http://www.w3.org/TR/xml-stylesheet", + "http://www.w3.org/Signature/2002/04/xml-stylesheet.b64", + "#object-1", + "", + "#object-2", + "#manifest-1", + "#signature-properties-1", + "", + "", + "#xpointer(/)", + "#xpointer(/)", + "#object-3", + "#object-3", + "#xpointer(id('object-3'))", + "#xpointer(id('object-3'))", + "#reference-2", + "#manifest-reference-1", + "#reference-1", + ]; + assert_eq!( + result.signed_info_references.len(), + expected_signed_info_uris.len() + ); + for (reference, expected_uri) in result + .signed_info_references + .iter() + .zip(expected_signed_info_uris) + { + assert_eq!(reference.uri, expected_uri); + assert_eq!(reference.status, DsigStatus::Valid, "{expected_uri}"); + } + + let expected_manifest = [ + ("http://www.w3.org/TR/xml-stylesheet", DsigStatus::Valid), + ("#reference-1", DsigStatus::Valid), + ( + "#notaries", + // The donor uses an XSLT transform, which this pure-Rust profile + // intentionally does not execute; failure occurs before digest comparison. + DsigStatus::Invalid(FailureReason::ReferenceProcessingFailure { ref_index: 2 }), + ), + ]; + assert_eq!(result.manifest_references.len(), expected_manifest.len()); + for (reference, (expected_uri, expected_status)) in + result.manifest_references.iter().zip(expected_manifest) + { + assert_eq!(reference.uri, expected_uri); + assert_eq!(reference.status, expected_status, "{expected_uri}"); + } +} + +#[test] +fn rejects_missing_or_tampered_external_resources() { + // Detached references cannot trigger I/O and must fail on absent or altered caller bytes. + let default = DefaultKeyResolver::default(); + let document = xml("signature-external-dsa"); + let missing = HashMap::new(); + assert!( + VerifyContext::new() + .key_resolver(&default) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&missing) + .verify(&document) + .is_err() + ); + + let mut tampered = external_resources(); + tampered.insert( + "http://www.w3.org/TR/xml-stylesheet".into(), + b"tampered".to_vec(), + ); + let result = VerifyContext::new() + .key_resolver(&default) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&tampered) + .verify(&document) + .expect("tampering is a validation result"); + assert_ne!(result.status, DsigStatus::Valid); +} + +#[test] +fn bounds_external_resources_before_dereference() { + // Resource limits are enforced for the complete caller map, not only the referenced entry. + let default = DefaultKeyResolver::default(); + let mut oversized = external_resources(); + oversized.insert("urn:oversized".into(), vec![0; 8 * 1024 * 1024 + 1]); + assert!(matches!( + VerifyContext::new() + .key_resolver(&default) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&oversized) + .verify(&xml("signature-external-dsa")), + Err(DsigError::Policy( + xml_sec::policy::PolicyViolation::ResourceLimit { + resource: "external resource bytes", + .. + } + )) + )); + + let mut aggregate = external_resources(); + aggregate + .extend((0..5).map(|index| (format!("urn:aggregate:{index}"), vec![0; 7 * 1024 * 1024]))); + assert!(matches!( + VerifyContext::new() + .key_resolver(&default) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&aggregate) + .verify(&xml("signature-external-dsa")), + Err(DsigError::Policy( + xml_sec::policy::PolicyViolation::ResourceLimit { + resource: "aggregate external resource bytes", + .. + } + )) + )); +} + +#[test] +fn rejects_wrong_hmac_key_and_invalid_output_length() { + // MAC mismatch is an invalid status; malformed truncation is a processing error. + let wrong = HmacSha1VerificationKey::new(b"wrong".to_vec()).expect("valid HMAC key"); + let result = VerifyContext::new() + .key(&wrong) + .verify(&xml("signature-enveloping-hmac-sha1")) + .expect("wrong MAC is a validation result"); + assert_ne!(result.status, DsigStatus::Valid); + + let malformed = xml("signature-enveloping-hmac-sha1-80").replacen( + "80", + "72", + 1, + ); + assert!(malformed.contains("72")); + assert!(VerifyContext::new().key(&wrong).verify(&malformed).is_err()); + + let implicit_full_length = xml("signature-enveloping-hmac-sha1-80").replacen( + "80", + "", + 1, + ); + assert!( + VerifyContext::new() + .key(&wrong) + .verify(&implicit_full_length) + .is_err() + ); +} + +#[test] +fn rejects_malformed_dsa_key_value() { + // Invalid CryptoBinary input must be rejected before DSA key construction. + let malformed = xml("signature-enveloped-dsa").replacen("cfYpihpAQeep", "!!!!ihpAQeep", 1); + assert!(malformed.contains("!!!!ihpAQeep")); + assert!( + VerifyContext::new() + .key_resolver(&DefaultKeyResolver::default()) + .verify(&malformed) + .is_err() + ); +} + +#[test] +fn partial_dsa_key_value_falls_back_to_later_complete_key() { + // XMLDSig permits Y-only DSAKeyValue sources; an unusable first source must + // not prevent a later complete DSAKeyValue from verifying the signature. + let document = xml("signature-enveloped-dsa").replacen( + "\n ", + "\n AQ==\n ", + 1, + ); + assert!(document.contains("AQ==")); + + assert_valid( + "partial DSAKeyValue fallback", + VerifyContext::new() + .key_resolver(&DefaultKeyResolver::default()) + .verify(&document), + ); +} + +#[test] +fn rejects_missing_ambiguous_and_weak_key_resolution() { + // KeyName, RetrievalMethod IDs, and legacy RSA policy each fail closed. + let resources = external_resources(); + let missing = VerifyContext::new() + .key_resolver(&DefaultKeyResolver::default()) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&xml("signature-keyname")); + assert!(matches!( + missing, + Ok(result) if result.status == DsigStatus::Invalid(FailureReason::KeyNotFound) + )); + + let ambiguous = xml("signature").replacen( + "", + "", + 1, + ); + let ambiguous_error = VerifyContext::new() + .key_resolver(&DefaultKeyResolver::default()) + .allow_internal_dtd(true) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&ambiguous) + .expect_err("duplicate ID must fail before key resolution"); + assert!( + matches!( + ambiguous_error, + DsigError::InvalidStructure { + reason: "X509Data RetrievalMethod target is missing or ambiguous" + } + ), + "unexpected duplicate-ID error: {ambiguous_error:?}" + ); + + let weak = VerifyContext::new() + .key_resolver(&DefaultKeyResolver::default()) + .verify(&xml("signature-enveloping-rsa")); + assert!(matches!( + weak, + Err(DsigError::Policy( + xml_sec::policy::PolicyViolation::Algorithm { + operation: "verification", + .. + } + )) + )); +} + +#[test] +fn rejects_dtd_and_unsupported_retrieval_defaults() { + // Internal DTD parsing and RetrievalMethod transform compatibility require exact opt-ins. + assert!(matches!( + VerifyContext::new() + .key_resolver(&DefaultKeyResolver::default()) + .verify(&xml("signature")), + Err(DsigError::XmlParse(_)) + )); + + let unsupported = xml("signature").replacen( + "ancestor-or-self::dsig:X509Data", + "descendant-or-self::dsig:X509Data", + 1, + ); + let resources = external_resources(); + let unsupported_error = VerifyContext::new() + .key_resolver(&DefaultKeyResolver::default()) + .allow_internal_dtd(true) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&unsupported) + .expect_err("unsupported RetrievalMethod XPath must fail closed"); + assert!(matches!( + unsupported_error, + DsigError::ParseKeyInfo(ParseError::InvalidStructure(reason)) + if reason == "unsupported RetrievalMethod XPath selection" + )); + + let retrieval = DefaultKeyResolver::new(KeyResolverConfig { + lookup_certs: vec![cert("balor.pem")], + trusted_certs: vec![cert("ca.pem")], + trust: chain_policy(false), + ..KeyResolverConfig::default() + }); + let reference_error = VerifyContext::new() + .key_resolver(&retrieval) + .external_resources(&resources) + .verify(&xml("signature-retrievalmethod-rawx509crt")) + .expect_err("external SignedInfo reference must require an explicit opt-in"); + assert!(matches!( + reference_error, + DsigError::DisallowedUri { uri } + if uri == "http://www.w3.org/TR/xml-stylesheet" + )); + + let retrieval_error = VerifyContext::new() + .key_resolver(&retrieval) + .allowed_uri_types(UriTypeSet::ALL) + .external_resources(&resources) + .verify(&xml("signature-retrievalmethod-rawx509crt")) + .expect_err("external key retrieval must require its own explicit opt-in"); + assert!(matches!( + retrieval_error, + DsigError::DisallowedUri { uri } + if uri == "tests/merlin-xmldsig-twenty-three/certs/balor.der" + )); +} diff --git a/tests/signing_digest.rs b/tests/signing_digest.rs index 218c5c31..b81ee672 100644 --- a/tests/signing_digest.rs +++ b/tests/signing_digest.rs @@ -1,4 +1,7 @@ +use std::collections::HashSet; + use xml_sec::c14n::{C14nAlgorithm, C14nMode}; +use xml_sec::policy::SigningPolicy; use xml_sec::xmldsig::mutation::append_signature_to_root; use xml_sec::xmldsig::parse::{find_signature_node, parse_signed_info}; use xml_sec::xmldsig::uri::UriReferenceResolver; @@ -268,6 +271,66 @@ fn computes_enveloped_signature_digest_for_whole_document() { assert_reference_digests_verify(&filled); } +#[test] +fn signing_policy_rejects_disallowed_reference_transform() { + // A signing policy is an execution boundary, not advisory metadata: every + // template transform must be accepted before any digest work runs. + let private_key = + RsaSigningKey::from_pkcs8_pem(&read_fixture("tests/fixtures/keys/rsa/rsa-2048-key.pem")) + .expect("RSA private key fixture must parse"); + let template = template_with_reference( + ReferenceBuilder::new(DigestAlgorithm::Sha256) + .uri("") + .transform(Transform::Enveloped), + ); + let xml = + append_signature_to_root("", &template).expect("append signature"); + let policy = SigningPolicy { + transforms: Some(HashSet::from([exclusive_c14n().uri().to_owned()])), + ..SigningPolicy::default() + }; + + assert!(matches!( + SignContext::new(&private_key) + .policy(policy) + .sign_template(&xml), + Err(SigningError::Digest(SigningDigestError::Policy(_))) + )); +} + +#[test] +fn signing_policy_shares_canonicalization_budget_with_signed_info() { + // Reference transforms and SignedInfo consume one operation-wide C14N + // allowance, preventing a template from multiplying the configured cap. + let private_key = + RsaSigningKey::from_pkcs8_pem(&read_fixture("tests/fixtures/keys/rsa/rsa-2048-key.pem")) + .expect("RSA private key fixture must parse"); + let template = template_with_reference( + ReferenceBuilder::new(DigestAlgorithm::Sha256) + .uri("#payload") + .transform(Transform::C14n(exclusive_c14n())), + ); + let xml = append_signature_to_root( + "canonicalized bytes", + &template, + ) + .expect("append signature"); + let policy = SigningPolicy { + resources: xml_sec::policy::ResourcePolicy { + max_canonicalized_bytes: 32, + ..xml_sec::policy::ResourcePolicy::default() + }, + ..SigningPolicy::default() + }; + + assert!(matches!( + SignContext::new(&private_key) + .policy(policy) + .sign_template(&xml), + Err(SigningError::Digest(SigningDigestError::Transform(_))) + )); +} + #[test] fn fills_only_signed_info_reference_digest_values() { // Manifests can contain their own DigestValue elements inside the same diff --git a/tests/uri_integration.rs b/tests/uri_integration.rs index eeff8a96..18211f0b 100644 --- a/tests/uri_integration.rs +++ b/tests/uri_integration.rs @@ -106,14 +106,11 @@ fn fragment_id_canonicalizes_subtree_only() { } #[test] -fn fragment_id_includes_comments_in_subtree() { - // Unlike empty URI, #id subtrees include comments +fn fragment_id_excludes_comments_in_subtree() { + // XMLDSig bare-name dereference strips comments even when C14N retains them. let xml = r#""#; let result = deref_and_canonicalize_with_comments(xml, "#x"); - assert_eq!( - result, - r#""# - ); + assert_eq!(result, r#""#); } #[test] diff --git a/tests/xmlenc_encrypt_xmlsec1.rs b/tests/xmlenc_encrypt_xmlsec1.rs index 86c5da34..4ae169d7 100644 --- a/tests/xmlenc_encrypt_xmlsec1.rs +++ b/tests/xmlenc_encrypt_xmlsec1.rs @@ -5,11 +5,13 @@ use std::{ fs, path::{Path, PathBuf}, - process::Command, sync::atomic::{AtomicU64, Ordering}, time::{SystemTime, UNIX_EPOCH}, }; +#[path = "common/xmlsec1.rs"] +mod xmlsec1; + use rsa::{RsaPublicKey, pkcs8::DecodePublicKey}; use xml_sec::xmlenc::{ DataEncryptionAlgorithm, EncryptedDataBuilder, EncryptionRecipient, OaepDigestAlgorithm, @@ -51,32 +53,10 @@ impl Drop for TemporaryFile { } } -fn xmlsec1_version_supports_interop(version: &str) -> bool { - version - .split_whitespace() - .find_map(|token| { - let mut components = token.split('.'); - Some(( - components.next()?.parse::().ok()?, - components.next()?.parse::().ok()?, - components.next()?.parse::().ok()?, - )) - }) - .is_some_and(|version| version >= (1, 3, 8)) -} - -fn xmlsec1_is_available() -> bool { - let Ok(output) = Command::new("xmlsec1").arg("--version").output() else { - return false; - }; - output.status.success() - && std::str::from_utf8(&output.stdout).is_ok_and(xmlsec1_version_supports_interop) -} - fn decrypt_with_xmlsec1(encrypted_xml: &str, key_option: &str, key_path: &Path) -> Vec { let input = TemporaryFile::write("xmlenc-input", "xml", encrypted_xml.as_bytes()); let output = TemporaryFile::path("xmlenc-output", "data"); - let command_output = Command::new("xmlsec1") + let command_output = xmlsec1::command() .arg("decrypt") .arg("--lax-key-search") .arg(key_option) @@ -98,17 +78,20 @@ fn decrypt_with_xmlsec1(encrypted_xml: &str, key_option: &str, key_path: &Path) #[test] fn xmlsec1_version_gate_accepts_ci_version() { - assert!(!xmlsec1_version_supports_interop("xmlsec1 1.3.7 (openssl)")); - assert!(xmlsec1_version_supports_interop("xmlsec1 1.3.8 (openssl)")); - assert!(xmlsec1_version_supports_interop("xmlsec1 1.3.12 (openssl)")); + assert!(!xmlsec1::version_supports_interop( + "xmlsec1 1.3.12 (openssl)" + )); + assert!(xmlsec1::version_supports_interop( + "xmlsec1 1.3.13 (openssl)" + )); } #[test] fn xmlsec1_decrypts_direct_aes_gcm_from_xml_sec() { // This validates nonce/tag framing and direct KeyName XML against an // independent implementation rather than our reciprocal decrypt path. - if !xmlsec1_is_available() { - eprintln!("skipping XMLEnc interop: xmlsec1 >= 1.3.8 is not installed"); + if !xmlsec1::is_available() { + eprintln!("skipping XMLEnc interop: xmlsec1 >= 1.3.13 is not installed"); return; } let key = [0x4a; 16]; @@ -134,8 +117,8 @@ fn xmlsec1_decrypts_direct_aes_gcm_from_xml_sec() { fn xmlsec1_decrypts_rsa_oaep_wrapped_aes_cbc_from_xml_sec() { // This covers generated session-key transport, OAEP digest/MGF metadata, // nested EncryptedKey lookup, and XMLEnc CBC random-padding framing. - if !xmlsec1_is_available() { - eprintln!("skipping XMLEnc interop: xmlsec1 >= 1.3.8 is not installed"); + if !xmlsec1::is_available() { + eprintln!("skipping XMLEnc interop: xmlsec1 >= 1.3.13 is not installed"); return; } let public_key_path = Path::new("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"); diff --git a/tests/xmlsec1_interop.rs b/tests/xmlsec1_interop.rs index d059138b..a4524710 100644 --- a/tests/xmlsec1_interop.rs +++ b/tests/xmlsec1_interop.rs @@ -2,10 +2,12 @@ use std::fs; use std::path::{Path, PathBuf}; -use std::process::Command; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +#[path = "common/xmlsec1.rs"] +mod xmlsec1; + use xml_sec::c14n::{C14nAlgorithm, C14nMode}; use xml_sec::xmldsig::{ DefaultKeyResolver, DigestAlgorithm, DsigStatus, EcdsaP256SigningKey, EcdsaP384SigningKey, @@ -118,41 +120,34 @@ fn encoded_payload_xml(id_attribute: &str) -> String { ) } -// `--add-id-attr`, used by the reciprocal interop helpers below, was added in 1.3.8. -fn xmlsec1_version_supports_interop(version: &str) -> bool { - version - .split_whitespace() - .find_map(|token| { - let mut components = token.split('.'); - Some(( - components.next()?.parse::().ok()?, - components.next()?.parse::().ok()?, - components.next()?.parse::().ok()?, - )) - }) - .is_some_and(|version| version >= (1, 3, 8)) -} - -fn xmlsec1_is_available() -> bool { - let Ok(output) = Command::new("xmlsec1").arg("--version").output() else { - return false; - }; - - output.status.success() - && std::str::from_utf8(&output.stdout).is_ok_and(xmlsec1_version_supports_interop) -} - #[test] -fn xmlsec1_version_gate_requires_add_id_attr_support() { - assert!(!xmlsec1_version_supports_interop("xmlsec1 1.3.0 (openssl)")); - assert!(!xmlsec1_version_supports_interop("xmlsec1 1.3.7 (openssl)")); - assert!(xmlsec1_version_supports_interop("xmlsec1 1.3.8 (openssl)")); - assert!(xmlsec1_version_supports_interop("xmlsec1 1.3.12 (openssl)")); - assert!(xmlsec1_version_supports_interop("xmlsec1 2.0.0 (openssl)")); - assert!(!xmlsec1_version_supports_interop( +fn xmlsec1_version_gate_requires_pinned_snapshot() { + assert!(!xmlsec1::version_supports_interop( + "xmlsec1 1.3.8 (openssl)" + )); + assert!(!xmlsec1::version_supports_interop( + "xmlsec1 1.3.12 (openssl)" + )); + assert!(xmlsec1::version_supports_interop( + "xmlsec1 1.3.13 (openssl)" + )); + assert!(xmlsec1::version_supports_interop("xmlsec1 2.0.0 (openssl)")); + assert!(!xmlsec1::version_supports_interop( "xmlsec1 1.2.37 (openssl)" )); - assert!(!xmlsec1_version_supports_interop("xmlsec1 unknown")); + assert!(!xmlsec1::version_supports_interop("xmlsec1 unknown")); + for malformed in [ + "OpenSSL 3.0.0", + "xmlsec1 unknown OpenSSL 3.0.0", + "xmlsec1 1.3", + "xmlsec1 1.3.13.1", + "prefix xmlsec1 1.3.13", + ] { + assert!( + !xmlsec1::version_supports_interop(malformed), + "malformed xmlsec1 version output {malformed:?} must fail closed" + ); + } } fn signed_payload_xml(key: &dyn SigningKey, builder: &SignatureBuilder) -> String { @@ -184,7 +179,7 @@ fn interop_fixture_references_the_enveloped_root() { fn verify_with_xmlsec1(signed_xml: &str, public_key: &Path) -> std::process::Output { let input = TemporaryXmlFile::write("xmlsec1-interop", signed_xml); - Command::new("xmlsec1") + xmlsec1::command() .arg("--verify") .arg("--lax-key-search") .arg("--add-id-attr") @@ -204,7 +199,7 @@ fn sign_with_xmlsec1( ) -> String { let output_file = TemporaryXmlFile::write("xmlsec1-signed", ""); let key_and_certificate = format!("{},{}", private_key.display(), certificate.display()); - let output = Command::new("xmlsec1") + let output = xmlsec1::command() .arg("--sign") .arg("--add-id-attr") .arg("Id") @@ -243,7 +238,7 @@ fn assert_xmlsec1_accepts(signed_xml: &str, public_key: &str) { fn xmlsec1_verifies_rsa_sha256_signature_from_xml_sec() { // A separate implementation must accept the generated enveloped signature, // including its reference digest, exclusive C14N, and RSA SignatureValue. - if !xmlsec1_is_available() { + if !xmlsec1::is_available() { eprintln!("skipping xmlsec1 interoperability test: xmlsec1 is not installed"); return; } @@ -266,7 +261,7 @@ fn xmlsec1_verifies_rsa_sha256_signature_from_xml_sec() { fn xmlsec1_verifies_base64_reference_signature_from_xml_sec() { // The donor implementation must derive the same decoded octets from a // node set containing nested elements and comments. - if !xmlsec1_is_available() { + if !xmlsec1::is_available() { eprintln!("skipping xmlsec1 interoperability test: xmlsec1 is not installed"); return; } @@ -287,7 +282,7 @@ fn xmlsec1_verifies_base64_reference_signature_from_xml_sec() { fn xml_sec_verifies_base64_reference_signature_from_xmlsec1() { // Reciprocal generation proves our parser and text-node conversion accept // the transform representation emitted and digested by xmlsec1. - if !xmlsec1_is_available() { + if !xmlsec1::is_available() { eprintln!("skipping xmlsec1 interoperability test: xmlsec1 is not installed"); return; } @@ -328,7 +323,7 @@ fn xml_sec_verifies_base64_reference_signature_from_xmlsec1() { fn xmlsec1_verifies_xpath_filter2_signature_from_xml_sec() { // xmlsec1 must derive the same subtree set after ordered intersect and // subtract operations and accept our resulting RSA signature. - if !xmlsec1_is_available() { + if !xmlsec1::is_available() { eprintln!("skipping xmlsec1 interoperability test: xmlsec1 is not installed"); return; } @@ -351,7 +346,7 @@ fn xmlsec1_verifies_xpath_filter2_signature_from_xml_sec() { fn xml_sec_verifies_xpath_filter2_signature_from_xmlsec1() { // Reciprocal signing proves the parser and evaluator accept Filter 2.0 XML // and digest octets produced independently by xmlsec1. - if !xmlsec1_is_available() { + if !xmlsec1::is_available() { eprintln!("skipping xmlsec1 interoperability test: xmlsec1 is not installed"); return; } @@ -387,7 +382,7 @@ fn xmlsec1_verifies_selected_axes_without_their_owner_from_xml_sec() { // Canonical XML serializes selected attribute and namespace nodes even // when their owner element is absent, producing valid digest octets that // are intentionally not a well-balanced XML fragment. - if !xmlsec1_is_available() { + if !xmlsec1::is_available() { eprintln!("skipping xmlsec1 interoperability test: xmlsec1 is not installed"); return; } @@ -410,7 +405,7 @@ fn xmlsec1_verifies_selected_axes_without_their_owner_from_xml_sec() { fn xml_sec_verifies_selected_axes_without_their_owner_from_xmlsec1() { // Reciprocal signing proves xmlsec1 independently canonicalizes the same // esoteric node-set to the octets consumed by xml-sec. - if !xmlsec1_is_available() { + if !xmlsec1::is_available() { eprintln!("skipping xmlsec1 interoperability test: xmlsec1 is not installed"); return; } @@ -445,7 +440,7 @@ fn xml_sec_verifies_selected_axes_without_their_owner_from_xmlsec1() { fn xmlsec1_verifies_ecdsa_signatures_from_xml_sec() { // P-256 and P-384 prove that xml-sec emits XMLDSig raw r||s values that // xmlsec1 accepts for both supported ECDSA curve widths. - if !xmlsec1_is_available() { + if !xmlsec1::is_available() { eprintln!("skipping xmlsec1 interoperability test: xmlsec1 is not installed"); return; } @@ -484,7 +479,7 @@ fn xmlsec1_verifies_ecdsa_signatures_from_xml_sec() { fn xmlsec1_rejects_tampered_signature_from_xml_sec() { // The external verifier must reject a changed signed payload, proving the // test is exercising validation rather than merely command invocation. - if !xmlsec1_is_available() { + if !xmlsec1::is_available() { eprintln!("skipping xmlsec1 interoperability test: xmlsec1 is not installed"); return; } @@ -516,7 +511,7 @@ fn xmlsec1_rejects_tampered_signature_from_xml_sec() { fn xml_sec_verifies_xmlsec1_signatures_with_embedded_certificates() { // xmlsec1 must create signatures that our full pipeline accepts through // the embedded X509Data resolver, not through a separately injected key. - if !xmlsec1_is_available() { + if !xmlsec1::is_available() { eprintln!("skipping xmlsec1 interoperability test: xmlsec1 is not installed"); return; } @@ -561,7 +556,7 @@ fn xml_sec_verifies_xmlsec1_signatures_with_embedded_certificates() { fn xml_sec_rejects_tampered_xmlsec1_signature_before_crypto_verification() { // Mutating the signed Object must fail reference validation before the // verifier reaches SignatureValue cryptography, matching XMLDSig fail-fast. - if !xmlsec1_is_available() { + if !xmlsec1::is_available() { eprintln!("skipping xmlsec1 interoperability test: xmlsec1 is not installed"); return; }