Technischer Artikel

OpenSSL-AIA- und CRL-Abruf für PDF-Signaturen in Delphi

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

Reihenfolge von VerifyCmsWithSsl im OpenSSL-Backend der PDFium Component: Der CMS-Signaturcheck läuft mit unterdrückter Kettenauswertung, kaputte Bytes berühren also nie das Netzwerk; RetrieveIntermediates folgt AIA-caIssuers-URLs erst nach einem Kettenfehler mit aktiviertem OnlineRetrieval, und RetrieveCrls lädt Distribution-Point-CRLs in einen separaten Store, sobald die Kette vertrauenswürdig ist
Integrität, dann Trust, dann Revocation: Das Netzwerk wird nur zwischen den Schritten berührt, die es brauchen, und eine CRL an einem unvertrauenswürdigen Pfad beweist nichts
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 in TPdfCmsVerifyOptions.Default und TPadesTrustValidationOptions.Default jeweils 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 harten Deckel einer TPdfCryptoFetchSession, die sich die AIA- und CRL-Schritte eines Verifikationsaufrufs in PDFium Component teilen: UrlRetrievalTimeoutMs defaultet auf 15000 ms mit einem Null-Fallback von 30000, höchstens 8 Anfragen pro Session, 1 MiB pro Antwort und 4 MiB insgesamt, wobei fehlgeschlagene Antworten weiter zählen, und URLs über 2048 Zeichen werden abgelehnt
Die Grenzen sind Konstanten, keine Vorschläge: Bytes aus einer fehlgeschlagenen Antwort zähren das Budget trotzdem, und weil jede Signatur und jeder Timestamp separat verifiziert wird, wächst der Worst Case mit der Anzahl der Signaturen

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

Warum ein WinHTTP-Request mit Timeout trotzdem in den Speicher schreiben kann: Die Rückkehr beim Timeout lässt Callbacks in der Luft, also richtet PDFium Component den asynchronen Read auf einen heap-allozierten THttpState-Record, dessen 16-KB-Buffer und zwei Referenzen – eine beim Aufrufer, eine vom finalen HANDLE_CLOSING-Callback freigegeben – erst freigegeben werden, wenn die letzte Seite fertig ist
Eine verspätete Fertigstellung darf ihren Read abschließen, nachdem Ihr Warten aufgegeben hat; Heap-Ownership mit zwei Referenzen bedeutet, dass dieser Schreibzugriff in noch lebendem Speicher landet
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