Технічна стаття

Офлайн-перевірки відклику PDF-підписів на Windows у Delphi

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 валідатор і мусить уникати

Два незалежні вимикачі охороняють офлайн-валідацію PDF-підписів на Windows: CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL обмежує лише побудову ланцюга на кшталт AIA-витягування видавця і коренів, тоді як відклик потребує CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY, бо без нього провайдери досі завантажують CRL і шлють OCSP-запити, які викривають, який сертифікат валідується
PDFium VCL OR-ить прапорець cache-only відклику в прохід відклику щоразу, коли OnlineRetrieval False, тож ptnpOffline валідація довіри лишається поза мережею для обох проходів
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 нульовий

Обхід доказів, який ReadWinRevocationEvidence виконує для кожного елемента ланцюга Windows у Delphi: pRevocationInfo мусить бути присутнім, cbSize мусить бути достатнім для читання, dwRevocationResult мусить бути нульовим, а кінцевий елемент виключається лише коли позначений самопідписаним чи CA-довіреним, тож нульова маска помилок довіри більше не сховає неперевірений сертифікат
Чистий вердикт вимагає щонайменше одного не-кореневого елемента та відповіді від кожного обов'язкового елемента, а RevocationError тримає сирий DWORD провайдера на кшталт CRYPT_E_REVOKED
// Стиснуто з обходу доказів: елемент рахується лише коли
// провайдер відклику справді відповів за нього
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

Відклик підпису і часового штампа лишається порізно в PDFium VCL: від'єднаний CMS підпису документа наповнює RevocationReason і NativeRevocationError, приєднаний CMS токена RFC 3161 наповнює TimeStampRevocationReason і NativeTimeStampRevocationError, а неперевірені стадії лишають pcrrNone і нуль поруч зі своїми полями статусу
Відкликаний сертифікат TSA тому не може видавати себе за відкликаного підписувача, а невдача часового штампа ніколи не стирає результат цілісності, який верифікація підпису вже встановила
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 для крос-платформених розгортань