PDFium VCL проверява revocation на PDF подписи offline на Windows, като добавя CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY към revocation прохода на CertGetCertificateChain, защото cache-only флагът, ползван за строене на веригата, изобщо не покрива CRL или OCSP доставяне. От v3.119.1 offline извикване на ValidatePadesTrust не докосва мрежата, а чист резултат изисква реални доказателства за revocation на всеки сертификат поотделно. Остатъкът от тази статия е за защо и двете половини на това изречение се нуждаеха от поправка
Настройката, която изплува проблема, е обикновена. Validation service върви на заключен Windows host, TPadesTrustValidationOptions.NetworkPolicy е ptnpOffline (което е и подразбиращото се), а операторът очаква всеки отговор да идва от локалния certificate cache. После някой забелязва outbound заявки към CA distribution point в firewall лога или batch job, който стага цялото UrlRetrievalTimeoutMs от 15000 ms на всеки подпис. Нищо в кода не е искало мрежата. Windows отива там сам
Защо offline строене на верига все пак тегли CRL-и на Windows?
Защото CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL ограничава само URL доставянето, което строенето на верига върши: AIA issuer извличания, root и CTL обновявания. Microsoft документацията за CertGetCertificateChain казва изрично, че флагът не се прилага при revocation проверка. Revocation има собствен ключ, CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY ($80000000), и без него revocation provider-ите са свободни да свалят CRL или да изпращат OCSP заявка, макар обграждащото извикване да изглежда offline. PDFium VCL сега OR-ва този флаг в revocation прохода, когато OnlineRetrieval е False, върху chain флаговете, CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT и CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT. Това има значение за повече от латентност: OCSP заявка казва на responder-а кой сертификат разглеждате, а точно това е длъжен да избягва един 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;
// Offline вече значи offline и за revocation: само кеширани CRL и OCSP
// отговори, без да се вдига checkpoint pcvstOnlineRetrieval
Report := Pdf.ValidatePadesTrust(Options);
end;
Две строенета на верига, две error полета
Windows backend-ът строи веригата два пъти и всеки build вече притежава собствения си error слот. Първото извикване на CertGetCertificateChain върви без revocation флагове и захранва CertVerifyCertificateChainPolicy с base политиката, което произвежда TrustStatus и TrustError. Второто извикване добавя revocation флаговете. Преди v3.119.1 провал на това второ извикване записваше GetLastError в TrustError, така че верига, току-що валидирана като доверена, можеше да се върне изглеждаща недоверена, защото някой revocation provider се е препънал. Поправката чете GetLastError веднага и го съхранява в TPdfCmsVerifyResult.RevocationError, оставяйки присъдата от първия проход на мира. А True връщане от второто извикване също не се третира като успех; то значи само, че Windows е върнал chain контекст, заслужаващ инспекция
Какво реално доказва нулева trust error маска?
Само по себе си — нищо. Агрегиран TrustStatus.dwErrorStatus нула след revocation прохода казва, че не е вдигнат error бит, а верига, в която нито един елемент не е носил изобщо revocation информация, може да произведе точно това. По-старият код картираше „нито revoked бит, нито unknown бит, нито offline бит“ направо на валидно, което е класическият начин валидатор да докладва непроверен сертификат като чист. Новата рутина ReadWinRevocationEvidence обхожда всяка simple верига и всеки елемент, отхвърля структури, чийто cbSize е твърде малък за безопасно четене, и докладва успех само когато съществува поне един non-root елемент и всеки такъв елемент носи CERT_REVOCATION_INFO, чийто dwRevocationResult е нула
// Сгъстено от evidence обхода: елемент брои само когато
// revocation provider реално е отговорил за него
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); // верига само с root не доказва нищо
Резултатът от provider-а се пази суров. RevocationError държи DWORD-а dwRevocationResult точно както е върнат от provider-а, предпочитайки грешката от revoked елемента, когато има такъв (CRYPT_E_REVOKED е $80092010), а trust bitmask-ата никога не се преоблича като native error код. Картирането към TPdfCmsRevocationReason е нарочно грубо: pcrrCertificateRevoked с pcvsInvalid за изрично revocation, pcrrChainUntrusted, когато веригата се е провалила по причини, нямаски връзка с revocation, и pcrrUnknown за всичко останало. Windows може да е опитал OCSP вместо CRL, така че offline или unknown резултат не се превежда на pcrrCrlExpired. OpenSSL CMS verification backend-ът може да направи тези CRL-специфични разграничения, защото оценява само CRL-овете, които му подадете, докато macOS SecTrust backend-ът оставя полетата на pcrrNone и нула, което значи „няма детайлна диагностика“, не „минало“
Къде спира изключването на root
CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT легитимно прескача котвата, защото никой не публикува CRL, revoke-ваща root спрямо самия него. Капанът е да решиш кой елемент е root-ът. PDFium VCL изключва последния елемент на simple верига само когато dwInfoStatus го маркира като self-signed ($00000008) или изрично CA-trusted ($00004000). Offline host често не може да достави липсващ issuer, така че веригата свършва на intermediate; третирането на последния елемент на тази частична верига като root би тихо изпуснало единствения сертификат, чийто revocation статус най-вероятно липсва в кеша. Този елемент остава в задължителния набор, няма отговор от provider и резултатът остава pcvsIndeterminate
Как резултатите за revocation на подпис и timestamp се държат отделно?
Като отделни полета, които никога не се презаписват едно от друго. PAdES валидаторът верифицира detached CMS на подписа на документа и attached CMS на RFC 3161 timestamp токена в две независими извиквания, а v3.119.0 даде на всяко собствени диагностики на TPadesSignatureValidation: RevocationReason и NativeRevocationError за подписващия, TimeStampRevocationReason и NativeTimeStampRevocationError за TSA. Revoke-нат TSA сертификат не може затова да се представи за revoke-нат подписващ, а провал на timestamp не изтрива интегритетен резултат, който вече е установен. Когато 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;
Evidence report-ът следва същото правило. CSV export-ът долепя revocationReason, nativeRevocationError и timestamp колоните през nativeTimeStampRevocationError в края на съществуващия ред на колоните, така че по-стари parser-и продължават да работят, а JSON export-ът добавя съответстващи полета, без да променя какво значат старите. Ако offline валидацията продължава да се връща indeterminate, траен fix е нагоре по веригата: съберете validation материала при подписване, както описва статията за дълготрайни PDF подписи с RFC 3161 timestamps и DSS, вместо да разчитате проверяващата машина да има топъл кеш
Какво доказва и какво не доказва тестовата матрица
Windows verification матрицата мина 30 контролирани chain-API сценария и един реален offline CMS smoke на всяка Delphi и FPC Win32 и Win64 цел. Реалният smoke верифицира валиден подпис под недоверен частен CA, докато чисти и изрично revoked изходи идват от stub-нати отговори на CertGetCertificateChain, а не от инсталирани trust anchor-и или живо доставяне. Това е честна граница, заслужаваща да се изтъкне: обработката на флаговете, изолацията на грешките и evidence обходът са заковани, но какво съдържа revocation кешът на конкретна машина в даден ден си остава работа на Windows, а празен кеш вече произвежда коректно „unknown“ вместо мрежова заявка или фалшиво „валидно“
Offline revocation обработката, диагностиките по поле и evidence export-ите са част от PDF signature validation API-я в PDFium VCL за Delphi и C++Builder, до OpenSSL и macOS backend-ите за крос-платформени deployments