Technický článek

Získávání AIA a CRL pro podpisy PDF přes OpenSSL v Delphi

PDFium VCL teď na svém OpenSSL backendu dokáže dokončit řetěz podpisu PDF a zkontrolovat revokaci přes síť: když je zapnuté OnlineRetrieval, nainstaluje ConfigureSslCmsVerifier verifikátor, který stahuje chybějící mezilehlé certifikáty z URL AIA caIssuers a CRL z CRL distribution points, uvnitř pevného rozpočtu na čas, požadavky a bajty na každé ověřovací volání. Stažené certifikáty jsou vždycky jen materiál pro řetěz. Důvěra dál přichází výhradně ze systémového úložiště a z kotev, které nakonfigurujete

Mezera, kterou tohle zavírá, se ukáže poprvé, když validujete reálné PDF na Linuxovém serveru. Velká část podepisujících vkládá do CMS jen vlastní leaf certifikát, takže OpenSSL se k rootovi nedopracuje, TrustStatus se vrací jako invalid a revokace se nikdy nespustí, protože řetěz se nikdy nestal důvěryhodným. Před v3.121.0 byl OpenSSL backend popsaný v Ověřování PDF podpisů přes OpenSSL v PDFium VCL striktně offline a OnlineRetrieval na něm neměla žádný efekt. Jedna věc stojí za říct hned na začátku: samotný engine PDFium nedělá žádnou CMS verifikaci, takže všechna níže uvedená pravidla bydlí v PAdES vrstvě komponenty a v její OpenSSL vazbě, kde si je můžete přečíst

V jakém pořadí OpenSSL backend ověřuje, stahuje a kontroluje?

Nejdřív integrita, pak důvěra, pak revokace a sítě se dotkne jen mezi kroky, které to potřebují. VerifyCmsWithSsl zkontroluje CMS podpis a signed attributes (RFC 5652) s potlačeným vyhodnocením řetězu, a pokud to selže, vrací se hned, dřív než vůbec existuje fetch session, takže dokument s rozbitými byty nespustí žádný odchozí požadavek. Jen když pak řetěz selže a je zapnuté OnlineRetrieval, následuje AIA odkazy a ověří znovu. CRL distribution points se stahují až ve chvíli, kdy je řetěz důvěryhodný, protože CRL visící na nedůvěryhodné cestě nic nedokazuje. Tři verdikty zůstávají oddělené po celou dobu: validní podpis s nekompletním řetězem se pořád hlásí jako validní podpis

Pořadí VerifyCmsWithSsl v OpenSSL backendu PDFium Component: kontrola CMS podpisu běží s potlačeným vyhodnocením řetězu, takže rozbité byty se sítě nikdy nedotknou; RetrieveIntermediates následuje URL AIA caIssuers až po selhání řetězu se zapnutým OnlineRetrieval a RetrieveCrls stahuje CRL z distribution points do samostatného úložiště, jakmile je řetěz důvěryhodný
Integrita, pak důvěra, pak revokace: sítě se dotkne jen mezi kroky, které to potřebují, a CRL visící na nedůvěryhodné cestě nic nedokazuje
uses
  PDFium, FPdfCrypto, FPdfCryptoSsl, FPdfPades;

var
  Pdf: TPdf;
  Probe: TPdfCmsVerifyOptions;
  Diags: TPdfSslVerifyDiagnostics;
  Trust: TPadesTrustValidationOptions;
  Verdict: TPadesValidationResult;
  I: Integer;
begin
  ConfigureSslTrustAnchors(LoadCorporateRoots);   // DER; jediná extra důvěra
  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;  // defaultně ptnpOffline
  Trust.NetworkPolicy := ptnpOnline;
  Trust.CheckRevocation := True;
  Trust.UrlRetrievalTimeoutMs := 10000;           // na jedno ověřovací volání

  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;

Proč se stažené certifikáty nikdy nepřidávají do trust store?

Protože URL pocházejí z certifikátu, který se právě validuje, a vybral je podepisující. Položka authorityInfoAccess caIssuers (RFC 5280 §4.2.2.1) je jen tip, kde bydlí issuer, nic víc. Kdyby cokoli, co na tohle URL odpoví, putovalo do úložiště trust anchorů, mohl by si kdokoli podepsat vlastním klíčem, nasměrovat AIA na vlastní server a dostat zelený verdikt. RetrieveIntermediates proto předává každý vyparsovaný certifikát do CMS_add1_cert, který ho umístí do nedůvěryhodné množiny právě téhle CMS struktury, a OpenSSL pořád musí postavit cestu od něj ke kotvě, kterou jste nakonfigurovali, nebo kterou už drží systémové úložiště. Je tu i tišší důvod: argument certifikátů CMS_verify není drop-in náhradou za certifikáty vložené v CMS, takže přidávat přímo do CMS je spolehlivá cesta

