Technisch artikel

PDF-handtekeningen: OpenSSL AIA- en CRL-ophaling in Delphi

PDFium VCL kan nu op zijn OpenSSL-backend een PDF-handtekeningketen compleet maken en revocatie over het netwerk controleren: is OnlineRetrieval ingeschakeld, dan installeert ConfigureSslCmsVerifier een verificateur die ontbrekende tussenliggende certificaten van de AIA-caIssuers-URL's downloadt en CRL's van de CRL-distribution-points, binnen een vast tijd-, aanvraag- en bytebudget per verificatieaanroep. Gedownloade certificaten zijn uitsluitend ketenmateriaal. Vertrouwen komt nog steeds uitsluitend uit de systeemwinkel en de ankers die u configureert

Het gat dat hiermee wordt gedicht toont zich zodra u realistische PDF's op een Linux-server valideert. Een flink deel van de ondertekenaars sluit alleen hun eigen leaf-certificaat in de CMS in, dus OpenSSL kan geen root bereiken, TrustStatus komt ongeldig terug, en revocatie draait nooit omdat de keten nooit betrouwbaar werd. Vóór v3.121.0 was de OpenSSL-backend uit het verifiëren van PDF-handtekeningen met OpenSSL in PDFium VCL strikt offline en had OnlineRetrieval geen effect erop. Eén ding is het vooraf vermelden waard: de PDFium-engine zelf doet helemaal geen CMS-verificatie, dus elke regel hieronder woont in de PAdES-laag van de component en zijn OpenSSL-binding, waar u haar kunt lezen

In welke volgorde verifieert, haalt en controleert de OpenSSL-backend?

Eerst integriteit, dan vertrouwen, dan revocatie, en het netwerk wordt alleen aangeraakt tussen de stappen die hem nodig hebben. VerifyCmsWithSsl controleert de CMS-handtekening en de signed attributes (RFC 5652) met ketenevaluatie onderdrukt, en als dat mislukt keert hij meteen terug, nog voordat er een ophaalsessie bestaat, dus een document met kapotte bytes triggert geen uitgaand verzoek. Alleen als de keten dan faalt en OnlineRetrieval aan staat, volgt hij AIA-verbindingen en verifieert opnieuw. CRL-distribution-points worden pas opgehaald als de keten vertrouwd is, want een CRL die aan een onbetrouwbaar pad hangt bewijst niets. De drie oordelen blijven gedurende alles gescheiden: een geldige handtekening met een incomplete keten wordt nog steeds als geldige handtekening gemeld

Volgorde van VerifyCmsWithSsl in de OpenSSL-backend van PDFium Component: de CMS-handtekeningcontrole draait met ketenevaluatie onderdrukt, dus kapotte bytes raken het netwerk nooit; RetrieveIntermediates volgt AIA-caIssuers-URL's pas na een ketenfaling met OnlineRetrieval ingeschakeld, en RetrieveCrls haalt distribution-point-CRL's op in een aparte winkel zodra de keten vertrouwd is
Eerst integriteit, dan vertrouwen, dan revocatie: het netwerk wordt alleen aangeraakt tussen de stappen die hem nodig hebben, en een CRL die aan een onbetrouwbaar pad hangt bewijst niets
uses
  PDFium, FPdfCrypto, FPdfCryptoSsl, FPdfPades;

var
  Pdf: TPdf;
  Probe: TPdfCmsVerifyOptions;
  Diags: TPdfSslVerifyDiagnostics;
  Trust: TPadesTrustValidationOptions;
  Verdict: TPadesValidationResult;
  I: Integer;
begin
  ConfigureSslTrustAnchors(LoadCorporateRoots);   // DER; het enige extra vertrouwen
  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;  // standaard ptnpOffline
  Trust.NetworkPolicy := ptnpOnline;
  Trust.CheckRevocation := True;
  Trust.UrlRetrievalTimeoutMs := 10000;           // per verificatieaanroep

  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;

Waarom worden gedownloade certificaten nooit aan de trust store toegevoegd?

Omdat de URL's uit het te valideren certificaat komen, en de ondertekenaar heeft ze gekozen. De authorityInfoAccess-caIssuers-entry (RFC 5280 §4.2.2.1) is een hint over waar de issuer huist, niets meer. Zou wat die URL beantwoordt in de trust-anchor-winkel belanden, dan kon iedereen met een eigenaangemaakte sleutel ondertekenen, AIA naar zijn eigen server laten wijzen en een groen oordeel ontvangen. RetrieveIntermediates geeft elk geparseerd certificaat daarom door aan CMS_add1_cert, dat het in de niet-vertrouwde set van deze ene CMS-structuur plaatst, en OpenSSL moet er nog steeds een pad van bouwen naar een door u geconfigureerd anker of wat de systeemwinkel al bevat. Er is nog een stillere reden: het certificaatargument van CMS_verify is geen drop-in vervanger voor de certificaten die in de CMS zitten, dus toevoegen aan de CMS zelf is de betrouwbare weg

