Tehnički članak

OpenSSL AIA i CRL dohvat za PDF potpise u Delphiju

PDFium VCL sada može na svom OpenSSL backendu dovršiti lanac PDF potpisa i provjeriti revokaciju preko mreže: kad je OnlineRetrieval uključen, ConfigureSslCmsVerifier instalira verifiera koji iz AIA caIssuers URL-ova skida nedostajuće međucertifikate, a CRL-ove s CRL distribution points, unutar fiksnog budžeta vremena, zahtjeva i bajtova po pozivu vrednovanja. Skinuti certifikati su samo i isključivo materijal lanca. Trust i dalje dolazi isključivo iz sistemskog storea i sidara koje konfigurirate

Jaz koji se time zatvara pokazuje se prvog trenutka kad na Linux serveru validirate PDF-ove iz stvarnog svijeta. Velik udio potpisnika u CMS ugrađuje samo vlastiti leaf certifikat, pa OpenSSL ne može doći do korijena, TrustStatus vraća se nevaljan, a revokacija se uopće ne pokrene jer lanac nikad nije postao pouzdan. Prije v3.121.0 OpenSSL backend opisan u članku o vrednovanju PDF potpisa OpenSSL-om u PDFium VCL bio je strogo offline i OnlineRetrieval na njega nije djelovao. Jedno vrijedi reći unaprijed: sam PDFium engine ne radi nikakvu CMS verifikaciju, pa sva pravila dolje žive u PAdES sloju komponente i u njezinu OpenSSL bindingu, gdje ih možete i pročitati

U kojem redoslijedu OpenSSL backend verificira, dohvaća i provjerava?

Najprije integritet, zatim trust, pa revokacija, i mreža se dira samo među koracima kojima treba. VerifyCmsWithSsl provjerava CMS potpis i potpisane atribute (RFC 5652) s prigušenom evaluacijom lanca, i ako to padne, vraća se odmah, prije nego fetch sesija uopće postoji, pa dokument sa slomljenim bajtovima ne okida nikakav odlazni zahtjev. Samo ako lanac zatim padne a OnlineRetrieval je uključen, slijedi AIA poveznice i verificira ponovno. CRL distribution points dohvaćaju se tek kad je lanac pouzdan, jer CRL koja visi o nepouzdanoj putanji ne dokazuje ništa. Tri presude ostaju odvojene kroz cijeli tok: valjan potpis s nepotpunim lancem i dalje se javlja kao valjan potpis

Redoslijed VerifyCmsWithSsl u OpenSSL backendu PDFium Componenta: CMS provjera potpisa radi s prigušenom evaluacijom lanca pa slomljeni bajtovi nikad ne diraju mrežu; RetrieveIntermediates slijedi AIA caIssuers URL-ove tek nakon pada lanca s uključenim OnlineRetrieval, a RetrieveCrls dohvaća distribution point CRL-ove u poseban store čim je lanac pouzdan
Integritet, zatim trust, pa revokacija: mreža se dira samo među koracima kojima treba, a CRL koja visi o nepouzdanoj putanji ne dokazuje ništa
uses
  PDFium, FPdfCrypto, FPdfCryptoSsl, FPdfPades;

var
  Pdf: TPdf;
  Probe: TPdfCmsVerifyOptions;
  Diags: TPdfSslVerifyDiagnostics;
  Trust: TPadesTrustValidationOptions;
  Verdict: TPadesValidationResult;
  I: Integer;
begin
  ConfigureSslTrustAnchors(LoadCorporateRoots);   // DER; jedini dodatni 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 po zadanim
  Trust.NetworkPolicy := ptnpOnline;
  Trust.CheckRevocation := True;
  Trust.UrlRetrievalTimeoutMs := 10000;           // po pozivu vrednovanja

  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;

Zašto skinuti certifikati nikad ne ulaze u trust store?

Jer URL-ovi dolaze od certifikata koji se vrednuje, a odabrao ih je potpisnik. authorityInfoAccess caIssuers unos (RFC 5280 §4.2.2.1) je naznaka gdje stanuje izdavatelj, ništa više. Da bi ono što odgovori na taj URL završilo u trust-anchor storeu, svatko bi mogao potpisati vlastitim domaćim ključem, uperiti AIA u vlastiti server i primiti zelenu presudu. RetrieveIntermediates stoga svaki parsirani certifikat predaje CMS_add1_cert, koji ga smješta u nepouzdani skup ove jedne CMS strukture, a OpenSSL i dalje mora iz njega sagraditi putanju do sidra koje ste konfigurirali ili koje već drži sistemski store. Postoji i tiši razlog: argument certifikata u CMS_verify nije drop-in zamjena za certifikate ugrađene u CMS, pa je dodavanje u sam CMS pouzdan put

