PDFium VCL перевіряє відклик PDF-підписів офлайн на Windows, додаючи CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY до проходу відклику в CertGetCertificateChain, бо прапорець cache-only, який використовується для побудови ланцюга, зовсім не покриває завантаження CRL чи OCSP. Від v3.119.1 офлайновий виклик ValidatePadesTrust лишається поза мережею, а чистий результат вимагає справжніх по-сертифікатних доказів відклику. Решта цього посту — про те, чому обидві половини того речення потребували виправлення
Обставини, які викривають проблему, звичайні. Сервіс валідації бігає на закритому Windows-хості, TPadesTrustValidationOptions.NetworkPolicy стоїть у ptnpOffline (це й типове значення), і оператор чекає, що кожна відповідь прийде з локального кешу сертифікатів. А тоді хтось помічає вихідні запити до distribution point CA в логах фаєрвола, чи пакетну роботу, що застрягає на всі UrlRetrievalTimeoutMs у 15000 мс на кожному підписі. Ніщо в коді не просило мережу. Windows пішла туди сама
Чому офлайн-побудова ланцюга все ще тягне CRL на Windows?
Бо CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL обмежує лише URL-завантаження, яким займається побудова ланцюга: AIA-витягування видавця, оновлення коренів і CTL. Документація Microsoft для CertGetCertificateChain каже прямо, що прапорець не стосується перевірки відклику. У відклику власний вимикач — CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY ($80000000), і без нього провайдери відклику вільні завантажити CRL чи надіслати OCSP-запит, хоч навколишній виклик виглядає офлайновим. PDFium VCL тепер OR-ить той прапорець у прохід відклику щоразу, коли OnlineRetrieval False, поверх ланцюгових прапорців, CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT і CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT. Це важливо не лише для затримки: OCSP-запит каже респондеру, який сертифікат ви роздивляєтеся, — а саме цього air-gapped валідатор і мусить уникати
uses
PDFium, FPdfCrypto, FPdfPades;
var
Options: TPadesTrustValidationOptions;
Report: TPadesValidationResult;
begin
Options := TPadesTrustValidationOptions.Default; // ptnpOffline, 15000 ms
Options.CheckRevocation := True; // типово False
Options.CheckTimeStamps := True;
// Офлайн тепер означає офлайн і для відклику: лише кешовані CRL і OCSP
// відповіді, жодного чекпойнта pcvstOnlineRetrieval не піднімається
Report := Pdf.ValidatePadesTrust(Options);
end;
Дві побудови ланцюга, два поля помилок
Бекенд Windows будує ланцюг двічі, і кожна побудова тепер має власний слот помилки. Перший виклик CertGetCertificateChain бігає без прапорців відклику і годує CertVerifyCertificateChainPolicy базовою політикою, що дає TrustStatus і TrustError. Другий виклик додає прапорці відклику. До v3.119.1 невдача того другого виклику писала GetLastError у TrustError, тож ланцюг, щойно верифікований як довірений, міг повернутися таким, що виглядає недовіреним, бо провайдер відклику гикнувся. Фікс читає GetLastError одразу і зберігає його в TPdfCmsVerifyResult.RevocationError, не чіпаючи вердикту першого проходу. І True від другого виклику теж не трактується як успіх; це лише означає, що Windows віддала назад контекст ланцюга, вартий інспекції
Що насправді доводить нульова маска помилок довіри?
Сама по собі — нічого. Агрегатний TrustStatus.dwErrorStatus нуль після проходу відклику каже, що жоден біт помилки не піднято, і ланцюг, у якому жоден елемент узагалі не ніс інформації відклику, може дати рівно це. Старіший код мапив «немає біта відклику, немає біта невідомого, немає офлайн-біта» прямо у валідний — класичний спосіб, яким валідатор звітує неперевірений сертифікат як чистий. Нова рутина ReadWinRevocationEvidence обходить кожен простий ланцюг і кожен елемент, відхиляє структури з замалою для безпечного читання cbSize, і звітує успіх лише коли існує щонайменше один не-кореневий елемент і кожен такий елемент несе CERT_REVOCATION_INFO, чий dwRevocationResult нульовий
// Стиснуто з обходу доказів: елемент рахується лише коли
// провайдер відклику справді відповів за нього
for J := 0 to ElementCount - 1 do
begin
Element := Elements[J];
ExcludedRoot := (J = ElementCount - 1) and
((Element^.TrustStatus.dwInfoStatus and
(CERT_TRUST_IS_SELF_SIGNED or CERT_TRUST_IS_CA_TRUSTED)) <> 0);
InfoPresent := (Element^.pRevocationInfo <> nil) and
(Element^.pRevocationInfo^.cbSize >= SizeOf(TCERT_REVOCATION_INFO));
if not ExcludedRoot then
begin
Inc(RequiredCount);
if not InfoPresent or
(Element^.pRevocationInfo^.dwRevocationResult <> 0) then
Complete := False;
end;
end;
Complete := Complete and (RequiredCount > 0); // ланцюг лише з кореня нічого не доводить
Результат провайдера тримається сирим. RevocationError тримає DWORD dwRevocationResult рівно так, як його повернув провайдер, надаючи перевагу помилці від відкликаного елемента, коли він є (CRYPT_E_REVOKED — це $80092010), а бітова маска довіри ніколи не переодягається в нативний код помилки. Мапінг на TPdfCmsRevocationReason навмисно грубий: pcrrCertificateRevoked з pcvsInvalid для явного відклику, pcrrChainUntrusted, коли ланцюг провалився з причин, не пов'язаних з відкликом, і pcrrUnknown для всього іншого. Windows міг спробувати OCSP замість CRL, тож офлайн чи невідомий результат не перекладається в pcrrCrlExpired. Бекенд верифікації CMS на OpenSSL може робити ті CRL-специфічні розрізнення, бо він оцінює лише ті CRL, які ви йому дасте, тоді як бекенд macOS SecTrust лишає поля в pcrrNone і нулі, що означає «немає детальної діагностики», а не «пройшло»
Де закінчується виключення кореня
CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT легітимно пропускає якір, бо ніхто не публікує CRL, який відкликає корінь проти нього самого. Пастка — у вирішенні, який елемент є коренем. PDFium VCL виключає останній елемент простого ланцюга лише коли його dwInfoStatus мічить його самопідписаним ($00000008) чи явно CA-довіреним ($00004000). Офлайн-хост часто не може витягнути відсутнього видавця, тож ланцюг закінчується на проміжному; трактування фінального елемента того часткового ланцюга як кореня мовчки викинуло б той сертифікат, чий статус відклику найімовірніше відсутній у кеші. Той елемент лишається в обов'язковому наборі, відповіді провайдера не має, і результат залишається pcvsIndeterminate
Як результати відклику підпису й часового штампа тримаються порізно?
Як окремі поля, які ніколи не перезаписують одне одного. PAdES-валідатор верифікує від'єднаний CMS підпису документа та приєднаний CMS токена часового штампа RFC 3161 у двох незалежних викликах, і v3.119.0 дала кожному власну діагностику на TPadesSignatureValidation: RevocationReason і NativeRevocationError для підписувача, TimeStampRevocationReason і NativeTimeStampRevocationError для TSA. Відкликаний сертифікат TSA тому не може видавати себе за відкликаного підписувача, а невдача часового штампа не стирає результат цілісності, який уже встановлено. Коли CheckRevocation False, чи валідація не дійшла до тієї стадії, поля лишаються в pcrrNone і 0, тож читайте їх завжди поруч із RevocationStatus і TimeStampRevocationStatus
for I := 0 to High(Report.Signatures) do
begin
S := Report.Signatures[I];
case S.RevocationStatus of
pcsInvalid:
Log(Format('sig %d: signer revoked, provider 0x%.8x',
[I, S.NativeRevocationError]));
pcsIndeterminate:
Log(Format('sig %d: revocation unknown, reason %d, provider 0x%.8x',
[I, Ord(S.RevocationReason), S.NativeRevocationError]));
pcsNotChecked:
Log(Format('sig %d: revocation not checked', [I]));
end;
if S.TimeStampRevocationStatus = pcsIndeterminate then
Log(Format('sig %d: TSA revocation unknown, provider 0x%.8x',
[I, S.NativeTimeStampRevocationError]));
end;
Звіт доказів іде за тим самим правилом. CSV-експорт додає revocationReason, nativeRevocationError і колонки часового штампа аж до nativeTimeStampRevocationError у кінець наявного порядку колонок, тож старіші парсери працюють далі, а JSON-експорт додає відповідні поля, не міняючи сенсу старих. Якщо офлайн-валідація вперто повертається невизначеною, довговічний фікс — вище за течією: збирайте матеріал валідації в момент підписання, як описує довгострокові PDF-підписи з часовими штампами RFC 3161 і DSS, а не сподівайтеся, що в машини, яка верифікує, теплий кеш
Що тестова матриця доводить, а що ні
Матриця верифікації Windows пройшла 30 контрольованих сценаріїв chain-API і один справжній офлайн CMS smoke на кожній цілі Delphi і FPC Win32 і Win64. Справжній smoke верифікує валідний підпис під недовіреною приватною CA, тоді як чисті та явно відкликані результати походять зі заглушених відповідей CertGetCertificateChain, а не з встановлених якорів довіри чи живого завантаження. Це чесна межа, яку варто назвати: обробка прапорців, ізоляція помилок і обхід доказів пришпилені, але те, що містить кеш відклику конкретної машини в конкретний день, — все ще справа Windows, і порожній кеш тепер коректно дає «невідомо» замість мережевого запиту чи фальшивого «валідно»
Обробка офлайн-відклику, діагностика на рівні полів і експорти доказів — частина API валідації PDF-підписів у PDFium VCL для Delphi та C++Builder, поруч із бекендами OpenSSL і macOS для крос-платформених розгортань