Articolo tecnico

Recupero AIA e CRL via OpenSSL per firme PDF in Delphi

PDFium VCL ora può completare una catena di firma PDF e controllare la revoca in rete sul suo backend OpenSSL: quando OnlineRetrieval è abilitato, ConfigureSslCmsVerifier installa un verifier che scarica i certificati intermedi mancanti dagli URL AIA caIssuers e le CRL dai CRL distribution points, dentro un budget fisso di tempo, richieste e byte per chiamata di verifica. I certificati scaricati sono solo materiale di catena. La fiducia continua a venire esclusivamente dallo store di sistema e dagli anchor che configurate

Il divario che questo colma si mostra la prima volta che validate PDF reali su un server Linux. Una buona parte dei firmatari incorpora nel CMS solo il proprio certificato foglia, quindi OpenSSL non raggiunge nessuna root, TrustStatus torna invalid e la revoca non gira mai perché la catena non è mai diventata affidabile. Prima della v3.121.0 il backend OpenSSL descritto in verificare firme PDF con OpenSSL in PDFium VCL era strettamente offline e OnlineRetrieval non aveva effetto su di lui. Una cosa vale la pena dirlo subito: il motore PDFium in sé non fa alcuna verifica CMS, quindi ogni regola qui sotto vive nel livello PAdES del componente e nel suo binding OpenSSL, dove potete leggerla

In che ordine il backend OpenSSL verifica, recupera e controlla?

Prima l'integrità, poi la fiducia, poi la revoca, e la rete viene toccata solo tra i passi che la richiedono. VerifyCmsWithSsl controlla la firma CMS e i signed attributes (RFC 5652) con la valutazione della catena soppressa, e se quello fallisce ritorna subito, prima ancora che esista una sessione di fetch, così un documento con byte rotti non innesca nessuna richiesta in uscita. Solo se la catena poi fallisce e OnlineRetrieval è attivo segue i link AIA e riverifica. I CRL distribution points vengono recuperati solo quando la catena è affidabile, perché una CRL appesa a un percorso non fidato non prova nulla. I tre verdetti restano separati per tutto il percorso: una firma valida con catena incompleta è comunque riferita come firma valida

Ordine di VerifyCmsWithSsl nel backend OpenSSL del PDFium Component: il controllo firma CMS gira con valutazione della catena soppressa, quindi byte rotti non toccano mai la rete; RetrieveIntermediates segue gli URL AIA caIssuers solo dopo un fallimento di catena con OnlineRetrieval abilitato, e RetrieveCrls recupera le CRL dei distribution point in uno store separato una volta che la catena è fidata
Integrità, poi fiducia, poi revoca: la rete viene toccata solo tra i passi che la richiedono, e una CRL appesa a un percorso non fidato non prova nulla
uses
  PDFium, FPdfCrypto, FPdfCryptoSsl, FPdfPades;

var
  Pdf: TPdf;
  Probe: TPdfCmsVerifyOptions;
  Diags: TPdfSslVerifyDiagnostics;
  Trust: TPadesTrustValidationOptions;
  Verdict: TPadesValidationResult;
  I: Integer;
begin
  ConfigureSslTrustAnchors(LoadCorporateRoots);   // DER; l'unica fiducia extra
  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 per default
  Trust.NetworkPolicy := ptnpOnline;
  Trust.CheckRevocation := True;
  Trust.UrlRetrievalTimeoutMs := 10000;           // per chiamata di verifica

  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;

Perché i certificati scaricati non finiscono mai nello store di fiducia?

Perché gli URL vengono dal certificato che state validando, e a sceglierli è stato il firmatario. La voce authorityInfoAccess caIssuers (RFC 5280 §4.2.2.1) è un indizio su dove vive l'issuer, nient'altro. Se qualunque cosa risponde a quell'URL finisse nello store degli anchor di fiducia, chiunque potrebbe firmare con una chiave fatta in casa, puntare l'AIA al proprio server e ricevere un verdetto verde. RetrieveIntermediates passa quindi ogni certificato analizzato a CMS_add1_cert, che lo colloca nell'insieme untrusted di questa sola struttura CMS, e OpenSSL deve comunque costruire un percorso da esso verso un anchor che avete configurato o che lo store di sistema già possiede. C'è anche un motivo più silenzioso: l'argomento certificati di CMS_verify non è un sostituto drop-in per i certificati incorporati nel CMS, quindi aggiungere al CMS stesso è la strada affidabile

