Ein ECDSA-signatureValue innerhalb eines CMS-Containers ist eine DER-SEQUENCE { INTEGER r, INTEGER s }. Die Windows-CNG-Funktion BCryptVerifySignature akzeptiert keins von beidem: Sie erwartet IEEE P1363 mit fester Breite r || s, ohne Tags und ohne Längenangaben. HotPDF, die native VCL-PDF-Komponente für Delphi und C++Builder, konvertiert zwischen beiden Formen nach strikten DER-Regeln, bevor ein Schlüssel importiert wird
Der dadurch verhinderte Fehler ist ein spezifischer und entmutigender. Acrobat öffnet das Dokument und zeigt ein grünes Häkchen. Der eigene Verifier, der dieselben Bytes durchläuft, liefert ungültig zurück, oder CNG gibt STATUS_INVALID_SIGNATURE ohne weitere Erklärung zurück. An der Signatur ist nichts falsch. Falsch ist, dass rund siebzig Bytes ASN.1 an eine API übergeben wurden, die vierundsechzig Bytes einer rohen Ganzzahl erwartete, und die Diskrepanz ist unsichtbar, solange man nicht weiß, wonach zu suchen ist
Warum weist BCryptVerifySignature eine gültige ECDSA-Signatur zurück?
Weil die beiden Seiten des Aufrufs unterschiedliche Signaturkodierungen sprechen, und keine kündigt das an. ISO 32000-1 §12.8 besagt, dass ein Signatur-Dictionary einen CMS-Blob in /Contents trägt; RFC 5652 §5.3 besagt, dass der signatureValue in jedem SignerInfo ein OCTET STRING ist, dessen Inhalt von dem definiert wird, was der Signaturalgorithmus vorgibt. Für ECDSA ist dieser Inhalt die SEC-1-DER-Struktur: eine SEQUENCE mit zwei INTEGERs. Sie ist absichtlich variabel lang, weil r und s Ganzzahlen sind und DER führende Null-Oktette aus Ganzzahlen entfernt
IEEE P1363 vertritt die gegenteilige Sichtweise. Es definiert die Signatur als Verkettung der beiden Koordinaten, jede links mit Nullen auf exakt die Byte-Breite des Kurvenfelds aufgefüllt. Eine P-256-Signatur ist stets 64 Bytes lang. Eine DER-Kodierung derselben Signatur ist normalerweise 70 oder 71 Bytes lang und kann irgendwo zwischen etwa 8 und 72 liegen. Übergibt man die DER-Form an BCryptVerifySignature, verurteilt allein die Längenprüfung den Aufruf zum Scheitern, weshalb HotPDF vor der Prüfung normalisiert, nicht danach
uses
HPDFECDSA;
// Converts a CMS signatureValue into the fixed-width form CNG expects.
// ARaw comes back as 64 bytes for P-256, 96 for P-384, 132 for P-521.
function ToP1363(const ADerSig: TBytes; ACurve: THPDFECDSACurve;
out ARaw: TBytes): Boolean;
begin
Result := HPDFECDSANormalizeSignature(ADerSig, ACurve, eseDER, ARaw);
end;
Die DER-Regeln, die ein Signaturparser nicht lockern darf
Jede hier aufgeführte Ablehnung ist eine, die HotPDF bewusst vornimmt, und jede schließt einen Weg, den ein nachsichtiger Parser offen ließe. Die Versuchung beim Schreiben eines Konverters ist, die beiden INTEGER-Knoten zu finden, ihren Inhalt zu kopieren und weiterzumachen. Das funktioniert bei wohlgeformter Eingabe und akzeptiert stillschweigend eine ganze Familie formbarer Re-Kodierungen bei feindlicher Eingabe. Also weist HPDFECDSANormalizeSignature eine negative Ganzzahl zurück, das heißt jedes r oder s, dessen erstes Inhaltsoktett das High-Bit gesetzt hat, denn ein gültiger ECDSA-Skalar ist positiv. Es weist einen Wert zurück, der vollständig null ist, da r = 0 oder s = 0 niemals eine legitime Signatur ist. Es weist ein überflüssiges führendes Null-Oktett zurück: X.690 §8.3 erlaubt genau eins, und nur dann, wenn das nächste Oktett andernfalls als negativ gelesen würde, sodass ein 00, gefolgt von einem Oktett unter 0x80, eine Re-Kodierung ist, keine Signatur. Es weist einen nicht-minimalen Längen-Header zurück, denn X.690 §10.1 verlangt die bestimmte Form, kodiert mit den wenigsten Oktetten, und eine Long-Form-Länge, die auch Short-Form hätte sein können, ist ein anderer Byte-String mit derselben Bedeutung. Es weist eine Ganzzahl zurück, die breiter ist als die Koordinatengröße der Kurve, da ein solcher Wert kein Feldelement sein kann. Und es weist jeden nachfolgenden Knoten nach s zurück, ebenso eine äußere SEQUENCE, deren Gesamtlänge nicht der Länge des gesamten Blobs entspricht
Diese letzten beiden sind wichtiger, als sie aussehen. Nachfolgende Bytes nach der SEQUENCE sind der klassische Signatur-Malleability-Trick: Müll anhängen, und ein nachsichtiger Verifier sagt weiterhin gültig, während der von ihm validierte Byte-String nicht der signierte Byte-String ist. Derselbe Instinkt treibt die in der Notiz zum PKCS#12-Parsing beschriebene ASN.1-Längenhärtung, und hier ist es derselbe Instinkt. In einem Verifikationspfad ist eine akzeptierte Struktur, die nie von einem konformen Signierer ausgegeben wurde, ein Defekt, keine Kulanz
Die Koordinatenbreite gehört zur Kurve, nicht zur Signatur
HotPDF leitet die Ausgabebreite aus der benannten Kurven-OID ab, niemals aus der Länge des gerade geparsten DER. Das ist die zweite Hälfte der Konvertierung, und diejenige, die man leicht subtil falsch macht. RFC 5480 §2.1.1 identifiziert die Kurve in den SubjectPublicKeyInfo-Parametern des Zertifikats, und HPDFECDSACurveFromOID bildet die drei von HotPDF unterstützten OIDs ab: 1.2.840.10045.3.1.7 für P-256, 1.3.132.0.34 für P-384 und 1.3.132.0.35 für P-521. HPDFECDSACoordinateSize liefert dann 32, 48 oder 66 Bytes, und der P1363-Puffer ist das Doppelte davon: 64, 96 oder 132. Jede dekodierte Ganzzahl wird rechtsbündig in ihre Hälfte eingesetzt, sodass ein kurzes r links mit Nullen aufgefüllt statt verschoben wird. P-521 ist die Kurve, die die meisten überrascht, denn 521 Bit sind 65,125 Bytes und werden auf 66 aufgerundet, was eine 132-Byte-Signatur ergibt, die keine Zweierpotenz-Intuition vorhergesagt hätte. Der öffentliche Schlüssel reist als unkomprimierter EC-Punkt gemäß RFC 5480 §2.2 mit, das ist 0x04 gefolgt von X und Y, sodass HotPDF prüft, dass er exakt 1 + 2 * CoordinateSize Bytes lang ist und mit 0x04 beginnt, bevor CNG überhaupt berührt wird
var
Digest, SigDER, PublicPoint: TBytes;
Curve: THPDFECDSACurve;
Res: THPDFECDSAVerifyResult;
begin
// secp256r1, taken from the certificate SubjectPublicKeyInfo parameters
Curve := HPDFECDSACurveFromOID('1.2.840.10045.3.1.7');
// PublicPoint must be $04 || X || Y, so 1 + 2 * 32 = 65 bytes for P-256
Res := HPDFECDSAVerifyDigest(Digest, SigDER, PublicPoint, Curve, eseDER);
case Res of
evrValid:
Memo1.Lines.Add('signature verifies');
evrInvalid:
Memo1.Lines.Add('signature does not match the digest');
evrMalformed:
Memo1.Lines.Add('DER encoding or public point rejected');
evrUnsupported:
Memo1.Lines.Add('curve or algorithm not supported here');
evrProviderUnavailable:
Memo1.Lines.Add('bcrypt.dll or the curve provider is missing');
evrProviderError:
Memo1.Lines.Add('CNG returned an unexpected status');
end;
end;
Man beachte den letzten Parameter. HPDFECDSAVerifyDigest akzeptiert auch eseP1363 für Aufrufer, die bereits eine Signatur fester Breite besitzen, etwa von einem Hardware-Token oder einem entfernten Signierdienst, der rohes r || s zurückgibt. Auch dieser Pfad erzwingt die Längen- und Nicht-null-Prüfung auf beiden Hälften, sodass ein Puffer richtiger Größe voller Nullen abgelehnt statt an den Provider durchgereicht wird
Warum schlägt der generische ECDSA-Algorithmusname auf älterem Windows fehl?
Weil der generische Name neuer ist als die Bereitstellungsbasis, für die man ausliefert. CNG stellt einen Algorithmus-Bezeichner ECDSA bereit, der die Kurve aus dem importierten Schlüssel ableitet, und das ist der saubere Weg, diesen Code zu schreiben, aber BCryptOpenAlgorithmProvider ist nur auf neueren Windows-Versionen garantiert auflösbar. Auf einer älteren Maschine schlägt der Open-Aufruf fehl, das Provider-Handle bleibt nil, und jede ECDSA-Verifikation in der Anwendung meldet nicht unterstützt für eine völlig einwandfreie Signatur. HotPDF umgeht diesen Abgrund, indem es stattdessen die kurvenspezifischen Bezeichner öffnet. Es löst ECDSA_P256, ECDSA_P384 und ECDSA_P521 einmalig auf, cacht ein Provider-Handle pro Kurve und schließt sie bei der Unit-Finalisierung. Jede Verifikation erledigt dann nur die günstige Arbeit: einen temporären öffentlichen Schlüssel aus einem ECCPUBLICBLOB importieren, BCryptVerifySignature aufrufen, den Schlüssel zerstören. Kein wiederholtes LoadLibrary, kein wiederholtes GetProcAddress, kein Provider-Öffnen und -Schließen pro Signatur. Die Batch-Verifikation einiger Hundert Dokumente spürt den Unterschied, ebenso ein Dienstprozess, der andernfalls unter Last Provider-Handles verschleißen würde
Die Ergebniscodes bleiben bei dieser Unterscheidung ehrlich. evrProviderUnavailable bedeutet, die Maschine konnte HotPDF keinen Provider geben; evrInvalid bedeutet, CNG hat mit STATUS_INVALID_SIGNATURE geantwortet. Beides in einen einzigen Fehlschlag zu verschmelzen ist der Weg, auf dem ein Bereitstellungsproblem als gefälschtes Dokument fehlgemeldet wird. Dieselbe Trennung zwischen Umgebungsfehler und kryptografischem Fehler zieht sich durch die CNG- und CAPI-Behandlung auf der Signierseite, behandelt im Artikel zum Zertifikatspeicher-Signieren und zur Byte-Reihenfolge
Wer hat das signiert? SignerIdentifier ist zweierlei
RFC 5652 §5.3 macht SignerIdentifier zu einer CHOICE, und ein Verifier, der nur einen Zweig behandelt, verifiziert still gegen den falschen Schlüssel. Der erste Zweig ist issuerAndSerialNumber, eine SEQUENCE mit dem Aussteller-Namen im rohen DER und der Seriennummer als INTEGER, und der Abgleich ist ein Byte-Vergleich gegen jedes Zertifikat in der CMS-certificates-Menge. Der zweite Zweig ist [0] subjectKeyIdentifier, ein implizit getaggtes OCTET STRING, und der Abgleich erfordert ein Eintauchen in das Zertifikat, statt nur dessen Header-Felder zu vergleichen
Das Eintauchen hat eine Schicht, die überrascht. Der Schlüsselbezeichner lebt in einer X.509v3-Erweiterung, sodass HotPDF das [3]-Erweiterungsfeld des tbsCertificate durchläuft, die Erweiterung findet, deren OID 2.5.29.14 ist, das optionale kritische BOOLEAN überspringt und das extnValue-OCTET-STRING nimmt. Dieser Oktett-String ist nicht der Bezeichner. Gemäß RFC 5280 §4.2.1.2 ist sein Inhalt selbst DER, und der Typ KeyIdentifier ist ein weiteres OCTET STRING, sodass man ein zweites Mal parst, um an die eigentlichen Bytes zu gelangen. Hört man eine Schicht zu früh auf, vergleicht man einen 22-Byte-Wrapper gegen einen 20-Byte-Bezeichner, kein Zertifikat passt je, und der Verifier fällt auf welche Heuristik man als Nächstes geschrieben hat zurück, was die eigentliche Gefahr ist. Das erste Zertifikat in der Menge zu nehmen ist eine verlockende Abkürzung, und sie ist immer falsch, wenn das CMS eine Kette trägt, was meistens der Fall ist, weil das Blatt-Zertifikat nicht zuerst kommen muss. HotPDF akzeptiert ein unabgeglichenes Zertifikat nur dann, wenn der Container genau eines enthält; sind mehrere Zertifikate vorhanden, ist ein exakter SignerIdentifier-Abgleich zwingend. Einen Digest gegen den öffentlichen Schlüssel einer Zwischen-CA zu verifizieren erzeugt keine freundliche Fehlermeldung, sondern ein selbstsicheres ungültig für ein einwandfreies Dokument
var
Pdf: THotPDF;
Info: THPDFSignatureInfo;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('signed.pdf') > 0 then
for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
if Pdf.VerifyLoadedSignatureEx(I, Info) = svValid then
Memo1.Lines.Add(Format('%s: %s %s over %s, signer %s',
[String(Info.FieldName), String(Info.PublicKeyAlgorithm),
String(Info.CurveName), String(Info.HashAlgorithm),
String(Info.SignerName)]))
else
Memo1.Lines.Add(Format('%s: not valid', [String(Info.FieldName)]));
finally
Pdf.Free;
end;
end;
THPDFSignatureInfo.CurveName meldet P-256, P-384 oder P-521, sodass ein Audit-Log festhält, welche Kurve tatsächlich verwendet wurde, statt nur das Wort ECDSA. Die dokumentweite Verdrahtung rund um diesen Aufruf, insbesondere wie die /ByteRange-Segmente gehasht werden und warum der Digest über die Datei statt über den geparsten Objektbaum berechnet werden muss, ist Thema des begleitenden Artikels zur Verifikation von PDF-Signaturen
Was das nicht liefert
Ein grünes Ergebnis von HPDFECDSAVerifyDigest beantwortet genau eine Frage: Diese Bytes wurden mit dem privaten Schlüssel signiert, der zu diesem öffentlichen Schlüssel passt. Es sagt nichts darüber aus, ob dieser Schlüssel jemandem gehört, dem man vertrauen sollte. Kettenaufbau bis zu einem Trust Anchor, Widerruf über CRL oder OCSP und Richtlinienprüfungen sind separate Arbeit, und jedes Produkt, das eine gültige Signatur ohne sie meldet, meldet weniger, als der Nutzer annimmt. Gültigkeitsdaten des Zertifikats werden genau aus diesem Grund separat in THPDFSignatureInfo ausgewiesen: Eine Signatur kann kryptografisch verifizieren, während das Zertifikat, das sie erstellt hat, vor zwei Jahren abgelaufen ist. Auch die Kurvenunterstützung ist bewusst schmal gehalten. Drei NIST-Primkurven werden behandelt, und eine Signatur über jede andere Kurve liefert nicht unterstützt statt eine Vermutung. Der CNG-Pfad ist ausschließlich für Windows, was für eine VCL-Komponente der richtige Tausch ist, aber erwähnenswert, bevor man einen plattformübergreifenden Dienst darum herum plant. Und die Strenge ist nicht konfigurierbar: Es gibt keinen nachsichtigen Modus, der eine nicht-minimale DER-Länge akzeptiert, weil ein Legacy-Signierer eine solche ausgegeben hat. Trifft man in der Produktion auf eine solche Datei, ist die ehrliche Reaktion, sie zu dokumentieren und den Erzeuger zu kontaktieren, nicht den Parser aufzuweichen, bis die Datei durchgeht
Der hier beschriebene ECDSA-Verifikationspfad ist Teil der Standard-HotPDF Component für Delphi und C++Builder, zusammen mit den Pfaden für RSA PKCS#1 v1.5 und RSA-PSS sowie dem vollständigen Signaturinformations-Record; die Produktseite führt die vollständige Referenz zu digitalen Signaturen