De ophaallus is bewust smal. RetrieveIntermediates draait hooguit 4 rondes, waarbij elke ronde caIssuers-URL's uit elk certificaat verzamelt dat nu in de CMS zit, en stopt zodra een ronde niets toevoegt of het tijdbudget op is. Een antwoord moet met d2i_X509 decoderen als één DER-certificaat dat de volledige body verbruikt; nakomende bytes worden geweigerd, en een PKCS#7-certs-only-bundel vanaf een .p7c-URL wordt overgeslagen in plaats van uitgepakt. De OCSP-toegangsmethode in dezelfde AIA-extensie wordt genegeerd, want deze backend spreekt geen OCSP. Aan de revocatiekant leest RetrieveCrls alleen de fullName-URI's van elke DistributionPoint (RFC 5280 §4.2.1.13) uit de CMS-certificaten en de geconfigureerde ankers, en de gedownloade CRL's gaan naar een tweede, onafhankelijke X509_STORE met full-chain-CRL-controle, dus een ontbrekende of verouderde CRL verandert RevocationStatus zonder ooit TrustStatus aan te raken

// Ingekort uit VerifyCmsWithSsl (FPdfCryptoSsl.pas); BIO-opzet weggelaten.
// Elke _CMS_verify-aanroep krijgt een verse content-BIO
if _CMS_verify(Cms, nil, nil, Bio, nil,
  CMS_NO_SIGNER_CERT_VERIFY or CMS_BINARY) <> 1 then
  Exit;                                   // kapotte handtekening: helemaal geen netwerk
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, alleen untrusted
  // verifieer de keten opnieuw tegen dezelfde ankerwinkel
end;

if Options.CheckRevocation and (FetchSession <> nil) and
   (Result.TrustStatus = pcvsValid) then
  RetrievedCrls := RetrieveCrls(Cms, FetchSession);
// tweede, aparte winkel: geconfigureerde CRL's plus opgehaalde
Store := BuildStore(True, RetrievedCrls, RevocationChecked, CrlsMalformed);

Wat kost één verificatieaanroep hooguit?

Een vast plafond, afgedwongen door één TPdfCryptoFetchSession die de AIA- en CRL-stappen van één verificatieaanroep delen. De limieten zijn constanten in FPdfCryptoHttp, geen suggesties:

  • Tijd: UrlRetrievalTimeoutMs, dat in zowel TPdfCmsVerifyOptions.Default als TPadesTrustValidationOptions.Default standaard op 15000 staat; een met 0 gemaakte sessie valt terug op 30000, en de klok begint zodra de handtekening is geslaagd, over elk later verzoek heen
  • Aanvragen: hooguit 8 per sessie, geteld voordat het transport wordt geprobeerd, dus een dode host verbruikt toch een plek
  • Bytes: 1 MiB per antwoord en 4 MiB in totaal, met URL's langer dan 2048 tekens geweigerd nog vóór elke verbinding
De harde plafonds van één TPdfCryptoFetchSession die de AIA- en CRL-stappen van een verificatieaanroep in PDFium Component delen: UrlRetrievalTimeoutMs staat standaard op 15000 ms met een nul-terugval van 30000, hooguit 8 aanvragen per sessie, 1 MiB per antwoord en 4 MiB in totaal waarbij mislukte antwoorden toch meetellen, en URL's boven 2048 tekens worden geweigerd
De limieten zijn constanten, geen suggesties: bytes uit een mislukt antwoord putten het budget alsnog uit, en omdat elke handtekening en tijdstempel apart verifieert groeit het slechtste geval met het aantal handtekeningen

De boekhouding is strenger dan hij op het eerste gezicht oogt. Bytes ontvangen uit een mislukt antwoord tellen toch mee voor het totaal, dus een server die 404 antwoordt met een grote pagina kan het budget niet gratis leegtrekken. De lezing die de per-antwoordlimiet overschrijdt breekt de download af in plaats van een afgekapte body door te geven aan de ASN.1-parser, en een HTTP 200 met een lege body wordt ronduit geweigerd, omdat het AIA-pad anders Data[0] van een lege array zou indexeren. Alleen kale http://- en https://-URL's gaan erdoor, zonder redirects, cookies, inloggegevens of automatische proxy-ontdekking, terwijl HTTPS zijn normale certificaat- en hostnaamcontroles houdt. URL-deduplicatie is met opzet beperkt tot één aanroep: de volgende validatie moet een pas gepubliceerde CRL kunnen zien. Het budget is ook per aanroep, niet per document, en ValidatePadesTrust verifieert elke handtekening en elk tijdstempeltoken apart, dus het slechtste geval groeit met het aantal handtekeningen

