Техническая статья

Офлайн-проверки отзыва PDF-подписей на Windows в Delphi

PDFium VCL проверяет отзыв PDF-подписей офлайн на Windows, добавляя CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY в revocation-проход CertGetCertificateChain, потому что флаг cache-only, используемый при построении цепочки, вообще не покрывает доставку CRL или OCSP. С v3.119.1 офлайновый вызов ValidatePadesTrust не выходит в сеть, а чистый результат требует реальных улик отзыва на каждый сертификат. Остаток поста — о том, почему обе половины этого предложения требовали починки

Конфигурация, вскрывающая проблему, заурядная. Сервис валидации бежит на запертом Windows-хосте, TPadesTrustValidationOptions.NetworkPolicy стоит ptnpOffline (это ещё и дефолт), и оператор ждёт, что каждый ответ придёт из локального кэша сертификатов. А потом кто-то замечает в логе файрвола исходящие запросы к точке распространения CA, или батч, висящий все полные UrlRetrievalTimeoutMs в 15000 мс на каждой подписи. Код сеть не просил. Windows сходила туда сама

Почему офлайновая постройка цепочки всё ещё тянет CRL на Windows?

Потому что CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL ограничивает только URL-доставку, которую делает построение цепочки: подгрузки AIA-эмитентов, обновления корней и CTL. Документация Microsoft по CertGetCertificateChain прямо говорит, что флаг не относится к проверке отзыва. У отзыва свой выключатель — CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY ($80000000), и без него revocation-провайдеры вольны скачать CRL или отправить OCSP-запрос, хотя окружающий вызов выглядит офлайновым. PDFium VCL теперь OR-ит этот флаг в revocation-проход всякий раз, когда OnlineRetrieval равно False, поверх цепочных флагов, CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT и CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT. Это важно не только ради латентности: OCSP-запрос говорит респондеру, на какой сертификат вы смотрите, — ровно то, чего офлайн-валидатор должен избегать

Два независимых выключателя стерегут офлайн-валидацию PDF-подписей на Windows: CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL ограничивает лишь построение цепочки вроде подгрузок AIA-эмитентов и корней, а отзыву нужен CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY, потому что без него провайдеры всё равно скачивают CRL и шлют OCSP-запросы, раскрывающие, какой сертификат валидируется
PDFium VCL OR-ит флаг cache-only для отзыва в revocation-проход всякий раз, когда 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, так что цепочка, только что верифицированная как доверенная, могла вернуться выглядящей недоверенной из-за икоты revocation-провайдера. Фикс читает GetLastError немедленно и хранит его в TPdfCmsVerifyResult.RevocationError, не трогая вердикт первого прохода. И True от второго вызова тоже не считается успехом: он лишь значит, что Windows вернула контекст цепочки, который стоит осмотреть

Что на самом деле доказывает нулевая маска ошибок доверия?

Сама по себе — ничего. Суммарный TrustStatus.dwErrorStatus равный нулю после revocation-прохода говорит лишь, что ни один бит ошибки не поднялся, а цепочка, где ни один элемент вообще не нёс информации об отзыве, выдаёт ровно это. Прежний код мапил «нет бита отозванности, нет бита неизвестности, нет офлайн-бита» напрямую в «валидно» — классический способ, которым валидатор рапортует непроверенный сертификат как чистый. Новая рутина ReadWinRevocationEvidence обходит каждую простую цепочку и каждый элемент, отвергает структуры, чей cbSize слишком мал для безопасного чтения, и репортит успех только когда существует хотя бы один не-корневой элемент и каждый такой элемент несёт CERT_REVOCATION_INFO с dwRevocationResult равным нулю

Обход улик, который ReadWinRevocationEvidence выполняет на каждом элементе цепочки Windows в Delphi: pRevocationInfo обязан присутствовать, cbSize должен быть достаточно велик для чтения, dwRevocationResult должен быть нулём, а конечный элемент исключается только при пометке self-signed или CA-trusted, так что нулевая маска ошибок доверия больше не спрячет непроверенный сертификат
Чистый вердикт требует хотя бы одного не-корневого элемента и ответа от каждого обязательного элемента, а RevocationError хранит сырой DWORD провайдера вроде CRYPT_E_REVOKED
// Сокращено из обхода улик: элемент считается, только когда
// revocation-провайдер реально ответил за него
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 помечает его как self-signed ($00000008) или явно CA-trusted ($00004000). Офлайновый хост часто не может дотянуться до недостающего эмитента, так что цепочка кончается на intermediate; счесть финальный элемент такой частичной цепочки корнем — значит тихо выронить тот единственный сертификат, чей статус отзыва вероятнее всего отсутствует в кэше. Этот элемент остаётся в обязательном наборе, ответа провайдера у него нет, и результат остаётся 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 смок на каждой цели Delphi и FPC, Win32 и Win64. Настоящий смок верифицирует валидную подпись под недоверенным частным CA, а исходы «чисто» и «явно отозвано» приходят из заглушенных ответов CertGetCertificateChain, а не из установленных трастовых якорей или живой доставки. Это честная граница, которую стоит проговорить: обработка флагов, изоляция ошибок и обход улик зафиксированы, но то, что лежит в кэше отзыва конкретной машины в конкретный день, — всё ещё забота Windows, а пустой кэш теперь корректно даёт «неизвестно» вместо сетевого запроса или ложного «валидно»

Офлайн-обработка отзыва, диагностики по отдельным полям и экспорты улик — часть API валидации PDF-подписей в PDFium VCL для Delphi и C++Builder, рядом с бэкендами OpenSSL и macOS для кроссплатформенных развёртываний