PDFium VCL kann jetzt auf seinem OpenSSL-Backend eine PDF-Signaturkette über das Netzwerk vervollständigen und die Revocation prüfen: Ist OnlineRetrieval aktiviert, installiert ConfigureSslCmsVerifier einen Verifizierer, der fehlende Zwischenzertifikate aus den AIA-caIssuers-URLs und CRLs aus den CRL-Distribution-Points lädt, innerhalb eines festen Zeit-, Anfrage- und Byte-Budgets pro Verifikationsaufruf. Heruntergeladene Zertifikate sind immer nur Kettenmaterial. Trust kommt nach wie vor ausschließlich aus dem Systemstore und den Ankern, die Sie konfigurieren
Die Lücke, die das schließt, zeigt sich, sobald Sie realweltliche PDFs auf einem Linux-Server validieren. Ein großer Teil der Signer bettet nur das eigene Leaf-Zertifikat ins CMS ein, also kommt OpenSSL nicht zu einem Root, TrustStatus kehrt invalid zurück, und die Revocation läuft nie, weil die Kette nie vertrauenswürdig wurde. Vor v3.121.0 war das in Verifizieren von PDF-Signaturen mit OpenSSL in PDFium VCL beschriebene OpenSSL-Backend strikt offline, und OnlineRetrieval blieb ohne Wirkung auf es. Eines vorweg: Die PDFium-Engine selbst macht überhaupt keine CMS-Verifikation, also wohnt jede Regel unten in der PAdES-Schicht der Komponente und ihrer OpenSSL-Bindung, wo Sie sie lesen können
In welcher Reihenfolge verifiziert, lädt und prüft das OpenSSL-Backend?
Zuerst Integrität, dann Trust, dann Revocation, und das Netzwerk wird nur zwischen den Schritten berührt, die es brauchen. VerifyCmsWithSsl prüft die CMS-Signatur und die signierten Attribute (RFC 5652) mit unterdrückter Kettenauswertung, und scheitert das, kehrt es sofort zurück, bevor eine Fetch-Session überhaupt existiert – ein Dokument mit kaputten Bytes löst also keine ausgehende Anfrage aus. Nur wenn die Kette danach scheitert und OnlineRetrieval an ist, folgt es den AIA-Links und verifiziert erneut. CRL-Distribution-Points werden erst geladen, wenn die Kette vertrauenswürdig ist, denn eine CRL, die an einem unvertrauenswürdigen Pfad hängt, beweist nichts. Die drei Urteile bleiben durchgehend getrennt: Eine gültige Signatur mit unvollständiger Kette wird weiterhin als gültige Signatur gemeldet
uses
PDFium, FPdfCrypto, FPdfCryptoSsl, FPdfPades;
var
Pdf: TPdf;
Probe: TPdfCmsVerifyOptions;
Diags: TPdfSslVerifyDiagnostics;
Trust: TPadesTrustValidationOptions;
Verdict: TPadesValidationResult;
I: Integer;
begin
ConfigureSslTrustAnchors(LoadCorporateRoots); // DER; der einzige zusätzliche Trust
ConfigureSslCmsVerifier;
Probe := TPdfCmsVerifyOptions.Default;
Probe.OnlineRetrieval := True;
Probe.CheckRevocation := True;
Diags := SslVerifyOptionsDiagnostics(Probe);
if psvdOnlineRetrievalIgnored in Diags then
Log('no HTTP transport or CMS_add1_cert: validation stays offline');
Trust := TPadesTrustValidationOptions.Default; // ptnpOffline als Default
Trust.NetworkPolicy := ptnpOnline;
Trust.CheckRevocation := True;
Trust.UrlRetrievalTimeoutMs := 10000; // pro Verifikationsaufruf
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'signed-contract.pdf';
Pdf.Active := True;
Verdict := Pdf.ValidatePadesTrust(Trust);
for I := 0 to High(Verdict.Signatures) do
Log(Format('#%d trust=%d revocation=%d', [I,
Ord(Verdict.Signatures[I].CertificateTrustStatus),
Ord(Verdict.Signatures[I].RevocationStatus)]));
finally
Pdf.Free;
end;
end;
Warum landen heruntergeladene Zertifikate nie im Trust-Store?
Weil die URLs aus dem Zertifikat stammen, das gerade validiert wird – und der Signer hat sie gewählt. Der authorityInfoAccess-caIssuers-Eintrag (RFC 5280 §4.2.2.1) ist ein Hinweis darauf, wo der Issuer wohnt, nichts weiter. Würfe man, was diese URL antwortet, in den Trust-Anchor-Store, könnte jeder mit einem selbstgemachten Schlüssel signieren, AIA auf den eigenen Server zeigen lassen und ein grünes Urteil kassieren. RetrieveIntermediates reicht jedes geparste Zertifikat deshalb an CMS_add1_cert weiter, das es in die untrusted-Menge genau dieser einen CMS-Struktur einsortiert, und OpenSSL muss weiterhin einen Pfad von dort zu einem Anker bauen, den Sie konfiguriert haben oder den der Systemstore ohnehin hält. Es gibt auch einen leiseren Grund: Das Zertifikat-Argument von CMS_verify ist kein Drop-in-Ersatz für die im CMS eingebetteten Zertifikate, also ist das Hinzufügen ins CMS selbst der verlässliche Weg
Die Abrufschleife ist bewusst eng. RetrieveIntermediates läuft höchstens 4 Runden, sammelt je Runde die caIssuers-URLs aller inzwischen im CMS liegenden Zertifikate und stoppt, sobald eine Runde nichts hinzufügt oder das Zeitbudget aufgebraucht ist. Eine Antwort muss mit d2i_X509 als einzelnes DER-Zertifikat dekodieren, das den gesamten Body verzehrt; angehängte Bytes werden zurückgewiesen, und ein von einer .p7c-URL ausgeliefertes PKCS#7-certs-only-Bundle wird übersprungen statt entpackt. Die OCSP-Access-Methode derselben AIA-Erweiterung wird ignoriert, denn dieses Backend spricht kein OCSP. Auf der Revocations-Seite liest RetrieveCrls nur die fullName-URIs jedes DistributionPoint (RFC 5280 §4.2.1.13) aus den CMS-Zertifikaten und den konfigurierten Ankern, und die geladenen CRLs landen in einem zweiten, unabhängigen X509_STORE mit Full-Chain-CRL-Prüfung, sodass eine fehlende oder veraltete CRL RevocationStatus ändert, ohne TrustStatus je anzufassen
// Aus VerifyCmsWithSsl (FPdfCryptoSsl.pas) verdichtet; BIO-Aufbau ausgelassen.
// Jeder _CMS_verify-Aufruf bekommt ein frisches Content-BIO
if _CMS_verify(Cms, nil, nil, Bio, nil,
CMS_NO_SIGNER_CERT_VERIFY or CMS_BINARY) <> 1 then
Exit; // kaputte Signatur: gar kein Netzwerk
if Options.OnlineRetrieval and SslCapabilities.OnlineRetrieval then
FetchSession := TPdfCryptoFetchSession.Create(Options.UrlRetrievalTimeoutMs);
Store := BuildStore(False, nil, RevocationChecked, CrlsMalformed);
if (_CMS_verify(Cms, nil, Store, Bio, nil, CMS_BINARY) <> 1) and
(FetchSession <> nil) then
begin
RetrieveIntermediates(Cms, FetchSession); // CMS_add1_cert, nur untrusted
// die Kette erneut gegen denselben Anchor-Store verifizieren
end;
if Options.CheckRevocation and (FetchSession <> nil) and
(Result.TrustStatus = pcvsValid) then
RetrievedCrls := RetrieveCrls(Cms, FetchSession);
// zweiter, separater Store: konfigurierte CRLs plus geladene
Store := BuildStore(True, RetrievedCrls, RevocationChecked, CrlsMalformed);
Was kostet ein Verifikationsaufruf höchstens?
Ein festes Limit, erzwungen von einer TPdfCryptoFetchSession, die sich die AIA- und CRL-Schritte eines einzelnen Verifikationsaufrufs teilen. Die Grenzen sind Konstanten in FPdfCryptoHttp, keine Vorschläge:
- Zeit:
UrlRetrievalTimeoutMs, das inTPdfCmsVerifyOptions.DefaultundTPadesTrustValidationOptions.Defaultjeweils auf 15000 defaultet; eine mit 0 erzeugte Session fällt auf 30000 zurück, und die Uhr startet, sobald die Signatur bestanden hat, und deckt jede spätere Anfrage - Anfragen: höchstens 8 pro Session, gezählt, bevor der Transport angetreten wird, ein toter Host verbraucht also weiterhin einen Slot
- Bytes: 1 MiB pro Antwort und 4 MiB insgesamt, URLs über 2048 Zeichen werden vor jedem Verbindungsaufbau abgelehnt
Die Buchhaltung ist strenger, als sie zuerst aussieht. Bytes aus einer fehlgeschlagenen Antwort zählen gegen das Gesamtbudget, also kann ein Server, der einen 404er mit großer Seite antwortet, das Budget nicht gratis leersaugen. Der Read, der das Pro-Antwort-Limit überschreitet, bricht den Download ab, statt einen gestutzten Body an den ASN.1-Parser weiterzureichen, und ein HTTP 200 mit leerem Body wird rundheraus abgelehnt, weil der AIA-Pfad sonst Data[0] eines leeren Arrays indizieren würde. Nur schlichte http://- und https://-URLs kommen durch, ohne Redirects, Cookies, Credentials oder automatische Proxy-Erkennung, während HTTPS seine üblichen Zertifikats- und Hostnamen-Checks behält. Das URL-Deduplizieren ist bewusst auf einen Aufruf begrenzt: Die nächste Validierung muss eine frisch veröffentlichte CRL sehen können. Das Budget gilt außerdem pro Aufruf, nicht pro Dokument, und ValidatePadesTrust verifiziert jede Signatur und jedes Timestamp-Token separat – der Worst Case wächst also mit der Anzahl der Signaturen
Warum kann ein WinHTTP-Request mit Timeout trotzdem in Ihren Speicher schreiben?
Weil die Rückkehr beim Timeout die bereits in der Luft befindlichen Callbacks nicht abbestellt. Der Windows-Transport fährt WinHTTP asynchron und wartet auf ein Event mit der verbleibenden Session-Zeit, und wenn dieses Warten aufgibt, kann die Anfrage trotzdem noch einen Read abschließen und sich danach melden. Richten Sie den asynchronen Read auf einen Stack-Buffer, schreibt diese verspätete Fertigstellung in einen Frame, der dann irgendeiner völlig fremden Funktion gehört. Der Fix ist Ownership, nicht Timing: Event und der 16-KB-Read-Buffer wohnen in einem Heap-Record mit zwei Referenzen, eine hält der Aufrufer, eine gibt nur der abschließende HANDLE_CLOSING-Callback frei – die Seite, die zuletzt fertig ist, gibt den Speicher frei
type
PHttpState = ^THttpState;
THttpState = record
References: LongInt; // Aufrufer + finaler HANDLE_CLOSING-Callback
Event: THandle;
Status, Count: DWORD;
Buffer: array[0..16383] of Byte; // asynchrone Reads landen hier, nie auf dem Stack
end;
procedure ReleaseState(State: PHttpState);
begin
if InterlockedDecrement(State.References) = 0 then
begin
CloseHandle(State.Event);
Dispose(State);
end;
end;
// Im Status-Callback: HANDLE_CLOSING ist die letzte Meldung, die WinHTTP
// zur Anfrage schickt, also gibt sie die zweite Referenz frei
if Status = HttpHandleClosing then
begin
ReleaseState(State);
Exit;
end;
Was libcurl auf FPC Unix liefern muss
Einen asynchronen Resolver und einen threadsicheren Build, sonst bleibt der Online-Abruf aus. Auf FPC Unix läuft der Transport über libcurl, dieselbe Abhängigkeit hinter dem libcurl-Timestamp-Backend für Nicht-Windows-Ziele, und die Bindung weist jede Bibliothek zurück, deren Feature-Maske weder CURL_VERSION_ASYNCHDNS noch CURL_VERSION_THREADSAFE trägt. Der Grund: CURLOPT_NOSIGNAL, das eine Bibliothek im Prozess eines anderen setzen muss, bedeutet kombiniert mit einem synchronen Resolver, dass ein DNS-Lookup den Timeout schlicht überleben kann. Die zweite Falle ist das Herunterfahren: curl_global_cleanup wartet nicht auf asynchrone DNS-Threads, also bleibt das Modul, sobald libcurl initialisiert wurde, gemappt, bis der Prozess endet, statt einen Hintergrundthread in entladenen Code laufen zu lassen. Scheitert eine der beiden Anforderungen, ist SslCapabilities.OnlineRetrieval False, und SslVerifyOptionsDiagnostics meldet psvdOnlineRetrievalIgnored, statt so zu tun, als sei das Netzwerk befragt worden
Was das Ergebnis garantiert und was nicht
Ein gültiger RevocationStatus von diesem Backend bedeutet, dass aktuelle CRLs, die die ganze Kette abdecken, gefunden, konfiguriert oder geladen wurden und keine ein Zertifikat daraus gelistet hat; mehr nicht. Es gibt kein OCSP, also lässt eine CA, die Revocation nur über OCSP veröffentlicht, das Ergebnis unsupported, und ein Netzwerkfehler sieht exakt so aus wie eine CA, die nichts veröffentlicht. Beachten Sie außerdem, dass psvdNoCrlsConfigured nur die von Ihnen konfigurierten CRLs beschreibt – mit Online-Abruf ist es ein Hinweis, keine Misserfolgsprognose. Muss eine Audit-Spur ohne Netzwerkzugriff reproduzierbar sein, lassen Sie NetworkPolicy auf seinem ptnpOffline-Default: Es wird keine Fetch-Session erzeugt, und das Backend öffnet nie eine Verbindung, was zum Offline-Vertrag auf der CryptoAPI-Seite passt, wie er in Offline-Revocation-Checks für PDF-Signaturen unter Windows beschrieben ist
Der Abrufcode, die Budgets und die Transport-Bindungen kommen als Source mit der PDFium Delphi component, also können Sie exakt nachvollziehen, welche URLs eine Validierung kontaktieren darf und wie viel sie laden darf, bevor Sie ptnpOnline auf einem Server aktivieren, der unvertrauenswürdige Dokumente verarbeitet