Dohvatna petlja namjerno je uska. RetrieveIntermediates radi najviše 4 krugova, svaki skuplja caIssuers URL-ove sa svih certifikata koji su tada u CMS-u, i staje čim krug ništa ne doda ili je vremenski budžet potrošen. Odgovor se mora dekodirati s d2i_X509 kao jedan DER certifikat koji troši cijelo tijelo; repni bajtovi se odbacuju, a PKCS#7 certs-only paket poslužen s .p7c URL-a preskače se umjesto da se raspakira. OCSP metoda pristupa u istom AIA proširenju ignorira se, jer ovaj backend ne govori OCSP. Na strani revokacije RetrieveCrls čita samo fullName URI-je svakog DistributionPointa (RFC 5280 §4.2.1.13) iz CMS certifikata i konfiguriranih sidara, a skinute CRL-ove ostavljaju u drugom, neovisnom X509_STORE s provjerom CRL-ova cijelog lanca, pa nedostajuća ili zastarjela CRL mijenja RevocationStatus a da nikad ne dira TrustStatus

// Sažeto iz VerifyCmsWithSsl (FPdfCryptoSsl.pas); BIO postava izostavljena.
// Svaki _CMS_verify poziv dobiva svježi content BIO
if _CMS_verify(Cms, nil, nil, Bio, nil,
  CMS_NO_SIGNER_CERT_VERIFY or CMS_BINARY) <> 1 then
  Exit;                                   // slomljen potpis: bez mreže uopće
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, samo nepouzdano
  // verificiraj lanac ponovno prema istom anchor storeu
end;

if Options.CheckRevocation and (FetchSession <> nil) and
   (Result.TrustStatus = pcvsValid) then
  RetrievedCrls := RetrieveCrls(Cms, FetchSession);
// drugi, odvojeni store: konfigurirane CRL-ove plus dohvaćene
Store := BuildStore(True, RetrievedCrls, RevocationChecked, CrlsMalformed);

Koliko jedan poziv vrednovanja košta najviše?

Fiksni strop, nametnut jednim TPdfCryptoFetchSession koji AIA i CRL koraci jednog poziva vrednovanja dijele. Granice su konstante u FPdfCryptoHttp, a ne preporuke:

  • Vrijeme: UrlRetrievalTimeoutMs, koji u oba TPdfCmsVerifyOptions.Default i TPadesTrustValidationOptions.Default ima zadanu 15000; sesija stvorena s 0 pada na 30000, a sat kreće čim potpis prođe, pokrivajući svaki kasniji zahtjev
  • Zahtjevi: najviše 8 po sesiji, brojano prije nego se transport i pokuša, pa i mrtvi host potroši slot
  • Bajtovi: 1 MiB po odgovoru i 4 MiB ukupno, s URL-ovima duljim od 2048 znakova odbijenima prije bilo kakve veze
Kruti stropovi jednog TPdfCryptoFetchSession koji dijele AIA i CRL koraci poziva vrednovanja u PDFium Componentu: UrlRetrievalTimeoutMs ima zadanu 15000 ms s nultim padom na 30000, najviše 8 zahtjeva po sesiji, 1 MiB po odgovoru i 4 MiB ukupno pri čemu i neuspjeli odgovori računaju, a URL-ovi preko 2048 znakova se odbijaju
Granice su konstante, ne preporuke: bajtovi iz neuspjelog odgovora i dalje troše budžet, a budući da se svaki potpis i svaki vremenski žig verificira odvojeno, najgori slučaj raste s brojem potpisa

Računovodstvo je strože nego što na prvi pogled izgleda. Bajtovi primljeni iz neuspjelog odgovora i dalje računaju u ukupno, pa server koji odgovori 404 s velikom stranicom ne može besplatno isušiti budžet. Čitanje koje preskoči granicu po odgovoru prekida preuzimanje umjesto da ASN.1 parseru proslijedi odrezano tijelo, a HTTP 200 s praznim tijelom odbacuje se odmah, jer bi AIA put inače indeksirao Data[0] praznog niza. Prolaze samo obični http:// i https:// URL-ovi, bez preusmjeravanja, kolačića, vjerodajnica ili automatskog otkrivanja proxyja, dok HTTPS zadržava svoje uobičajene provjere certifikata i imena hosta. Deduplikacija URL-ova namjerno je ograničena na jedan poziv: sljedeća validacija mora moći vidjeti svježe objavljenu CRL. Budžet je također po pozivu, ne po dokumentu, a ValidatePadesTrust verificira svaki potpis i svaki vremenski žig zasebno, pa najgori slučaj raste s brojem potpisa