Retrievací smyčka je záměrně úzká. RetrieveIntermediates běží nejvýše 4 kola, z nichž každé posbírá URL caIssuers z každého certifikátu, který je teď v CMS, a zastaví se, jakmile kolo nepřidá nic nebo se vyčerpá časový rozpočet. Odpověď se musí dekódovat přes d2i_X509 jako jediný DER certifikát, který zkonzumuje celé tělo; koncové byty se odmítají a PKCS#7 bundle jen s certifikáty podávaný z URL .p7c se přeskočí místo rozbalení. Přístupová metoda OCSP ve stejné extens AIA se ignoruje, protože tenhle backend nemluví OCSP. Na straně revokace čte RetrieveCrls jen URI fullName každého DistributionPoint (RFC 5280 §4.2.1.13) z certifikátů CMS a z nakonfigurovaných kotev a stažené CRL jdou do druhého, nezávislého X509_STORE s full-chain kontrolou CRL, takže chybějící nebo zastaralá CRL změní RevocationStatus, aniž by se kdykoli dotkla TrustStatus

// Zhuštěno z VerifyCmsWithSsl (FPdfCryptoSsl.pas); nastavení BIO vynecháno.
// Každé volání _CMS_verify dostane čerstvý content BIO
if _CMS_verify(Cms, nil, nil, Bio, nil,
  CMS_NO_SIGNER_CERT_VERIFY or CMS_BINARY) <> 1 then
  Exit;                                   // rozbitý podpis: vůbec žádná síť
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, jen nedůvěryhodné
  // ověřit řetěz znovu proti témuž úložišti kotev
end;

if Options.CheckRevocation and (FetchSession <> nil) and
   (Result.TrustStatus = pcvsValid) then
  RetrievedCrls := RetrieveCrls(Cms, FetchSession);
// druhé, samostatné úložiště: nakonfigurované CRL plus stažené
Store := BuildStore(True, RetrievedCrls, RevocationChecked, CrlsMalformed);

Kolik maximálně stojí jedno ověřovací volání?

Pevný strop, vynucovaný jednou TPdfCryptoFetchSession, kterou sdílejí kroky AIA a CRL jednoho ověřovacího volání. Limity jsou konstanty v FPdfCryptoHttp, ne doporučení:

  • Čas: UrlRetrievalTimeoutMs, který se v TPdfCmsVerifyOptions.Default i TPadesTrustValidationOptions.Default defaultně staví na 15000; session vytvořená s 0 spadne zpět na 30000 a hodiny začnou běžet, jakmile podpis projde, a pokrývají každý pozdější požadavek
  • Požadavky: nejvýše 8 na session, počítané dřív, než se pokusí transport, takže mrtvý host si stejně spotřebuje slot
  • Bajty: 1 MiB na odpověď a 4 MiB celkově, přičemž URL delší než 2048 znaků se odmítnou dřív, než se naváže jakékoli spojení
Tvrdé stropy jedné TPdfCryptoFetchSession sdílené kroky AIA a CRL ověřovacího volání v PDFium Component: UrlRetrievalTimeoutMs má default 15000 ms s nulovým fallbackem na 30000, nejvýše 8 požadavků na session, 1 MiB na odpověď a 4 MiB celkově, přičemž neúspěšné odpovědi se do rozpočtu taky počítají, a URL nad 2048 znaků se odmítají
Limity jsou konstanty, ne doporučení: bajty z neúspěšné odpovědi rozpočet stejně vysypou a protože se každý podpis i časové razítko ověřují zvlášť, nejhorší případ roste s počtem podpisů

Účtování je přísnější, než na první pohled vypadá. Bajty přijaté z neúspěšné odpovědi se do celku počítají taky, takže server odpovídající 404 s velkou stránkou rozpočet nemůže vysypat zadarmo. Čtení, které překročí limit na odpověď, stáhne download místo předání useknutého těla ASN.1 parseru a HTTP 200 s prázdným tělem se odmítne rovnou, protože by cesta AIA jinak indexovala Data[0] prázdného pole. Projdou jen holé URL http:// a https://, bez redirectů, cookies, přihlašovacích údajů a automatického hledání proxy, zatímco HTTPS si drží své běžné kontroly certifikátů a hostname. Deduplikace URL je záměrně omezená na jedno volání: další validace musí mít šanci vidět čerstvě publikovanou CRL. Rozpočet je taky na volání, ne na dokument, a ValidatePadesTrust ověřuje každý podpis a každý token časového razítka zvlášť, takže nejhorší případ roste s počtem podpisů

