← Back to all articles

XML Signature validation pitfalls in Peppol

SealDoc Team · · 6 min read

Peppol SMP responses are signed XML documents. Validating those signatures should be straightforward: parse the document, locate the Signature element, verify the reference digest and the signature value. In practice, it fails more often than it should, and the failure mode is subtle enough that you might spend days on it.

The root cause is almost always Canonical XML (C14N) interacting with namespace prefix handling. This article explains exactly what goes wrong and how to fix it.

Why Canonical XML exists

An XML document can be serialized in multiple ways that are semantically identical but byte-for-byte different. Attributes can appear in any order. Namespace declarations can appear on any ancestor element. Whitespace in element content can vary. Any of these changes produce a different byte string and therefore a different hash.

Canonical XML (C14N, W3C standard) defines a normalization algorithm that transforms any semantically equivalent XML document into a single canonical form. XMLDSig uses C14N to compute a stable hash over a document or a portion of it.

If you compute a digest over canonicalized XML, and then someone re-serializes the document before you verify, the re-serialized version may produce a different canonical form, a different hash, and a verification failure, even though the document is semantically unchanged.

The Peppol SMP signature problem

When an Access Point signs an SMP response, it computes:

  1. Canonicalize the ServiceInformation element (or the entire document, depending on the reference URI)
  2. Compute SHA-256 over the canonical bytes
  3. Store the digest in Reference/DigestValue
  4. Sign the SignedInfo element with the Access Point’s private key

When you retrieve the SMP response over HTTP, several things may have happened since signing:

  • The SMP server may have loaded the document from a database and re-serialized it
  • The HTTP layer may have re-parsed and re-output the XML
  • The SOAP/HTTP framework may have normalized whitespace

Any of these can change the canonical form that you compute during verification. Even if the digest algorithm and the reference URI are correct, the bytes you canonicalize are different from the bytes the signer canonicalized.

Namespace prefix promotion

The most common cause is namespace prefix promotion.

An SMP server may sign a document that has namespace declarations on child elements:

<ServiceMetadata>
  <ServiceInformation xmlns:bdxr="http://docs.oasis-open.org/bdxr/ns/SMP/2016/05">
    ...
  </ServiceInformation>
  <Signature>...</Signature>
</ServiceMetadata>

When C14N is applied to ServiceInformation, the namespace declaration is included in the canonical form because it is in scope on that element.

When the SMP server re-serializes for delivery, many XML libraries move namespace declarations to the root element:

<ServiceMetadata xmlns:bdxr="http://docs.oasis-open.org/bdxr/ns/SMP/2016/05">
  <ServiceInformation>
    ...
  </ServiceInformation>
  <Signature>...</Signature>
</ServiceMetadata>

