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-запрос говорит респондеру, на какой сертификат вы смотрите, — ровно то, чего офлайн-валидатор должен избегать
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 равным нулю
// Сокращено из обхода улик: элемент считается, только когда
// 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
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 для кроссплатформенных развёртываний