Valkuilen bij XML-handtekeningvalidatie in Peppol
Peppol SMP-reacties zijn ondertekende XML-documenten. Het valideren van die handtekeningen zou eenvoudig moeten zijn: parseer het document, zoek het Signature-element, verifieer de referentiedigest en de handtekeningwaarde. In de praktijk mislukt het vaker dan nodig, en de foutmodus is subtiel genoeg om je er dagenlang mee bezig te houden.
De oorzaak is bijna altijd Canonical XML (C14N) die interageert met de afhandeling van naamruimteprefixes. Dit artikel legt precies uit wat er misgaat en hoe je het oplost.
Waarom Canonical XML bestaat
Een XML-document kan op meerdere manieren worden geserialiseerd die semantisch identiek maar byte-voor-byte anders zijn. Attributen kunnen in elke volgorde voorkomen. Naamruimtedeclaraties kunnen op elk voorouderelement staan. Witruimte in elementinhoud kan varieren. Al deze wijzigingen produceren een andere bytereeks en daarmee een andere hash.
Canonical XML (C14N, W3C-standaard) definieert een normalisatiealgoritme dat elk semantisch equivalent XML-document omzet in een enkele canonieke vorm. XMLDSig gebruikt C14N om een stabiele hash over een document of een deel ervan te berekenen.
Als je een digest berekent over gecanonicaliseerde XML en iemand het document herserializeert voor je verifieert, kan de hergeserialiseerde versie een andere canonieke vorm produceren, een andere hash en een verificatiefout, ook al is het document semantisch onveranderd.
Het Peppol SMP-handtekeningprobleem
Wanneer een Access Point een SMP-reactie ondertekent, berekent het:
- Canonicaliseer het
ServiceInformation-element (of het hele document, afhankelijk van de referentie-URI) - Bereken SHA-256 over de canonieke bytes
- Sla de digest op in
Reference/DigestValue - Onderteken het
SignedInfo-element met de prive-sleutel van het Access Point
Wanneer je de SMP-reactie via HTTP ophaalt, kunnen er verschillende dingen zijn gebeurd since het ondertekenen:
- De SMP-server heeft het document mogelijk uit een database geladen en hergeserialiseerd
- De HTTP-laag heeft de XML mogelijk opnieuw geparseerd en uitgevoerd
- Het SOAP/HTTP-framework heeft mogelijk witruimte genormaliseerd
Al deze dingen kunnen de canonieke vorm wijzigen die je berekent tijdens verificatie. Zelfs als het digestalgoritme en de referentie-URI correct zijn, zijn de bytes die je canonicaliseert anders dan de bytes die de ondertekenaar heeft gecanonicaliseerd.
Naamruimteprefix-verplaatsing
De meest voorkomende oorzaak is naamruimteprefix-verplaatsing.
Een SMP-server kan een document ondertekenen dat naamruimtedeclaraties heeft op kinderelementen:
<ServiceMetadata>
<ServiceInformation xmlns:bdxr="http://docs.oasis-open.org/bdxr/ns/SMP/2016/05">
...
</ServiceInformation>
<Signature>...</Signature>
</ServiceMetadata>
Wanneer C14N wordt toegepast op ServiceInformation, is de naamruimtedeclaratie opgenomen in de canonieke vorm omdat ze van toepassing is op dat element.
Wanneer de SMP-server herserializeert voor bezorging, verplaatsen veel XML-bibliotheken naamruimtedeclaraties naar het rootelement:
<ServiceMetadata xmlns:bdxr="http://docs.oasis-open.org/bdxr/ns/SMP/2016/05">
<ServiceInformation>
...
</ServiceInformation>
<Signature>...</Signature>
</ServiceMetadata>
Nu wanneer je C14N toepast op ServiceInformation, is de naamruimte gedeclareerd op de root, niet op ServiceInformation. Onder Exclusive C14N (http://www.w3.org/2001/10/xml-exc-c14n#) worden naamruimtedeclaraties die niet zichtbaar worden gebruikt op het element dat wordt gecanonicaliseerd, uitgesloten. De naamruimte staat niet meer op ServiceInformation; ze staat op ServiceMetadata. De C14N-uitvoer verandert. De digest komt niet meer overeen.
Dynamische naamruimteprefixes (ns1, ns2, …)
Een tweede klasse problemen: sommige SMP-implementaties gebruiken dynamisch gegenereerde naamruimteprefixes. In plaats van xmlns:bdxr geven ze xmlns:ns1, xmlns:ns2 enzovoort uit. Deze prefixes worden toegewezen bij serializatietijd en kunnen verschillen tussen de ondertekeningspass en de bezorgingspass.
Onder Inclusive C14N zijn naamruimteprefixbindingen opgenomen in de canonieke vorm. Als de prefix verandert van bdxr: naar ns1:, verandert de canonieke vorm en mislukt de digest.
Onder Exclusive C14N worden naamruimteprefixbindingen voor prefixes die niet worden gebruikt in het gecanonicaliseerde element uitgesloten. Maar als de prefix zelf verandert (de binding zelf is anders), veranderen de canonieke bytes voor elementen die die prefix gebruiken.
De oplossing is naamruimteprefixes normaliseren voor verificatie. Dit vereist het opnieuw lezen van het ondertekende deelschema met bekende, stabiele prefixbindingen. Het is niet mogelijk dit te doen en ook de originele handtekening te verifiëren; je moet verifiëren aan de hand van wat werkelijk is ondertekend, wat betekent dat je met de ruwe bytes moet werken die zijn ondertekend.
Het .NET SignedXml-probleem
De SignedXml-klasse van .NET heeft een specifieke beperking: het lost verwijzingen op relatief aan het document in het geheugen. Als je de SMP-reactie laadt in een XmlDocument, kunnen naamruimtedeclaraties worden verplaatst door de XML-parser voordat SignedXml ze ooit ziet.
De oplossing in .NET:
var doc = new XmlDocument
{
PreserveWhitespace = true // cruciaal
};
doc.Load(responseStream); // laad van ruwe bytes, niet van een hergeserialiseerde string
PreserveWhitespace = true verhindert dat de XML-parser tekstknopen normaliseert. Belangrijker nog: het gebruik van Load() vanuit de ruwe stroom (in plaats van vanuit een string die al was geparseerd en opnieuw uitgezonden) vermijdt de herserializatiepass die naamruimtedeclaraties verplaatst.
Doe dit niet:
var xml = await response.Content.ReadAsStringAsync();
doc.LoadXml(xml); // string-parsing kan al hebben hergeserialiseerd
Doe dit in plaats daarvan:
using var stream = await response.Content.ReadAsStreamAsync();
doc.Load(stream); // ruwe bytes, geen tussenliggende string
Verifieer vervolgens met SignedXml:
var signedXml = new SignedXml(doc);
var sigElement = doc.GetElementsByTagName("Signature")[0] as XmlElement
?? throw new InvalidOperationException("Geen Signature-element gevonden");
signedXml.LoadXml(sigElement);
var cert = GetCertificateFromKeyInfo(signedXml);
if (!signedXml.CheckSignature(cert, verifySignatureOnly: false))
throw new SecurityException("SMP-reactiehandtekeningverificatie mislukt");
verifySignatureOnly: false betekent dat .NET ook de referentiedigests valideert, niet alleen de handtekening over SignedInfo. Je wilt beide controles.
Certificaatvertrouwensketen
Verifiëren dat de handtekening wiskundig geldig is, is niet voldoende. Je moet ook verifiëren dat het ondertekeningscertificaat is uitgegeven door het Peppol PKI-vertrouwensanker.
OpenPEPPOL publiceert een trust store (SML-certificaat, SMP-certificaatwortels) in de productie- en test-PKI. Laad de trust store en verifieer de certificaatketen:
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);
}
Een SMP-reactie met een geldige handtekening van een niet-vertrouwd certificaat is een aanval, geen configuratiefout. Weiger hard.
Afhandeling van referentie-URI
Peppol SMP-handtekeningen gebruiken doorgaans een lege referentie-URI (URI=""), wat betekent dat de referentie het gehele document dekt. Onder C14N betekent dit dat het hele document wordt gecanonicaliseerd.
Als de referentie-URI een XPointer-expressie is (URI="#ID"), dekt de referentie alleen het element met dat ID. Dit is robuuster tegen naamruimteverplaatsing omdat je alleen een deelschema canonicaliseert, niet het hele document.
Als een handtekening mislukt en je de SMP-server kunt beheren, geef de voorkeur aan XPointer-verwijzingen boven de lege URI. Als je de server niet kunt beheren, werk met ruwe bytes.
Testbenadering
De betrouwbaarste manier om SMP-handtekeningverificatie te testen is tegen een bekende-goede SMP-reactie opgeslagen als ruwe bytes van een productie- of test-Peppol-deelnemer. Stel geen testreacties programmatisch samen; de naamruimteafhandeling zal afwijken van wat echte SMP-servers produceren.
Het Peppol-TESTnetwerk (acc.edelivery.tech.ec.europa.eu SML) heeft echte deelnemers die je kunt opzoeken en echte ondertekende SMP-reacties van kunt ophalen. Gebruik deze voor integratietests.
Samenvatting
De foutmodi, op volgorde van frequentie:
- Naamruimteverplaatsing: XML-parser verplaatst naamruimtedeclaraties bij laden naar root, waardoor de C14N-uitvoer voor het ondertekende deelschema verandert
- Herserializatie: de HTTP-reactie omzetten naar een string en terug voor verificatie
- Dynamische naamruimteprefixes: SMP-server gebruikt andere prefixes bij ondertekening versus bezorging
- Ontbrekende
PreserveWhitespace: .NET-XML-parser normaliseert witruimte, waardoor tekstknoop-hashes veranderen - Certificaatketen niet geverifieerd: handtekening is geldig maar certificaat is niet van Peppol PKI
Al deze zijn oplosbaar zonder de SMP-server te patchen. Laad vanuit ruwe bytes, stel PreserveWhitespace = true in en valideer altijd de certificaatvertrouwensketen.
SealDoc verwerkt SMP-discovery en handtekeningverificatie intern, inclusief alle bovenstaande randgevallen. Als je een aangepaste Peppol-integratie bouwt en handtekeningfouten tegenkomt die je niet kunt debuggen, biedt de SealDoc-API SMP-opzoekresultaten inclusief handtekeninggeldigheidssstatus, die je kunt vergelijken met de uitvoer van je eigen implementatie.