Articol tehnic

Preluare AIA și CRL OpenSSL pentru semnături PDF în Delphi

PDFium VCL poate completa acum un lanț de semnătură PDF și verifica revocarea peste rețea pe backend-ul său OpenSSL: când OnlineRetrieval e activat, ConfigureSslCmsVerifier instalează un verificator care descarcă certificatele intermediare lipsă din URL-urile AIA caIssuers și CRL-urile din punctele de distribuție CRL, în interiorul unui buget fix de timp, de cereri și de bytes per apel de verificare. Certificatele descărcate sunt doar material de lanț. Încrederea vine în continuare exclusiv din magazinul de sistem și din ancorele pe care le configurați

Golul pe care îl închide apare prima dată când validați PDF-uri reale pe un server Linux. O mare parte din semnatori înglobează în CMS doar propriul certificat frunză, deci OpenSSL nu poate ajunge la un root, TrustStatus se întoarce invalid, iar revocarea nu rulează niciodată pentru că lanțul nu a devenit niciodată de încredere. Înainte de v3.121.0, backend-ul OpenSSL descris în verificarea semnăturilor PDF cu OpenSSL în PDFium VCL era strict offline, iar OnlineRetrieval nu avea niciun efect asupra lui. Un lucru merită spus de la început: motorul PDFium în sine nu face deloc verificare CMS, deci fiecare regulă de mai jos trăiește în stratul PAdES al componentei și în legătura sa OpenSSL, unde o puteți citi

În ce ordine verifică, descarcă și controlează backend-ul OpenSSL?

Mai întâi integritatea, apoi încrederea, apoi revocarea, iar rețeaua e atinsă doar între pașii care au nevoie de ea. VerifyCmsWithSsl verifică semnătura CMS și atributele semnate (RFC 5652) cu evaluarea lanțului suprimată, iar dacă asta pică se întoarce imediat, înainte ca o sesiune de descărcare să existe măcar, deci un document cu byte-i stricați nu declanșază nicio cerere outbound. Doar dacă lanțul pică apoi și OnlineRetrieval e activat urmărește legăturile AIA și verifică din nou. Punctele de distribuție CRL sunt descărcate doar după ce lanțul e de încredere, pentru că un CRL atârnat de o cale fără încredere nu dovedește nimic. Cele trei verdicte rămân separate de-a lungul întregului proces: o semnătură validă cu un lanț incomplet e tot raportată ca semnătură validă

Ordinea lui VerifyCmsWithSsl în backend-ul OpenSSL din PDFium Component: verificarea semnăturii CMS rulează cu evaluarea lanțului suprimată, deci byte-i stricați nu ating niciodată rețeaua; RetrieveIntermediates urmărește URL-urile AIA caIssuers doar după o cădere de lanț cu OnlineRetrieval activat, iar RetrieveCrls descarcă CRL-urile punctelor de distribuție într-un magazin separat odată ce lanțul e de încredere
Integritate, apoi încredere, apoi revocare: rețeaua e atinsă doar între pașii care au nevoie de ea, iar un CRL atârnat de o cale fără încredere nu dovedește nimic
uses
  PDFium, FPdfCrypto, FPdfCryptoSsl, FPdfPades;

var
  Pdf: TPdf;
  Probe: TPdfCmsVerifyOptions;
  Diags: TPdfSslVerifyDiagnostics;
  Trust: TPadesTrustValidationOptions;
  Verdict: TPadesValidationResult;
  I: Integer;
begin
  ConfigureSslTrustAnchors(LoadCorporateRoots);   // DER; singura încredere în plus
  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 implicit
  Trust.NetworkPolicy := ptnpOnline;
  Trust.CheckRevocation := True;
  Trust.UrlRetrievalTimeoutMs := 10000;           // per apel de verificare

  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;

De ce certificatele descărcate nu intră niciodată în magazinul de încredere?

Pentru că URL-urile vin din certificatul care se validează, iar semnatarul le-a ales. Intrarea authorityInfoAccess caIssuers (RFC 5280 §4.2.2.1) e un indiciu despre unde stă emitentul, nimic mai mult. Dacă orice ar răspunde la URL-ul acela ar intra în magazinul de ancore de încredere, oricine ar putea semna cu o cheie făcută în casă, ar îndrepta AIA spre propriul server și ar primi un verdict verde. De aceea RetrieveIntermediates pasa fiecare certificat parsat către CMS_add1_cert, care îl pune în mulțimea fără încredere a acestei singure structuri CMS, iar OpenSSL mai are de construit o cale de la el la o ancoră configurată de dumneavoastră sau deja deținută de magazinul de sistem. Există și un motiv mai discret: argumentul certificat al lui CMS_verify nu e un înlocuitor drop-in pentru certificatele înglobate în CMS, deci adăugarea chiar în CMS e calea sigură

