PDFium VCL тепер може добудувати ланцюжок підпису PDF і перевірити відкликання через мережу на своєму OpenSSL backend: коли увімкнено OnlineRetrieval, ConfigureSslCmsVerifier встановлює верифікатор, який завантажує відсутні проміжні сертифікати з AIA caIssuers URL і CRL із точок розповсюдження CRL, у межах фіксованого бюджету часу, запитів і байтів на один виклик верифікації. Завантажені сертифікати — лише матеріал ланцюжка. Довіра досі приходить виключно з системного сховища і якорів, які ви налаштуєте
Розрив, який це закриває, вилазить уперше, коли ви валідуєте реальні PDF на Linux-сервері. Чимала частка підписувачів вбудовує в CMS лише власний leaf-сертифікат, тож OpenSSL не дотягується до кореня, TrustStatus повертається invalid, а перевірка відкликання взагалі не запускається, бо ланцюжок так і не став довіреним. До v3.121.0 OpenSSL backend, описаний у статті про верифікацію підписів PDF з OpenSSL у PDFium VCL, був строго офлайн, і OnlineRetrieval не мав на нього жодного впливу. Одне варто сказати одразу: сам рушій PDFium взагалі не робить CMS-верифікації, тож усе правило нижче живе в PAdES-шарі компонента і його OpenSSL-біндінгу, де його можна прочитати
У якому порядку OpenSSL backend верифікує, тягне і перевіряє?
Спершу цілісність, потім довіра, потім відкликання, і мережу торкають лише поміж кроків, які її потребують. VerifyCmsWithSsl перевіряє підпис CMS і signed attributes (RFC 5652) із придушеною оцінкою ланцюжка, і якщо це провалюється, повертається негайно, ще до того, як fetch-сесія взагалі існуватиме, тож документ із битими байтами не тригерить жодного вихідного запиту. Лише якщо ланцюжок тоді провалюється і OnlineRetrieval увімкнено, він іде за AIA-посиланнями і верифікує знову. Точки розповсюдження CRL тягнуть лише щойно ланцюжок став довіреним, бо CRL, що висить на недовереному шляху, нічого не доводить. Три вердикти лишаються роздільними впродовж усього: валідний підпис з неповним ланцюжком досі звітується як валідний підпис
uses
PDFium, FPdfCrypto, FPdfCryptoSsl, FPdfPades;
var
Pdf: TPdf;
Probe: TPdfCmsVerifyOptions;
Diags: TPdfSslVerifyDiagnostics;
Trust: TPadesTrustValidationOptions;
Verdict: TPadesValidationResult;
I: Integer;
begin
ConfigureSslTrustAnchors(LoadCorporateRoots); // DER; єдина додаткова довіра
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 за замовчуванням
Trust.NetworkPolicy := ptnpOnline;
Trust.CheckRevocation := True;
Trust.UrlRetrievalTimeoutMs := 10000; // на один виклик верифікації
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;
Чому завантажені сертифікати ніколи не додаються до trust store?
Бо URL приходять із сертифіката, що валідується, і їх обрав підписувач. Вхід authorityInfoAccess caIssuers (RFC 5280 §4.2.2.1) — це підказка, де живе issuer, і не більш. Якщо б усе, що відповідає на той URL, потрапляло до сховища trust-anchor, будь-хто міг би підписати саморобним ключем, навести AIA на власний сервер і отримати зелений вердикт. Тому RetrieveIntermediates передає кожен розпарсений сертифікат у CMS_add1_cert, який кладе його в недовірку множину цієї однієї структури CMS, і OpenSSL досі мусить добудувати шлях від нього до якоря, який ви налаштували, чи який уже тримає системне сховище. Є й тихіша причина: аргумент сертифікатів у CMS_verify — не заміна наскрізь для сертифікатів, вбудованих у CMS, тож додавання в саму CMS — надійний шлях
Петля завантаження навмисно вузька. RetrieveIntermediates робить щонайбільше 4 кола, кожне збирає caIssuers URL з кожного сертифіката, який тепер у CMS, і зупиняється щойно коло нічого не додає або вичерпано бюджет часу. Відповідь мусить декодуватися через d2i_X509 як один DER-сертифікат, що з'їдає все тіло; хвостові байти відкидаються, а PKCS#7-пакет лише з сертифікатами, поданий з URL .p7c, пропускається, а не розпаковується. Метод доступу OCSP у тому самому розширенні AIA ігнорується, бо цей backend не розмовляє OCSP. На боці відкликання RetrieveCrls читає лише URI fullName кожного DistributionPoint (RFC 5280 §4.2.1.13) з сертифікатів CMS і налаштованих якорів, а завантажені CRL ідуть у друге, незалежне X509_STORE з перевіркою CRL по всьому ланцюжку, тож відсутня чи протухла CRL змінює RevocationStatus, не торкаючись ніколи TrustStatus
// Скорочено з VerifyCmsWithSsl (FPdfCryptoSsl.pas); налаштування BIO опущено.
// Кожен виклик _CMS_verify отримує свіжий content BIO
if _CMS_verify(Cms, nil, nil, Bio, nil,
CMS_NO_SIGNER_CERT_VERIFY or CMS_BINARY) <> 1 then
Exit; // зламаний підпис: жодної мережі
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, лише недовірке
// перевірити ланцюжок знову проти того самого сховища якорів
end;
if Options.CheckRevocation and (FetchSession <> nil) and
(Result.TrustStatus = pcvsValid) then
RetrievedCrls := RetrieveCrls(Cms, FetchSession);
// друге, окреме сховище: налаштовані CRL плюс завантажені
Store := BuildStore(True, RetrievedCrls, RevocationChecked, CrlsMalformed);
Скільки коштує один виклик верифікації максимум?
Фіксована стеля, яку забезпечує один TPdfCryptoFetchSession, яким AIA- і CRL-кроки одного виклику верифікації діляться. Межі — константи в FPdfCryptoHttp, а не поради:
- Час:
UrlRetrievalTimeoutMs, усталене значення якого 15000 і вTPdfCmsVerifyOptions.Default, і вTPadesTrustValidationOptions.Default; сесія, створена з 0, падає назад до 30000, а годинник стартує щойно підпис пройшов, покриваючи кожен пізніший запит - Запити: щонайбільше 8 на сесію, рахуються до спроби транспорту, тож мертвий хост досі витрачає слот
- Байти: 1 МіБ на відповідь і 4 МіБ сумарно, з відмовою URL довших за 2048 символів ще до будь-якого з'єднання
Облік суворіший, ніж здається спершу. Байти, отримані з невалідної відповіді, досі рахуються в суму, тож сервер, що відповідає 404 великою сторінкою, не може з'їсти бюджет безкоштовно. Читання, що переходить ліміт на відповідь, перериває завантаження замість передачі обрізаного тіла парсеру ASN.1, а HTTP 200 із порожнім тілом відкидається одразу, бо шлях AIA інакше індексував би Data[0] порожнього масиву. Проходять лише прямі URL http:// і https://, без редиректів, кук, credential-ів чи автовиявлення проксі, тоді як HTTPS тримає свої звичайні перевірки сертифіката і hostname. Дедуплікація URL навмисно скоуплена на один виклик: наступна валідація мусить мати змогу побачити щойно опубліковану CRL. Бюджет також на виклик, не на документ, і ValidatePadesTrust верифікує кожен підпис і кожен токен мітки часу окремо, тож найгірший випадок росте з кількістю підписів
Чому запит WinHTTP з таймаутом досі може писати у вашу пам'ять?
Бо повернення за таймаутом не скасовує callback-и, уже в польоті. Windows-транспорт веде WinHTTP асинхронно і чекає на подію з рештою часу сесії, і коли те чекання здається, запит може досі завершити читання і сигналізувати після. Направте асинхронне читання на стековий буфер — і те пізнє завершення пише у фрейм, який на той час належить якійсь нерідній функції. Фікс — у володінні, а не в таймінгу: подія і 16-кілобайтовий буфер читання живуть у heap-записі з двома посиланнями, одне тримає викликач, а друге вивільняється лише фінальним callback-ом HANDLE_CLOSING, тож хто бік не фінішував останнім — той і звільняє пам'ять
type
PHttpState = ^THttpState;
THttpState = record
References: LongInt; // викликач + фінальний callback HANDLE_CLOSING
Event: THandle;
Status, Count: DWORD;
Buffer: array[0..16383] of Byte; // асинхронні читання приземляються тут, ніколи на стеку
end;
procedure ReleaseState(State: PHttpState);
begin
if InterlockedDecrement(State.References) = 0 then
begin
CloseHandle(State.Event);
Dispose(State);
end;
end;
// У статусному callback: HANDLE_CLOSING — останнє сповіщення, яке WinHTTP
// надсилає для запиту, тож воно скидає друге посилання
if Status = HttpHandleClosing then
begin
ReleaseState(State);
Exit;
end;
Що libcurl мусить дати на FPC Unix
Асинхронний resolver і thread-safe збірка, інакше онлайн-завантаження лишається вимкненим. На FPC Unix транспорт іде через libcurl — ту саму залежність, що стоїть за libcurl backend міток часу для не-Windows цілей, — і біндінг відмовляє будь-якій бібліотеці, чия маска можливостей не має ані CURL_VERSION_ASYNCHDNS, ані CURL_VERSION_THREADSAFE. Причина в тому, що CURLOPT_NOSIGNAL, який бібліотека в чужому процесі мусить поставити, у парі з синхронним resolver-ом означає: DNS-запит може просто пережити таймаут. Друга пастка — завершення: curl_global_cleanup не чекає асинхронні DNS-потоки, тож щойно libcurl ініціалізовано, модуль лишається змапованим до виходу процесу, замість пускати фоновий потік у вивантажений код. Коли будь-яка з вимог провалюється, SslCapabilities.OnlineRetrieval — False, а SslVerifyOptionsDiagnostics звітує psvdOnlineRetrievalIgnored, замість прикидатися, що мережу консультували
Що результат гарантує, а що ні
Валідний RevocationStatus від цього backend означає, що були знайдені, налаштовані чи завантажені актуальні CRL, що покривають весь ланцюжок, і жодна не перелічила сертифікат з нього; і нічого більше. OCSP немає, тож CA, що публікує відкликання лише через OCSP, лишає результат unsupported, а мережевий збій виглядає точнісінько як CA, що не публікує нічого. Зауважте також, що psvdNoCrlsConfigured описує лише CRL, які налаштували ви, тож з онлайн-завантаженням це підказка, а не прогноз провалу. Коли журнал аудиту мусить бути відтворюваним без доступу до мережі, лишайте NetworkPolicy на його усталеному ptnpOffline: fetch-сесія не створюється і backend ніколи не відкриває з'єднання, що збігається з офлайн-контрактом на боці CryptoAPI, описаним у статті про офлайн перевірку відкликання підписів PDF на Windows
Код завантаження, бюджети і транспортні біндінги виходять як сирці разом із компонентом PDFium для Delphi, тож ви можете точно підтвердити, з якими URL валідація може зв'язуватися і скільки вона може завантажити, перш ніж увімкнете ptnpOnline на сервері, що обробляє недоврені документи