Now when you apply C14N to ServiceInformation, the namespace is declared on the root, not on ServiceInformation. Under Exclusive C14N (http://www.w3.org/2001/10/xml-exc-c14n#), namespace declarations that are not visibly utilized on the element being canonicalized are excluded. The namespace is not on ServiceInformation anymore; it is on ServiceMetadata. The C14N output changes. The digest no longer matches.

Dynamic namespace prefixes (ns1, ns2, …)

A second class of problem: some SMP implementations use dynamically generated namespace prefixes. Instead of xmlns:bdxr, they emit xmlns:ns1, xmlns:ns2, and so on. These prefixes are assigned at serialization time, and they may differ between the signing pass and the delivery pass.

Under Inclusive C14N, namespace prefix bindings are included in the canonical form. If the prefix changes from bdxr: to ns1:, the canonical form changes, and the digest fails.

Under Exclusive C14N, namespace prefix bindings for prefixes not used in the element being canonicalized are excluded. But if the prefix itself changes (the binding itself is different), the canonical bytes for elements that use that prefix change.

The fix is to normalize namespace prefixes before verifying. This requires re-reading the signed subtree with known, stable prefix bindings. It is not possible to do this and also verify the original signature; you have to verify against what was actually signed, which means you need to work with the raw bytes that were signed.

The .NET SignedXml problem

.NET’s SignedXml class has a specific limitation: it resolves references relative to the document in memory. If you load the SMP response into an XmlDocument, namespace declarations may be moved by the XML parser before SignedXml ever sees them.

The fix in .NET:

var doc = new XmlDocument
{
    PreserveWhitespace = true  // critical
};
doc.Load(responseStream);  // load from raw bytes, not from a re-serialized string

PreserveWhitespace = true prevents the XML parser from normalizing text nodes. More importantly, using Load() from the raw stream (rather than from a string that was already parsed and re-emitted) avoids the re-serialization pass that promotes namespace declarations.

Do not do this:

var xml = await response.Content.ReadAsStringAsync();
doc.LoadXml(xml);  // string parsing may already have re-serialized

Do this instead:

using var stream = await response.Content.ReadAsStreamAsync();
doc.Load(stream);  // raw bytes, no intermediate string

Then verify with SignedXml:

var signedXml = new SignedXml(doc);
var sigElement = doc.GetElementsByTagName("Signature")[0] as XmlElement
    ?? throw new InvalidOperationException("No Signature element found");
signedXml.LoadXml(sigElement);

// Resolve the signing certificate from the SMP KeyInfo
var cert = GetCertificateFromKeyInfo(signedXml);

if (!signedXml.CheckSignature(cert, verifySignatureOnly: false))
    throw new SecurityException("SMP response signature verification failed");

verifySignatureOnly: false means .NET also validates the reference digests, not just the signature over SignedInfo. You want both checks.

Certificate trust chain

Verifying that the signature is mathematically valid is not enough. You also need to verify that the signing certificate is issued by the Peppol PKI trust anchor.

OpenPEPPOL publishes a trust store (SML certificate, SMP certificate roots) in the production and test PKI. Load the trust store and verify the certificate chain:

static bool IsInPeppolTrustChain(X509Certificate2 cert)
{
    var chain = new X509Chain();
    chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
    chain.ChainPolicy.CustomTrustStore.Add(peppolRootCert);
    chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
    return chain.Build(cert);
}

An SMP response with a valid signature from an untrusted certificate is an attack, not a misconfiguration. Fail hard.

Reference URI handling

Peppol SMP signatures typically use an empty reference URI (URI=""), which means the reference covers the entire document. Under C14N, that means the entire document is canonicalized.

If the reference URI is an XPointer expression (URI="#ID"), the reference covers only the element with that ID. This is more resilient to namespace promotion because you only canonicalize a subtree, not the whole document.

If a signature is failing and you can control the SMP server, prefer XPointer references over the empty URI. If you cannot control the server, work with raw bytes.

Testing approach

The most reliable way to test SMP signature verification is against a known-good SMP response saved as raw bytes from a production or test Peppol participant. Do not construct test responses programmatically; the namespace handling will differ from what real SMP servers produce.

The Peppol TEST network (acc.edelivery.tech.ec.europa.eu SML) has real participants you can look up and retrieve real signed SMP responses from. Use these for integration tests.

Summary

The failure modes, in order of frequency:

  1. Namespace promotion: XML parser moves namespace declarations to root on load, changing the C14N output for the signed subtree
  2. Re-serialization: converting the HTTP response to a string and back before verification
  3. Dynamic namespace prefixes: SMP server uses different prefixes on signing vs. delivery
  4. Missing PreserveWhitespace: .NET XML parser normalizes whitespace, changing text node hashes
  5. Certificate chain not verified: signature is valid but certificate is not from Peppol PKI

All of these are fixable without patching the SMP server. Load from raw bytes, set PreserveWhitespace = true, and always validate the certificate trust chain.

SealDoc handles SMP discovery and signature verification internally, including all of the above edge cases. If you are building a custom Peppol integration and hitting signature failures that you cannot debug, the SealDoc API exposes SMP lookup results including signature validity status, which you can compare against your own implementation’s output.


← Back to all articles