Bucla de descărcare e deliberat îngustă. RetrieveIntermediates rulează cel mult 4 runde, fiecare adunând URL-urile caIssuers din fiecare certificat aflat acum în CMS, și se oprește îndată ce o rundă nu adaugă nimic sau bugetul de timp s-a consumat. Un răspuns trebuie să se decodeze cu d2i_X509 ca un singur certificat DER care consumă întregul corp; byte-ii de la coadă sunt respinși, iar un pachet PKCS#7 doar-certificate servit de la un URL .p7c este sărit în loc să fie despachetat. Metoda de acces OCSP din aceeași extensie AIA e ignorată, fiindcă backend-ul acesta nu vorbește OCSP. Pe partea de revocare, RetrieveCrls citește doar URI-urile fullName ale fiecărui DistributionPoint (RFC 5280 §4.2.1.13) din certificatele CMS și din ancorele configurate, iar CRL-urile descărcate intră într-un al doilea X509_STORE independent, cu verificare CRL pe tot lanțul, astfel încât un CRL lipsă sau învechit schimbă RevocationStatus-ul fără să atingă vreodată TrustStatus-ul

// Condensat din VerifyCmsWithSsl (FPdfCryptoSsl.pas); configurarea BIO e omisă.
// Fiecare apel _CMS_verify primește un content BIO proaspăt
if _CMS_verify(Cms, nil, nil, Bio, nil,
  CMS_NO_SIGNER_CERT_VERIFY or CMS_BINARY) <> 1 then
  Exit;                                   // semnătură stricată: zero rețea
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, doar fără încredere
  // verifică lanțul din nou contra aceluiași magazin de ancore
end;

if Options.CheckRevocation and (FetchSession <> nil) and
   (Result.TrustStatus = pcvsValid) then
  RetrievedCrls := RetrieveCrls(Cms, FetchSession);
// al doilea magazin, separat: CRL-uri configurate plus cele descărcate
Store := BuildStore(True, RetrievedCrls, RevocationChecked, CrlsMalformed);

Ce costă cel mult un apel de verificare?

Un plafon fix, impus de un singur TPdfCryptoFetchSession pe care pașii AIA și CRL ai unui apel de verificare îl partajează. Limitele sunt constante în FPdfCryptoHttp, nu sugestii:

  • Timp: UrlRetrievalTimeoutMs, care are implicit 15000 atât în TPdfCmsVerifyOptions.Default, cât și în TPadesTrustValidationOptions.Default; o sesiune creată cu 0 cade pe 30000, iar ceasul pornește odată ce semnătura a trecut, acoperind fiecare cerere ulterioară
  • Cereri: cel mult 8 per sesiune, numărate înainte ca transportul să fie încercat, deci un host mort consumă tot un slot
  • Bytes: 1 MiB per răspuns și 4 MiB în total, cu URL-uri mai lungi de 2048 de caractere refuzate înainte de orice conexiune
Plafonurile dure ale unui TPdfCryptoFetchSession partajat de pașii AIA și CRL ai unui apel de verificare în PDFium Component: UrlRetrievalTimeoutMs are implicit 15000 ms cu fallback zero de 30000, cel mult 8 cereri per sesiune, 1 MiB per răspuns și 4 MiB în total cu răspunsurile eșuate contorizate tot, iar URL-urile peste 2048 de caractere sunt refuzate
Limitele sunt constante, nu sugestii: bytes dintr-un răspuns eșuat consumă tot bugetul, iar pentru că fiecare semnătură și timestamp se verifică separat, cazul cel mai rău crește cu numărul de semnături

Contabilitatea e mai strictă decât pare la prima vedere. Bytes primiți dintr-un răspuns eșuat contează tot la total, deci un server care răspunde 404 cu o pagină mare nu poate goli bugetul pe gratis. Citirea care traversează limita per răspuns întrerupe descărcarea în loc să paseze mai departe un corp trunchiat parserului ASN.1, iar un HTTP 200 cu corp vid e respins frontal, pentru că calea AIA ar indexa altfel Data[0]-ul unui tablou vid. Trec doar URL-uri http:// și https:// simple, fără redirecționări, cookie-uri, credențiale sau descoperire automată de proxy, în timp ce HTTPS își păstrează verificările obișnuite de certificat și de nume de host. Deduplicarea URL-urilor e limitată la un singur apel, din intenție: validarea următoare trebuie să poată vedea un CRL proaspăt publicat. Bugetul e tot per apel, nu per document, iar ValidatePadesTrust verifică fiecare semnătură și fiecare token de timestamp separat, deci cazul cel mai rău crește cu numărul de semnături

