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

Offline revocation проверки за PDF подписи в Delphi

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 валидатор

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

Обходът на доказателствата, който ReadWinRevocationEvidence върши върху всеки Windows chain елемент в Delphi: pRevocationInfo трябва да присъства, cbSize трябва да е достатъчно голям за четене, dwRevocationResult трябва да е нула, а крайният елемент се изключва само когато е маркиран self-signed или CA-trusted, така че нулева trust error маска вече не може да скрие непроверен сертификат
Чиста присъда изисква поне един non-root елемент и отговор от всеки задължителен елемент, като RevocationError пази суровия provider DWORD като CRYPT_E_REVOKED
// Сгъстено от 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

Revocation на подпис и timestamp остават отделни в PDFium VCL: detached CMS на подписа на документа пълни RevocationReason и NativeRevocationError, attached CMS на RFC 3161 токена пълни TimeStampRevocationReason и NativeTimeStampRevocationError, а непроверени стъпки оставят pcrrNone и нула до полетата им за статус
Revoke-нат TSA сертификат затова не може да се представи за revoke-нат подписващ, а провал на timestamp никога не изтрива интегритетен резултат, който верификацията на подписа вече е установила
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