Il loop di recupero è volutamente stretto. RetrieveIntermediates gira al massimo per 4 round, ognuno dei quali raccoglie gli URL caIssuers da ogni certificato ora nel CMS, e si ferma appena un round non aggiunge nulla o il budget di tempo si esaurisce. Una risposta deve decodificare con d2i_X509 come un singolo certificato DER che consuma l'intero corpo; i byte in coda sono rifiutati, e un bundle PKCS#7 certs-only servito da un URL .p7c viene saltato anziché spacchettato. Il metodo di accesso OCSP nella stessa estensione AIA è ignorato, dato che questo backend non parla OCSP. Sul lato revoca, RetrieveCrls legge solo gli URI fullName di ogni DistributionPoint (RFC 5280 §4.2.1.13) dai certificati del CMS e dagli anchor configurati, e le CRL scaricate finiscono in un secondo X509_STORE indipendente con controllo CRL full-chain, così una CRL mancante o stantia cambia RevocationStatus senza toccare mai TrustStatus

// Condensato da VerifyCmsWithSsl (FPdfCryptoSsl.pas); setup BIO omesso.
// Ogni chiamata _CMS_verify riceve un content BIO fresco
if _CMS_verify(Cms, nil, nil, Bio, nil,
  CMS_NO_SIGNER_CERT_VERIFY or CMS_BINARY) <> 1 then
  Exit;                                   // firma rotta: nessuna rete affatto
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, solo untrusted
  // riverifica la catena contro lo stesso store di anchor
end;

if Options.CheckRevocation and (FetchSession <> nil) and
   (Result.TrustStatus = pcvsValid) then
  RetrievedCrls := RetrieveCrls(Cms, FetchSession);
// secondo store, separato: CRL configurate più quelle recuperate
Store := BuildStore(True, RetrievedCrls, RevocationChecked, CrlsMalformed);

Quanto costa al massimo una chiamata di verifica?

Un tetto fisso, fatto rispettare da un solo TPdfCryptoFetchSession che i passi AIA e CRL di una singola chiamata di verifica condividono. I limiti sono costanti in FPdfCryptoHttp, non suggerimenti:

  • Tempo: UrlRetrievalTimeoutMs, che vale 15000 per default sia in TPdfCmsVerifyOptions.Default sia in TPadesTrustValidationOptions.Default; una sessione creata con 0 ricade su 30000, e l'orologio parte una volta che la firma è passata, coprendo ogni richiesta successiva
  • Richieste: al massimo 8 per sessione, contate prima di tentare il trasporto, quindi un host morto consuma comunque uno slot
  • Byte: 1 MiB per risposta e 4 MiB in totale, con URL più lunghi di 2048 caratteri rifiutati prima di ogni connessione
I tetti rigidi di un TPdfCryptoFetchSession condiviso dai passi AIA e CRL di una chiamata di verifica in PDFium Component: UrlRetrievalTimeoutMs vale 15000 ms per default con ricaduta a 30000 sullo zero, al massimo 8 richieste per sessione, 1 MiB per risposta e 4 MiB in totale con le risposte fallite che contano comunque, e URL oltre 2048 caratteri rifiutati
I limiti sono costanti, non suggerimenti: i byte di una risposta fallita drenano comunque il budget, e poiché ogni firma e ogni timestamp verifica separatamente il caso peggiore cresce col numero di firme

La contabilità è più severa di quanto sembri a prima vista. I byte ricevuti da una risposta fallita contano comunque sul totale, quindi un server che risponde 404 con una pagina grossa non drena gratis il budget. La lettura che attraversa il limite per risposta abortisce il download invece di passare un corpo troncato al parser ASN.1, e un HTTP 200 con corpo vuoto è rifiutato seccamente, perché il percorso AIA altrimenti indicizzerebbe Data[0] di un array vuoto. Passano solo URL http:// e https:// semplici, senza redirect, cookie, credenziali né scoperta automatica del proxy, mentre l'HTTPS mantiene i suoi normali controlli di certificato e hostname. La deduplicazione degli URL è volutamente limitata a una singola chiamata: la validazione successiva deve poter vedere una CRL pubblicata da poco. Il budget è inoltre per chiamata, non per documento, e ValidatePadesTrust verifica ogni firma e ogni token timestamp separatamente, quindi il caso peggiore cresce col numero di firme

