Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
164a9bb
feat(xmldsig): complete Merlin interop
polaz Aug 4, 2026
d9a013c
fix(xmldsig): address Merlin review findings
polaz Aug 5, 2026
e0ac4b2
fix(xmldsig): harden retrieval methods
polaz Aug 5, 2026
8fd4a48
fix(xmldsig): harden key retrieval
polaz Aug 5, 2026
155520b
fix(xmldsig): harden key source handling
polaz Aug 5, 2026
06f6749
fix(xmldsig): track xmlsec1 1.3.13
polaz Aug 5, 2026
fbfb5c7
fix(xmldsig): ignore CryptoBinary comments
polaz Aug 5, 2026
2304206
fix(xmldsig): harden external references
polaz Aug 5, 2026
fa4a088
ci: unpin stale cargo-fuzz lockfile
polaz Aug 5, 2026
9f3017b
fix(xmldsig): harden interop boundaries
polaz Aug 5, 2026
f9e1e5f
fix(ci): verify immutable interop inputs
polaz Aug 6, 2026
6905e49
fix(ci): use maintained action refs
polaz Aug 6, 2026
eb1840e
fix(xmldsig): harden interop setup
polaz Aug 6, 2026
3bc02d1
fix(xmldsig): honor matching context
polaz Aug 6, 2026
bcdccc4
fix(xmldsig): preserve resolution context
polaz Aug 6, 2026
3ec3a96
fix(xmldsig): harden resolution edge cases
polaz Aug 6, 2026
88541da
fix(xmldsig): close URI and trust edge cases
polaz Aug 6, 2026
c1d0bd7
fix(xmldsig): harden fallback resolution
polaz Aug 6, 2026
abd05ed
fix(xmldsig): unify detached parse policy
polaz Aug 6, 2026
1a8d77b
fix(xmldsig): enforce URI and X.509 invariants
polaz Aug 6, 2026
9c0cd48
fix(xmldsig): enforce signature-wide limits
polaz Aug 7, 2026
ddb8153
fix(xmldsig): harden bounded verification
polaz Aug 7, 2026
83f35c6
fix(xmldsig): defer malformed retrievals
polaz Aug 7, 2026
7b56c38
fix(security): unify policy and provider
polaz Aug 7, 2026
7cae909
fix(security): enforce policy invariants
polaz Aug 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
45 changes: 32 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 }}
Expand All @@ -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 }}
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
/target
/.tools
/fuzz/artifacts
/fuzz/corpus/*/*
!/fuzz/corpus/xmldsig_verify/signature.xml
/fuzz/target
Cargo.lock
*.swp
*.swo
Expand Down
14 changes: 11 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,38 +28,44 @@ 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"

[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",
Expand All @@ -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",
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
42 changes: 35 additions & 7 deletions docs/xmldsig.md
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Examples

Expand All @@ -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 `<KeyInfo>`.
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
`<KeyInfo>`.

`VerifyResult::status` reports core validation: `Valid` means the cryptographic signature and
every `<SignedInfo>` reference succeeded. `Invalid(reason)` means core validation completed but
Expand All @@ -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.
22 changes: 22 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 = ["."]
1 change: 1 addition & 0 deletions fuzz/corpus/xmldsig_verify/signature.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#"/>
34 changes: 34 additions & 0 deletions fuzz/fuzz_targets/xmldsig_verify.rs
Original file line number Diff line number Diff line change
@@ -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<DefaultKeyResolver> = 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);
});
37 changes: 37 additions & 0 deletions scripts/import-donor-fixtures.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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"
Expand Down Expand Up @@ -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")"
Expand Down
Loading