Waarom kan een verlopen WinHTTP-verzoek nog steeds in uw geheugen schrijven?

Omdat terugkeren bij timeout de callbacks die al in de lucht zijn niet annuleert. Het Windows-transport stuurt WinHTTP asynchroon aan en wacht op een event met de resterende sessietijd, en wanneer die wacht opgeeft, kan het verzoek nog steeds een lezing afronden en daarna signaleren. Richt de asynchrone lezing op een stackbuffer en die late afronding schrijft in een frame dat op dat moment bij een volstrekt andere functie hoort. De fix is eigendom, geen timing: het event en de 16-KB-leesbuffer wonen in een heap-record met twee referenties, één vastgehouden door de aanroeper en één pas vrijgegeven door de laatste HANDLE_CLOSING-callback, dus wie het laatst klaar is geeft het geheugen vrij

Waarom een verlopen WinHTTP-verzoek nog in het geheugen kan schrijven: terugkeren bij timeout laat callbacks in de lucht, dus de PDFium Component richt de asynchrone lezing op een heap-gealloceerd THttpState-record waarvan de 16-KB-buffer en twee referenties, één vastgehouden door de aanroeper en één vrijgegeven door de laatste HANDLE_CLOSING-callback, pas worden vrijgegeven als de laatste kant klaar is
Een late afronding mag zijn lezing nog afmaken nadat uw wacht heeft opgegeven; heap-eigendom met twee referenties betekent dat die schrijfactie in nog levend geheugen belandt
type
  PHttpState = ^THttpState;
  THttpState = record
    References: LongInt;               // aanroeper + laatste HANDLE_CLOSING-callback
    Event: THandle;
    Status, Count: DWORD;
    Buffer: array[0..16383] of Byte;   // asynchrone lezingen belanden hier, nooit op een stack
  end;

procedure ReleaseState(State: PHttpState);
begin
  if InterlockedDecrement(State.References) = 0 then
  begin
    CloseHandle(State.Event);
    Dispose(State);
  end;
end;

// In de status-callback: HANDLE_CLOSING is de laatste melding die WinHTTP
// voor het verzoek stuurt, dus hij laat de tweede referentie vallen
if Status = HttpHandleClosing then
begin
  ReleaseState(State);
  Exit;
end;

Wat libcurl op FPC Unix moet bieden

Een asynchrone resolver en een thread-safe build, of online-ophaling blijft uit. Op FPC Unix loopt het transport via libcurl, dezelfde afhankelijkheid achter de libcurl-tijdstempelbackend voor niet-Windows-doelen, en de binding weigert elke library waarvan het featuremasker een van CURL_VERSION_ASYNCHDNS of CURL_VERSION_THREADSAFE mist. De reden is dat CURLOPT_NOSIGNAL, dat een library in iemand anders zijn proces moet zetten, gecombineerd met een synchrone resolver betekent dat een DNS-opzoekvraag de timeout zomaar kan overleven. De tweede val is het afsluiten: curl_global_cleanup wacht niet op asynchrone DNS-threads, dus zodra libcurl is geïnitialiseerd blijft de module gemapt tot het proces afsluit in plaats van een achtergrondthread in ongeladen code te laten lopen. Faalt een van beide eisen, dan is SslCapabilities.OnlineRetrieval False en meldt SslVerifyOptionsDiagnostics psvdOnlineRetrievalIgnored in plaats van te doen alsof het netwerk is geraadpleegd

Wat de uitslag garandeert en wat niet

Een geldige RevocationStatus uit deze backend betekent dat actuele CRL's die de hele keten bestrijken zijn gevonden, geconfigureerd of gedownload, en dat geen enkele een certificaat eruit afkeurde; niets meer. Er is geen OCSP, dus een CA die revocatie alleen via OCSP publiceert laat de uitslag ongesteund achter, en een netwerkfout oogt precies als een CA die niets publiceert. Bedenk ook dat psvdNoCrlsConfigured alleen de CRL's beschrijft die u heeft geconfigureerd, dus met online-ophaling is het een hint, geen voorspelling van faling. Moet een audittrail reproduceerbaar zijn zonder netwerktoegang, laat NetworkPolicy dan op zijn ptnpOffline-standaard staan: er wordt geen ophaalsessie gemaakt en de backend opent nooit een verbinding, wat past bij het offlinecontract aan de CryptoAPI-kant uit offline PDF-handtekening-revocatiecontroles op Windows

De ophaalcode, de budgetten en de transportbindingen verschepen als broncode met de PDFium Delphi-component, dus u kunt exact bevestigen welke URL's een validatie mag contacteren en hoeveel ze mag downloaden voordat u ptnpOnline inschakelt op een server die onbetrouwbare documenten verwerkt