XML-Signatur-Validierungsfallen in Peppol
Peppol-SMP-Antworten sind signierte XML-Dokumente. Die Validierung dieser Signaturen sollte unkompliziert sein: Dokument parsen, das Signature-Element lokalisieren, den Referenz-Digest und den Signaturwert verifizieren. In der Praxis schlägt es öfter fehl als es sollte, und der Fehlermodus ist subtil genug, dass man tagelang damit beschäftigt sein kann.
Die Grundursache ist fast immer Canonical XML (C14N), das mit der Namespace-Präfix-Verarbeitung interagiert. Dieser Artikel erklärt genau, was schiefgeht und wie es behoben werden kann.
Warum Canonical XML existiert
Ein XML-Dokument kann auf mehrere Weisen serialisiert werden, die semantisch identisch, aber byte-für-byte verschieden sind. Attribute können in beliebiger Reihenfolge erscheinen. Namespace-Deklarationen können auf jedem Vorfahren-Element erscheinen. Leerzeichen im Elementinhalt kann variieren. Jede dieser Änderungen produziert eine andere Byte-Zeichenkette und damit einen anderen Hash.
Canonical XML (C14N, W3C-Standard) definiert einen Normalisierungsalgorithmus, der jedes semantisch äquivalente XML-Dokument in eine einzige kanonische Form umwandelt. XMLDSig verwendet C14N, um einen stabilen Hash über ein Dokument oder einen Teil davon zu berechnen.
Wenn Sie einen Digest über kanonisiertes XML berechnen und dann jemand das Dokument vor Ihrer Verifizierung re-serialisiert, kann die re-serialisierte Version eine andere kanonische Form, einen anderen Hash und einen Verifizierungsfehler erzeugen, obwohl das Dokument semantisch unverändert ist.
Das Peppol-SMP-Signaturproblem
Wenn ein Access Point eine SMP-Antwort signiert, berechnet er:
- Das
ServiceInformation-Element kanonisieren (oder das gesamte Dokument, abhängig von der Referenz-URI) - SHA-256 über die kanonischen Bytes berechnen
- Den Digest in
Reference/DigestValuespeichern - Das
SignedInfo-Element mit dem privaten Schlüssel des Access Points signieren
Wenn Sie die SMP-Antwort über HTTP abrufen, können seit der Signierung mehrere Dinge passiert sein:
- Der SMP-Server hat das Dokument möglicherweise aus einer Datenbank geladen und re-serialisiert
- Die HTTP-Schicht hat das XML möglicherweise erneut geparst und ausgegeben
- Das SOAP/HTTP-Framework hat möglicherweise Leerzeichen normalisiert
Jedes davon kann die kanonische Form verändern, die Sie bei der Verifizierung berechnen. Selbst wenn der Digest-Algorithmus und die Referenz-URI korrekt sind, sind die Bytes, die Sie kanonisieren, anders als die Bytes, die der Unterzeichner kanonisiert hat.
Namespace-Präfix-Promotion
Die häufigste Ursache ist Namespace-Präfix-Promotion.
Ein SMP-Server kann ein Dokument signieren, das Namespace-Deklarationen auf Kind-Elementen hat:
<ServiceMetadata>
<ServiceInformation xmlns:bdxr="http://docs.oasis-open.org/bdxr/ns/SMP/2016/05">
...
</ServiceInformation>
<Signature>...</Signature>
</ServiceMetadata>
Wenn C14N auf ServiceInformation angewendet wird, wird die Namespace-Deklaration in der kanonischen Form berücksichtigt, weil sie auf diesem Element gültig ist.
Wenn der SMP-Server zur Auslieferung re-serialisiert, verschieben viele XML-Bibliotheken Namespace-Deklarationen zum Root-Element:
<ServiceMetadata xmlns:bdxr="http://docs.oasis-open.org/bdxr/ns/SMP/2016/05">
<ServiceInformation>
...
</ServiceInformation>
<Signature>...</Signature>
</ServiceMetadata>
Wenn Sie jetzt C14N auf ServiceInformation anwenden, ist der Namespace auf dem Root deklariert, nicht auf ServiceInformation. Unter Exclusive C14N (http://www.w3.org/2001/10/xml-exc-c14n#) werden Namespace-Deklarationen, die auf dem kanonisierten Element nicht sichtbar genutzt werden, ausgeschlossen. Der Namespace steht nicht mehr auf ServiceInformation; er steht auf ServiceMetadata. Die C14N-Ausgabe ändert sich. Der Digest stimmt nicht mehr überein.
Dynamische Namespace-Präfixe (ns1, ns2, …)
Eine zweite Problemklasse: Manche SMP-Implementierungen verwenden dynamisch generierte Namespace-Präfixe. Anstatt xmlns:bdxr verwenden sie xmlns:ns1, xmlns:ns2 und so weiter. Diese Präfixe werden zum Serialisierungszeitpunkt zugewiesen, und sie können sich zwischen dem Signier- und dem Auslieferungsdurchlauf unterscheiden.
Unter Inclusive C14N werden Namespace-Präfix-Bindungen in der kanonischen Form berücksichtigt. Wenn sich das Präfix von bdxr: zu ns1: ändert, ändert sich die kanonische Form, und der Digest schlägt fehl.
Unter Exclusive C14N werden Namespace-Präfix-Bindungen für Präfixe, die im kanonisierten Element nicht verwendet werden, ausgeschlossen. Aber wenn sich das Präfix selbst ändert (die Bindung selbst ist anders), ändern sich die kanonischen Bytes für Elemente, die dieses Präfix verwenden.
Die Lösung ist, Namespace-Präfixe vor der Verifizierung zu normalisieren. Das erfordert das erneute Lesen des signierten Teilbaums mit bekannten, stabilen Präfix-Bindungen. Es ist nicht möglich, dies zu tun und auch die ursprüngliche Signatur zu verifizieren; Sie müssen gegen das verifizieren, was tatsächlich signiert wurde, was bedeutet, dass Sie mit den Roh-Bytes arbeiten müssen, die signiert wurden.
Das .NET-SignedXml-Problem
Die .NET-Klasse SignedXml hat eine spezifische Einschränkung: Sie löst Referenzen relativ zum im Speicher befindlichen Dokument auf. Wenn Sie die SMP-Antwort in ein XmlDocument laden, können Namespace-Deklarationen durch den XML-Parser verschoben werden, bevor SignedXml sie überhaupt sieht.
Die Lösung in .NET:
var doc = new XmlDocument
{
PreserveWhitespace = true // critical
};
doc.Load(responseStream); // load from raw bytes, not from a re-serialized string
PreserveWhitespace = true verhindert, dass der XML-Parser Textknoten normalisiert. Wichtiger noch: Die Verwendung von Load() aus dem Roh-Stream (anstatt aus einer Zeichenkette, die bereits geparst und erneut ausgegeben wurde) vermeidet den Re-Serialisierungsdurchlauf, der Namespace-Deklarationen promotet.
Nicht so vorgehen:
var xml = await response.Content.ReadAsStringAsync();
doc.LoadXml(xml); // string parsing may already have re-serialized
Stattdessen so vorgehen:
using var stream = await response.Content.ReadAsStreamAsync();
doc.Load(stream); // raw bytes, no intermediate string
Dann mit SignedXml verifizieren:
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 bedeutet, dass .NET auch die Referenz-Digests validiert, nicht nur die Signatur über SignedInfo. Beide Prüfungen sind erforderlich.
Zertifikats-Vertrauenskette
Die mathematische Gültigkeit der Signatur zu verifizieren, reicht nicht aus. Sie müssen auch verifizieren, dass das Signierzertifikat vom Peppol-PKI-Vertrauensanker ausgestellt wurde.
OpenPEPPOL veröffentlicht einen Vertrauensspeicher (SML-Zertifikat, SMP-Zertifikat-Roots) in der Produktions- und Test-PKI. Den Vertrauensspeicher laden und die Zertifikatskette verifizieren:
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);
}
Eine SMP-Antwort mit gültiger Signatur von einem nicht vertrauenswürdigen Zertifikat ist ein Angriff, keine Fehlkonfiguration. Konsequent ablehnen.
Referenz-URI-Verarbeitung
Peppol-SMP-Signaturen verwenden typischerweise eine leere Referenz-URI (URI=""), was bedeutet, dass die Referenz das gesamte Dokument abdeckt. Unter C14N bedeutet das, dass das gesamte Dokument kanonisiert wird.
Wenn die Referenz-URI ein XPointer-Ausdruck ist (URI="#ID"), deckt die Referenz nur das Element mit dieser ID ab. Dies ist resilienter gegenüber Namespace-Promotion, weil Sie nur einen Teilbaum kanonisieren, nicht das gesamte Dokument.
Wenn eine Signatur fehlschlägt und Sie den SMP-Server kontrollieren können, XPointer-Referenzen gegenüber der leeren URI bevorzugen. Wenn Sie den Server nicht kontrollieren können, mit Roh-Bytes arbeiten.
Test-Ansatz
Der zuverlässigste Weg, die SMP-Signaturverifizierung zu testen, ist gegen eine bekannte SMP-Antwort, die als Roh-Bytes von einem Produktions- oder Test-Peppol-Teilnehmer gespeichert wurde. Keine Test-Antworten programmatisch konstruieren; die Namespace-Verarbeitung weicht von dem ab, was echte SMP-Server produzieren.
Das Peppol-TEST-Netzwerk (acc.edelivery.tech.ec.europa.eu SML) hat echte Teilnehmer, die Sie nachschlagen und von denen Sie echte signierte SMP-Antworten abrufen können. Diese für Integrationstests verwenden.
Zusammenfassung
Die Fehlermodi nach Häufigkeit:
- Namespace-Promotion: XML-Parser verschiebt Namespace-Deklarationen beim Laden zur Root, was die C14N-Ausgabe für den signierten Teilbaum ändert
- Re-Serialisierung: HTTP-Antwort in eine Zeichenkette und zurück konvertieren vor der Verifizierung
- Dynamische Namespace-Präfixe: SMP-Server verwendet unterschiedliche Präfixe bei Signierung vs. Auslieferung
- Fehlendes
PreserveWhitespace: .NET-XML-Parser normalisiert Leerzeichen, ändert Textknoten-Hashes - Zertifikatskette nicht verifiziert: Signatur ist gültig, aber Zertifikat stammt nicht aus Peppol-PKI
Alle diese Probleme sind lösbar, ohne den SMP-Server zu patchen. Aus Roh-Bytes laden, PreserveWhitespace = true setzen und immer die Zertifikats-Vertrauenskette validieren.
SealDoc behandelt SMP-Discovery und Signaturverifizierung intern, einschließlich aller oben genannten Randfälle. Wenn Sie eine benutzerdefinierte Peppol-Integration aufbauen und auf Signaturfehler stoßen, die Sie nicht debuggen können, gibt die SealDoc API SMP-Lookup-Ergebnisse einschließlich Signaturvalidierungsstatus zurück, den Sie mit der Ausgabe Ihrer eigenen Implementierung vergleichen können.