De ce poate o cerere WinHTTP scursă din timp să scrie tot în memoria dumneavoastră?

Pentru că întoarcerea la expirarea timpului nu anulează callback-urile deja în zbor. Transportul Windows conduce WinHTTP asincron și așteaptă pe un eveniment cu timpul rămas al sesiunii, iar când așteptarea aceea renunță, cererea poate totuși completa o citire și semnala după. Îndreptați citirea asincronă către un buffer pe stack și completarea târzie aceea scrie într-un cadru care, între timp, aparține unei funcții complet străine. Repararea e despre ownership, nu despre timing: evenimentul și bufferul de citire de 16 KB trăiesc într-o înregistrare pe heap cu două referințe, una deținută de apelant și una eliberată doar de callback-ul final HANDLE_CLOSING, deci oricare parte termină ultima eliberează memoria

De ce o cerere WinHTTP scursă din timp poate scrie tot în memorie: întoarcerea la expirare lasă callback-uri în zbor, deci PDFium Component îndreaptă citirea asincronă către o înregistrare THttpState alocată pe heap al cărei buffer de 16 KB și două referințe, una deținută de apelant și una eliberată de callback-ul final HANDLE_CLOSING, sunt eliberate doar când ultima parte termină
O completare târzie își poate termina citirea după ce așteptarea dumneavoastră a renunțat; ownership-ul pe heap cu două referințe înseamnă că scrierea aceea aterizează în memorie încă vie
type
  PHttpState = ^THttpState;
  THttpState = record
    References: LongInt;               // apelant + callback-ul final HANDLE_CLOSING
    Event: THandle;
    Status, Count: DWORD;
    Buffer: array[0..16383] of Byte;   // citirile asincrone aterizează aici, niciodată pe stack
  end;

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

// În callback-ul de status: HANDLE_CLOSING este ultima notificare WinHTTP
// trimisă pentru cerere, deci renunță la a doua referință
if Status = HttpHandleClosing then
begin
  ReleaseState(State);
  Exit;
end;

Ce trebuie să ofere libcurl pe FPC Unix

Un resolver asincron și un build thread-safe, sau descărcarea online rămâne dezactivată. Pe FPC Unix transportul trece prin libcurl, aceeași dependență din spatele backend-ului de timestamp libcurl pentru ținte non-Windows, iar legătura refuză orice bibliotecă a cărei mască de funcții nu are nici CURL_VERSION_ASYNCHDNS, nici CURL_VERSION_THREADSAFE. Motivul e că CURLOPT_NOSIGNAL, pe care o bibliotecă din procesul altcuiva trebuie să-l seteze, combinat cu un resolver sincron înseamnă că o căutare DNS poate pur și simplu să supraviețuiască timeout-ului. A doua capcană e închiderea: curl_global_cleanup nu așteaptă thread-urile DNS asincrone, deci odată ce libcurl a fost inițializat modulul rămâne mapat până se termină procesul, în loc să lase un thread de fundal să alerge în cod descărcat. Când una dintre condiții pică, SslCapabilities.OnlineRetrieval este False și SslVerifyOptionsDiagnostics raportează psvdOnlineRetrievalIgnored în loc să pretindă că rețeaua a fost consultată

Ce garantează și ce nu garantează rezultatul

Un RevocationStatus valid din backend-ul acesta înseamnă că au fost găsite, configurate sau descărcate CRL-uri actuale care acoperă tot lanțul și niciuna nu lista un certificat din el; nimic mai mult. Nu există OCSP, deci o CA care publică revocarea doar prin OCSP lasă rezultatul nesuportat, iar o defecțiune de rețea arată exact ca o CA care nu publică nimic. De asemenea, țineți minte că psvdNoCrlsConfigured descrie doar CRL-urile configurate de dumneavoastră, deci cu descărcare online e un indiciu, nu o prognoză de eșec. Când o pistă de audit trebuie să fie reproductibilă fără acces la rețea, lăsați NetworkPolicy la implicitul ptnpOffline: nu se creează nicio sesiune de descărcare, iar backend-ul nu deschide niciodată o conexiune, ceea ce corespunde contractului offline de partea CryptoAPI descris în verificările offline de revocare a semnăturilor PDF pe Windows

Codul de descărcare, bugetele și legăturile de transport sosesc ca sursă împreună cu componenta PDFium pentru Delphi, deci puteți confirma exact ce URL-uri poate contacta o validare și cât poate descărca înainte să activați ptnpOnline pe un server care procesează documente fără încredere