Zašto WinHTTP zahtjev čiji je isteklo vrijeme može i dalje pisati u Vašu memoriju?

Jer povratak na istek vremena ne otkazuje callbackove koji su već u letu. Windows transport pokreće WinHTTP asinkrono i čeka na eventu s preostalim vremenom sesije, i kad taj wait odustane, zahtjev može i dalje dovršiti čitanje i signalizirati poslije. Uperite asinkrono čitanje u stack buffer i taj kasni dovršetak zapisuje u okvir koji tada pripada nekoj sasvim drugoj funkciji. Popravak je vlasništvo, ne tajming: event i 16 KB read buffer žive u heap zapisu s dvije reference, jednu drži pozivatelj a drugu otpušta samo konačni HANDLE_CLOSING callback, pa slobodu memorije dobije strana koja zadnja završi

Zašto WinHTTP zahtjev s isteklim vremenom može i dalje pisati u memoriju: povratak na isteku ostavlja callbackove u letu, pa PDFium Component usmjerava asinkrono čitanje u heap alocirani THttpState zapis čiji 16 KB buffer i dvije reference, jednu drži pozivatelj a drugu otpušta konačni HANDLE_CLOSING callback, oslobođaju se tek kad zadnja strana završi
Kasni dovršetak može dovršiti svoje čitanje poslije nego Vaš wait odustane; heap vlasništvo s dvije reference znači da taj zapis slijeće u memoriju koja je još živa
type
  PHttpState = ^THttpState;
  THttpState = record
    References: LongInt;               // pozivatelj + konačni HANDLE_CLOSING callback
    Event: THandle;
    Status, Count: DWORD;
    Buffer: array[0..16383] of Byte;   // asinkrona čitanja slijeću ovdje, nikad na stack
  end;

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

// U status callbacku: HANDLE_CLOSING je zadnja obavijest koju WinHTTP
// šalje za zahtjev, pa ona otpušta drugu referencu
if Status = HttpHandleClosing then
begin
  ReleaseState(State);
  Exit;
end;

Što libcurl mora dati na FPC Unixu

Asinkroni resolver i thread-safe build, ili online dohvat ostaje isključen. Na FPC Unixu transport ide kroz libcurl, istu ovisnost iza libcurl timestamp backendea za ne-Windows ciljeve, i binding odbija svaku biblioteku čija feature maska nema i CURL_VERSION_ASYNCHDNS i CURL_VERSION_THREADSAFE. Razlog je što CURLOPT_NOSIGNAL, koji biblioteka u nečijem tuđem procesu mora postaviti, u kombinaciji sa sinkronim resolverom znači da DNS lookup može slobodno nadživjeti istek vremena. Druga je zamka gašenje: curl_global_cleanup ne čeka asinkrone DNS niti, pa jednom inicijaliziran libcurl ostaje mapiran dok proces ne izađe, umjesto da pozadinska nit zaleti se u istovareni kod. Kad bilo koji uvjet padne, SslCapabilities.OnlineRetrieval je False a SslVerifyOptionsDiagnostics javlja psvdOnlineRetrievalIgnored umjesto da se pretvara da je mreža bila konzultirana

Što rezultat jamči, a što ne

Valjan RevocationStatus s ovog backendea znači da su nađene, konfigurirane ili skinute tekuće CRL-ove koje pokrivaju cijeli lanac i da nijedna nije stavila certifikat iz njega na popis; ništa više. OCSP-a nema, pa CA koji revokaciju objavljuje samo kroz OCSP ostavlja rezultat nepodržanim, a mrežna greška izgleda točno kao CA koji ništa ne objavljuje. Imajte na umu i da psvdNoCrlsConfigured opisuje samo CRL-ove koje ste konfigurirali, pa je uz online dohvat to naznaka, a ne prognoza pada. Kad revizijski trag mora biti reproduciv bez pristupa mreži, ostavite NetworkPolicy na zadanom ptnpOffline: fetch sesija se ne stvara i backend nikad ne otvara vezu, što se poklapa s offline ugovorom na CryptoAPI strani opisanim u offline provjerama revokacije PDF potpisa na Windowsima

Dohvatni kod, budžeti i transportni bindingi isporučuju se kao izvor uz PDFium Delphi komponentu, pa možete potvrditi točno koje URL-ove validacija smije kontaktirati i koliko smije skinuti prije nego uključite ptnpOnline na serveru koji rukuje nepouzdanim dokumentima