0012. Verify XML signatures against a trust anchor#
Status: Accepted
Date: 2026-11-09
Deciders: Sebastien Rousseau (release 0.0.83, decision D2)
Context#
verify_xml_signature(signed_xml) with no key or certificate verified
with the certificate inside the document’s KeyInfo. Anyone can make a
key and a certificate, sign a payment file, and embed that certificate,
so the call proved the file was unchanged since signing but not who
signed it, while its name and return value (True) read as the
latter.
Verification also disagreed with other implementations. It read the
document through ElementTree and canonicalised with C14N 2.0
(ElementTree.canonicalize), while the signature declares exclusive
C14N 1.0. A signature signxml 5.2.0 made over a bundled sample, keeping
the document’s default namespace, failed with a digest mismatch; so did
any signature whose Signature element is followed by whitespace, since
removing it dropped that text. It found the Signature by a substring
match on the tag.
The signer has its own form of the same problem: it rewrites the
document through ElementTree (a default namespace comes out as ns0:)
and digests that with C14N 2.0. Its output does verify with signxml,
because ElementTree only writes the namespaces it uses, but it is not
the document the caller passed in.
The README’s stability policy makes a change to generated XML or to an API’s contract breaking, announced one release ahead.
Options considered#
Require a trust anchor in 0.0.83, as the implementation plan had it. Closes the gap at once, but breaks callers without the notice the policy promises.
Add the anchored API in 0.0.83, keep the old call working with a
DeprecationWarning, and refuse it in 0.0.84.Require the anchor only in the new API and leave the old call trusting the embedded certificate until it is removed.
For the signer: change its output in 0.0.83 as an exception to the policy, change it in 0.0.84 after announcing it, or add an opt-in now.
Decision#
Option 2, and for the signer, announce in 0.0.83 and change in 0.0.84 (the maintainer’s choices on 2026-11-09).
0.0.83 adds verify_signature(signed_xml, trust_anchor=..., trust_embedded_cert=...), which returns a SignatureReport and needs
one of the two: a trust anchor (the signer’s certificate or public
key), or an explicit trust_embedded_cert=True that the report
records as trust="embedded". pain001 verify-signature has the same
rule (--cert or --trust-embedded-cert). verify_xml_signature is a
wrapper over it; without a key it still trusts the embedded
certificate, with a DeprecationWarning naming 0.0.84.
Verification parses once with lxml (DTDs and entities refused, as in
pain001 check), finds exactly one ds:Signature by namespace, and
digests the parsed tree with exclusive C14N 1.0, honouring an
InclusiveNamespaces prefix list and keeping the text after the
Signature when the enveloped transform removes it. It accepts only
allow-listed methods: exclusive C14N; exactly one URI="" reference with the
enveloped transform, optionally then exclusive C14N; SHA-256, SHA-384
or SHA-512 digests; RSA-SHA256, RSA-PSS (RFC 9231 sha256-rsa-MGF1),
and ECDSA-SHA256 and ECDSA-SHA384 with the raw r || s value XML-DSig
1.1 requires.
The signer gains those algorithms, chosen by the key or by
algorithm= (--xml-sign-alg). Its output for an RSA key and the
default algorithm is byte-identical to 0.0.82 across every bundled
coverage document; 0.0.84 moves it to the parsed tree and exclusive
C14N, as the CHANGELOG’s ### Announced section says.
Consequences#
The
signextra installslxmlwithcryptography.scripts/dsig_interop.pyruns in CI (theXML-DSig interopjob): pain001 and signxml, and xmlsec1, sign and verify each other’s signatures for every algorithm, so the canonical forms cannot drift apart again unnoticed.tests/test_dsig_verify.pybuilds signatures to the specification without pain001’s signer, including the cases 0.0.82 rejected.Certificate chains and validity dates are still not checked; the trust anchor is the certificate the caller already trusts.
Placing the signature in the Business Application Header (
AppHdr/Sgntr) moves to 0.0.84 with the signer change, since it is the same rewrite of the signer.0.0.84:
verify_xml_signaturewithout a key raises; the signer keeps the caller’s document and prefixes.
Implementation in 0.0.84#
The accepted second stage is implemented: the boolean wrapper requires
an explicit public key or certificate, and signing uses exclusive C14N
1.0 over the parsed document and SignedInfo. Input namespace prefixes are
preserved. A supported head.001.001.03 header carries the signature in
AppHdr/Sgntr; the container exists before the payload digest is taken.
An existing signature, ambiguous headers or an occupied container are
refused. Plain documents retain a root-level enveloped signature.
The standalone canonicalize_xml C14N 2.0 utility keeps its existing
contract and is no longer used by signing. Tests cover namespace
preservation, explicit trust, header placement, malformed input and all
supported signing algorithms. The interoperability script checks the
result against signxml and xmlsec1. The canonicalization and enveloped
transform rules follow the W3C XML Signature specification.