PDFium VCL behandelt CMS-Verifikation als austauschbares Backend hinter dem Interface IPdfCmsVerifier, sodass der PAdES-Validator auf Windows über CryptoAPI laufen kann, auf macOS über den Keychain und überall dort, wo OpenSSL vorhanden ist, über ConfigureSslCmsVerifier. Das Interface ist klein. Drei OpenSSL-Eigenheiten darunter liefern selbstbewusste, falsche Antworten, wenn man es naiv implementiert
Die Motivation ist schlicht, sobald eine Delphi-Anwendung Windows verlässt. Signaturvalidierung ist eines der wenigen Gebiete, in denen der Krypto-Stack der Plattform kein Implementierungsdetail ist: Er entscheidet, welche Zertifikate vertraut werden, welche Algorithmen existieren und was Revocation bedeutet. Einen fest verdrahtet, und der Code portiert nicht. Schlecht abstrahiert, meldet jede Plattform eine anders geformte Antwort, die der Aufrufer nicht vergleichen kann
Was die Abstraktion tatsächlich tragen muss
Zwei Verifikationsformen und drei unabhängige Urteile. Eine PDF-Signatur ist detached: Der signierte Inhalt sind die zwei Bytebereiche beiderseits des /Contents-Lochs, also nimmt VerifyDetached zwei Segmente statt eines Buffers. Ein Timestamp-Token ist attached, trägt seinen eigenen Inhalt, also nimmt VerifyAttached nur das DER
Das Ergebnis spaltet sich in drei Status, denn sie beantworten drei verschiedene Fragen und dürfen auseinanderliegen. SignatureStatus sagt, ob die Bytes vom Schlüssel im Signer-Zertifikat signiert wurden. TrustStatus sagt, ob dieses Zertifikat zu etwas chainiert, dem man vertraut. RevocationStatus sagt, ob das Zertifikat zum maßgeblichen Zeitpunkt noch gültig war. Ein Dokument mit mathematisch perfekter Signatur von einem Zertifikat, von dem man nie gehört hat, ist valid, untrusted und unknown — das in einen einzelnen Boolean zu pressen ist der Weg, auf dem Validatoren anfangen, ihre Nutzer anzulügen
uses
FPdfCrypto, FPdfCryptoSsl;
var
Options: TPdfCmsVerifyOptions;
begin
if not SslAvailable then
raise Exception.Create('libcrypto not usable: ' + SslMissingSymbols);
ConfigureSslTrustAnchors(LoadCorporateRoots); // DER, darf leer sein
ConfigureSslCrls(LoadFreshCrls); // DER, darf leer sein
ConfigureSslCmsVerifier; // installiert das Backend
Writeln('backend : ', PadesCmsVerificationBackendName);
Writeln('library : ', SslLibraryPath, ' ', SslLibraryVersion);
Writeln('ABI : ', SslAbiLayout); // ulong=<n> long=<n>
Options := TPdfCmsVerifyOptions.Default;
Options.CheckRevocation := True;
Options.CollectChainCertificates := True;
end;
SslAbiLayout sieht aus wie eine Kuriosität und ist keine. Jeder OpenSSL-Fehlercode und jedes Store-Flag kreuzt die Grenze als C-unsigned long, was auf Windows vier Byte und auf Linux und macOS acht sind. Als festen 32-Bit-Typ deklariert, funktioniert der Code auf Windows und liest dann still die halbe Wertbreite auf LP64. Die angenommenen Breiten als String zu melden, auf dem man in einem Test bestehen kann, verwandelt eine ganze Klasse von Plattform-ABI-Drift in einen Einzeiler-Check. Wer dasselbe Problem mit CK_ULONG in einer PKCS#11-Binding durchgearbeitet hat, erkennt es sofort; die Geschichte steht in PKCS#11-Struct-Packing und CK_ULONG-Breite
Warum sieht der zweite Verifikationsdurchlauf leeren Inhalt?
Weil CMS_verify die Detached-Content-BIO bis zum Dateiende liest und eine gelesene BIO nicht für einen zurückgespult wird. In zwei Durchläufen zu verifizieren ist ein vernünftiger Entwurf — erst die kryptografische Signatur allein mit unterdrückter Chain-Auswertung, dann die volle Auswertung — und es scheitert auf ungewöhnlich täuschende Art, wenn beide Durchläufe sich eine BIO teilen
Der zweite Durchlauf bekommt null Byte Inhalt. Im Detached-Modus ist das kein Fehler, denn ein leerer Content-Buffer ist eine legale Eingabe. Der Digest passt schlicht nicht, und das Scheitern zeigt sich als Chain-Aufbau-Fehler statt als Inhaltsfehler — man untersucht also Zertifikate und Trust Stores, während das eigentliche Problem eine Stream-Position ist. Die Memory-BIO mit BIO_new_mem_buf für jeden Durchlauf neu bauen. Kostet eine Allokation und beseitigt die Möglichkeit vollständig
Was das No-Verify-Flag unterdrückt und was nicht
CMS_NO_SIGNER_CERT_VERIFY unterdrückt die Chain-Auswertung, nicht die Signer-Zertifikatssuche. Intern löst OpenSSL die Signer-Zertifikate auf und hängt sie an, bevor es das Flag konsultiert, also ist der Signer nach einem ersten Durchlauf mit diesem Flag bereits verfügbar, und seine Algorithmus-Identifikatoren lassen sich direkt lesen. Es gibt keinen Grund, eine zweite volle Verifikation zu fahren, nur um das Signer-Zertifikat zu bekommen — genau das legt der Flag-Name nahe
Eine Besitzregel gehört dazu. Die Signer-Referenz gehört zur CMS-Struktur und darf nicht unabhängig freigegeben werden. Sie ist so lange gültig wie die Struktur, und sie freizugeben erzeugt eine Korruption, deren Symptom irgendwo ganz anders auftaucht, meist beim Aufräumen eines unverwandten Objekts
Warum lehnt eingeschaltete CRL-Prüfung jede Signatur ab?
Weil OpenSSL CRLs nur gegen das prüft, was der Store bereits hält, und von sich aus nichts nachzieht. Er folgt keinen CRL-Distribution-Points und spricht kein OCSP. X509_V_FLAG_CRL_CHECK auf einem Store ohne CRLs gesetzt, und jede Kette scheitert an der Unfähigkeit, eine Zertifikats-CRL zu beschaffen. Das Ergebnis sieht aus wie eine funktionierende Revocationsprüfung, die Probleme findet. Es ist eine Revocationsprüfung, die nie lief
Das Backend setzt das Flag deshalb nur, wenn ConfigureSslCrls tatsächlich mindestens eine CRL geliefert hat. Ohne eine kommt RevocationStatus als pcvsUnsupported zurück — die ehrliche Aussage, dass die Frage nicht beantwortet wurde. Aus demselben Grund hat OnlineRetrieval auf dieses Backend keinen Effekt, und es wird kein pcvstOnlineRetrieval-Checkpoint emittiert: Es gibt keinen Nachzieh-Pfad, von dem aus man Fortschritt melden könnte
Das ist eine Designposition, die sich generell verteidigen lässt: Ein Validator, der Revocation nicht prüfen kann, soll das sagen. Ein ungeprüftes Zertifikat als nicht widerrufen zu melden ist der mit Abstand häufigste Weg, mit dem Signaturvalidierungswerkzeuge ihre Nutzer in die Irre führen — genau diese Verwirrungsklasse untersucht warum Validatoren PAdES-Signaturen ablehnen
// Checkpoints lassen eine UI zeigen, welche Stufe läuft, und verraten,
// welche Stufen ein Backend tatsächlich ausführt
type
TSignatureProbe = class
procedure Checkpoint(Stage: TPdfCmsVerifyStage);
end;
procedure TSignatureProbe.Checkpoint(Stage: TPdfCmsVerifyStage);
begin
case Stage of
pcvstCryptographicSignature: Status('checking the signature');
pcvstChainBuild: Status('building the certificate chain');
pcvstOnlineRetrieval: Status('fetching validation data');
pcvstRevocationCheck: Status('checking revocation');
end;
end;
// Die drei Urteile getrennt lesen; sie dürfen auseinanderliegen
if Result.SignatureStatus = pcvsValid then
case Result.TrustStatus of
pcvsValid: Report('signed and trusted');
pcvsInvalid: Report('signed, chain rejected');
pcvsUnsupported,
pcvsIndeterminate: Report('signed, trust not established');
end;
if Result.RevocationStatus = pcvsUnsupported then
Report('revocation was not checked on this backend');
An eine Bibliothek binden, die man nicht pinnen kann
OpenSSL hat seine Stack-Accessoren zwischen 1.0 und 1.1 umbenannt, dieselbe logische Funktion hat also zwei mögliche Exportnamen, je nachdem, welchen Build der Host zufällig hat. Die Binding löst zuerst den neueren Namen auf und fällt auf den älteren zurück, und erst wenn keiner auflöst, wird ein fehlendes Symbol vermerkt. Das ist die richtige Form für jede dynamische Binding gegen eine Bibliothek, die man nicht selbst ausliefert: aktuelle Namen bevorzugen, historische tolerieren und nur echte Abwesenheit melden
SslMissingSymbols macht aus einem gescheiterten Laden ein diagnostizierbares Ereignis. Ein nicht leeres Ergebnis auf einem Host, der offensichtlich libcrypto installiert hat, heißt: Die installierte Version ist älter als die API, auf die dieser Build zielt — ein komplett anderes Support-Gespräch als eine fehlende Bibliothek. ConfigureSslLibraryPath deckt den anderen häufigen Fall ab: einen Host mit mehreren OpenSSL-Builds, bei dem der auf dem Default-Suchpfad nicht der gewünschte ist
Ein Backend pro Plattform wählen
Die praktische Lösung: Beim Startup wählen und festhalten, welche geantwortet hat. Auf Windows integriert sich das Plattform-Backend mit den Zertifikatsspeichern, die ein Unternehmen ohnehin verwaltet — normalerweise genau das, was man will. Auf macOS passt das Keychain-Backend in dieselbe Überlegung und ist beschrieben in Signaturen mit SecTrust auf macOS verifizieren. OpenSSL ist die portable Option und auch die richtige Wahl, wenn man eine Validierungs-Policy braucht, die über Plattformen identisch ist, statt einer, die jedem Plattform-Trust Store folgt
Was immer man installiert: PadesCmsVerificationBackendName neben jedem festgehaltenen Urteil mitloggen. Ein gespeichertes Validierungsergebnis ohne das Backend, das es erzeugt hat, lässt sich später nicht reproduzieren, denn die drei Statuswerte bedeuten subtil Unterschiedliches, je nachdem, welcher Stack geantwortet hat. Die Signaturinspektions-Layer über all dem, inklusive der Frage, wie PAdES-Level gemeldet werden, behandelt PDF-Digitalsignaturen und PAdES-Level inspizieren
Das alles liegt als Quelle der PDFium Delphi component bei, was hier mehr als üblich zählt: Bei einem Signaturvalidator ist es kein Nice-to-have, exakt lesen zu können, welche Flags ein Backend setzt und welche Prüfungen es überspringt — es ist der einzige Weg zu wissen, was ein grüner Haken in der eigenen Anwendung tatsächlich behauptet