Proč může po timeoutu vypršeného požadavku WinHTTP ještě zapsat do vaší paměti?

Protože návrat po timeoutu nezruší callbacky, které už jsou ve vzduchu. Windows transport pohání WinHTTP asynchronně a čeká na událost se zbytkem času session, a když tohle čekání kapituluje, požadavek může pořád dokončit čtení a signalizovat navíc. Namiřte asynchronní čtení na buffer na stacku a pozdní dokončení zapíše do rádce, který mezitím patří úplně jiné funkci. Oprava je o vlastnictví, ne o načasování: událost a čtecí buffer o 16 KB bydlí v heap záznamu se dvěma referencemi, jednu drží volající a druhou uvolňuje až závěrečný callback HANDLE_CLOSING, takže paměť uvolní ta strana, která skončí poslední

Proč může požadavek WinHTTP po timeoutu ještě zapsat do paměti: návrat po timeoutu nechá callbacky ve vzduchu, takže PDFium Component míří asynchronní čtení na heap alokovaný záznam THttpState, jehož 16KB buffer a dvě reference — jedna u volajícího, druhá uvolňovaná závěrečným callbackem HANDLE_CLOSING — se uvolní, až skončí poslední strana
Pozdní dokončení může dopsat své čtení poté, co vaše čekání kapitulovalo; heap vlastnictví se dvěma referencemi znamená, že tenhle zápis dopadne do paměti, která je pořád živá
type
  PHttpState = ^THttpState;
  THttpState = record
    References: LongInt;               // volající + závěrečný callback HANDLE_CLOSING
    Event: THandle;
    Status, Count: DWORD;
    Buffer: array[0..16383] of Byte;   // asynchronní čtení dopadají sem, nikdy na stack
  end;

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

// V status callbacku: HANDLE_CLOSING je poslední notifikace, kterou WinHTTP
// pro požadavek posílá, takže upustí druhou referenci
if Status = HttpHandleClosing then
begin
  ReleaseState(State);
  Exit;
end;

Co musí libcurl dodat na FPC Unix

Asynchronní resolver a build bezpečný pro vlákna, nebo online retrieval zůstane vypnutá. Na FPC Unix jde transport přes libcurl, tutéž závislost za backendem časových razítek přes libcurl pro cíle mimo Windows, a vazba odmítne jakoukoli knihovnu, jejíž feature masce chybí CURL_VERSION_ASYNCHDNS nebo CURL_VERSION_THREADSAFE. Důvodem je, že CURLOPT_NOSIGNAL, které musí nastavit knihovna uvnitř cizího procesu, v kombinaci se synchronním resolverem znamená, že DNS lookup může prostě přežít timeout. Druhá pastka je ukončení: curl_global_cleanup nečeká na asynchronní DNS vlákna, takže jakmile se libcurl inicializuje, modul zůstává namapovaný až do konce procesu místo to, aby pustil vlákno na pozadí do odmapovaného kódu. Když selže některá z obou podmínek, je SslCapabilities.OnlineRetrieval False a SslVerifyOptionsDiagnostics hlásí psvdOnlineRetrievalIgnored místo předstírání, že se sítě dotklo

Co výsledek garantuje a co ne

Validní RevocationStatus od tohohle backendu znamená, že se našly, nakonfigurovaly nebo stáhly aktuální CRL pokrývající celý řetěz a žádná z nich nevyjmenovala certifikát z něj; nic víc. OCSP tu není, takže CA, která publikuje revokaci jen přes OCSP, nechává výsledek na unsupported a síťový výpadek vypadá úplně stejně jako CA, která nepublikuje nic. Všimněte si taky, že psvdNoCrlsConfigured popisuje jen CRL, které nakonfigurovali vy, takže s online retrieval je to tip, ne předpověď selhání. Když musí být auditní stopa reprodukovatelná bez přístupu k síti, nechte NetworkPolicy na defaultu ptnpOffline: nevytváří se žádná fetch session a backend nikdy neotevře spojení, což odpovídá offline kontraktu na straně CryptoAPI popsanému v Offline kontroly revokace PDF podpisů na Windows v Delphi

Retrievací kód, rozpočty i transportní vazby shipují jako zdroják s komponentou PDFium pro Delphi, takže si můžete potvrdit, přesně které URL si validace může kontaktovat a kolik smí stáhnout, než zapnete ptnpOnline na serveru, který má co do činění s nedůvěryhodnými dokumenty