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ă
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 înTPdfCmsVerifyOptions.Default, cât și înTPadesTrustValidationOptions.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
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
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