Perché una richiesta WinHTTP in timeout può ancora scrivere nella vostra memoria?

Perché tornare al timeout non annulla le callback già in volo. Il trasporto Windows pilota WinHTTP in modo asincrono e aspetta su un evento col tempo residuo della sessione, e quando quell'attesa molla, la richiesta può comunque completare una lettura e segnalare dopo. Puntate la lettura asincrona su un buffer dello stack e quel completamento tardivo scrive in un frame che a quel punto appartiene a qualche funzione non correlata. La correzione è di ownership, non di timing: l'evento e il buffer di lettura da 16 KB vivono in un record heap con due riferimenti, uno tenuto dal chiamante e uno rilasciato solo dalla callback finale HANDLE_CLOSING, così chi finisce per ultimo libera la memoria

Perché una richiesta WinHTTP in timeout può ancora scrivere in memoria: tornare al timeout lascia callback in volo, quindi il PDFium Component punta la lettura asincrona su un record THttpState allocato su heap il cui buffer da 16 KB e i due riferimenti, uno tenuto dal chiamante e uno rilasciato dalla callback finale HANDLE_CLOSING, vengono liberati solo quando l'ultimo lato finisce
Un completamento tardivo può finire la sua lettura dopo che la vostra attesa ha mollato; l'ownership su heap con due riferimenti significa che quella scrittura atterra in memoria ancora viva
type
  PHttpState = ^THttpState;
  THttpState = record
    References: LongInt;               // chiamante + callback HANDLE_CLOSING finale
    Event: THandle;
    Status, Count: DWORD;
    Buffer: array[0..16383] of Byte;   // le letture asincrone atterrano qui, mai su uno stack
  end;

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

// Nella callback di stato: HANDLE_CLOSING è l'ultima notifica che WinHTTP
// invia per la richiesta, quindi lascia cadere il secondo riferimento
if Status = HttpHandleClosing then
begin
  ReleaseState(State);
  Exit;
end;

Che cosa deve fornire libcurl su FPC Unix

Un resolver asincrono e una build thread-safe, altrimenti il recupero online resta spento. Su FPC Unix il trasporto passa per libcurl, la stessa dipendenza dietro il backend timestamp libcurl per target non Windows, e il binding rifiuta qualsiasi libreria la cui feature mask manchi di CURL_VERSION_ASYNCHDNS o CURL_VERSION_THREADSAFE. La ragione è che CURLOPT_NOSIGNAL, che una libreria dentro il processo di qualcun altro deve impostare, combinato con un resolver sincrono significa che una lookup DNS può semplicemente sopravvivere al timeout. La seconda trappola è lo shutdown: curl_global_cleanup non aspetta i thread DNS asincroni, quindi una volta inizializzato libcurl il modulo resta mappato finché il processo esce, invece di lasciare che un thread di background corra dentro codice scaricato. Quando uno dei due requisiti fallisce, SslCapabilities.OnlineRetrieval è False e SslVerifyOptionsDiagnostics riferisce psvdOnlineRetrievalIgnored invece di fingere che la rete sia stata consultata

Che cosa il risultato garantisce e che cosa no

Un RevocationStatus valido da questo backend significa che sono state trovate, configurate o scaricate CRL attuali che coprono l'intera catena, e nessuna elencava un certificato di essa; nient'altro. Non c'è OCSP, quindi una CA che pubblica la revoca solo via OCSP lascia il risultato unsupported, e un fallimento di rete sembra esattamente una CA che non pubblica nulla. Notate anche che psvdNoCrlsConfigured descrive solo le CRL che avete configurato, quindi col recupero online è un indizio, non una previsione di fallimento. Quando una traccia di audit deve essere riproducibile senza accesso alla rete, lasciate NetworkPolicy al suo default ptnpOffline: nessuna sessione di fetch viene creata e il backend non apre mai una connessione, il che corrisponde al contratto offline del lato CryptoAPI descritto in controlli di revoca offline delle firme PDF su Windows

Il codice di recupero, i budget e i binding di trasporto viaggiano come sorgente con il PDFium Delphi component, così potete confermare esattamente quali URL una validazione può contattare e quanto può scaricare prima di abilitare ptnpOnline su un server che tratta documenti non fidati