PDFium VCL verifica la revocación de firmas PDF de forma offline en Windows agregando CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY a la pasada de revocación de CertGetCertificateChain, porque la bandera cache-only usada para construir la cadena no cubre en absoluto la recuperación de CRL ni de OCSP. Desde la v3.119.1 una llamada offline a ValidatePadesTrust se queda fuera de la red, y un resultado limpio exige evidencia de revocación real por certificado. El resto de este post va de por qué las dos mitades de esa frase necesitaban arreglo
El escenario que expone el problema es común. Un servicio de validación corre sobre un host Windows blindado, TPadesTrustValidationOptions.NetworkPolicy está en ptnpOffline (que además es el default), y el operador espera que cada respuesta salga de la caché local de certificados. Entonces alguien nota peticiones salientes a un distribution point de una CA en el log del firewall, o un trabajo por lotes que se clava el UrlRetrievalTimeoutMs completo de 15000 ms en cada firma. Nada en el código pidió la red. Windows fue de todas formas
¿Por qué una construcción de cadena offline igual baja CRLs en Windows?
Porque CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL solo restringe la recuperación por URL que hace la construcción de cadena: fetches de issuer AIA, actualizaciones de raíz y CTL. La documentación de Microsoft de CertGetCertificateChain dice explícitamente que la bandera no aplica a la verificación de revocación. La revocación tiene su propio interruptor, CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY ($80000000), y sin él los proveedores de revocación quedan en libertad de bajar una CRL o mandar una petición OCSP aunque la llamada envolvente parezca offline. PDFium VCL ahora hace OR de esa bandera en la pasada de revocación siempre que OnlineRetrieval sea False, encima de las banderas de cadena, CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT y CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT. Eso importa por más que la latencia: una petición OCSP le dice al respondedor qué certificado está usted mirando, que es justo lo que un validador aislado del aire debe evitar
uses
PDFium, FPdfCrypto, FPdfPades;
var
Options: TPadesTrustValidationOptions;
Report: TPadesValidationResult;
begin
Options := TPadesTrustValidationOptions.Default; // ptnpOffline, 15000 ms
Options.CheckRevocation := True; // False por defecto
Options.CheckTimeStamps := True;
// Offline ahora significa offline también para revocación: respuestas CRL y OCSP cacheadas
// solamente, no se levanta ningún checkpoint pcvstOnlineRetrieval
Report := Pdf.ValidatePadesTrust(Options);
end;
Dos construcciones de cadena, dos campos de error
El backend Windows construye la cadena dos veces, y cada construcción ahora tiene su propia ranura de error. La primera llamada a CertGetCertificateChain corre sin banderas de revocación y alimenta CertVerifyCertificateChainPolicy con la política base, lo que produce TrustStatus y TrustError. La segunda llamada agrega las banderas de revocación. Antes de la v3.119.1, un fallo de esa segunda llamada escribía GetLastError en TrustError, así que una cadena que acababa de verificarse como confiable podía volver viéndose no confiable porque un proveedor de revocación estornudó. El fix lee GetLastError de inmediato y lo guarda en TPdfCmsVerifyResult.RevocationError, dejando quieto el veredicto de la primera pasada. Y un True de la segunda llamada tampoco se trata como éxito; solo significa que Windows devolvió un contexto de cadena que vale la pena inspeccionar
¿Qué prueba realmente una máscara de error de confianza en cero?
Por sí sola, nada. Un TrustStatus.dwErrorStatus agregado en cero después de la pasada de revocación dice que no se levantó ningún bit de error, y una cadena donde ningún elemento cargó información de revocación puede producir exactamente eso. El código anterior mapeaba «sin bit de revocado, sin bit de desconocido, sin bit de offline» directo a válido, que es la manera clásica con la que un validador reporta un certificado sin verificar como limpio. La nueva rutina ReadWinRevocationEvidence recorre cada cadena simple y cada elemento, rechaza estructuras cuyo cbSize es demasiado chico para leerse con seguridad, y reporta éxito solo cuando existe al menos un elemento no raíz y cada uno de esos elementos carga un CERT_REVOCATION_INFO cuyo dwRevocationResult es cero
// Condensado del recorrido de evidencia: un elemento cuenta solo cuando
// un proveedor de revocación realmente respondió por él
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); // una cadena de solo raíz no prueba nada
El resultado del proveedor se conserva crudo. RevocationError guarda el DWORD dwRevocationResult exactamente como el proveedor lo devolvió, prefiriendo el error del elemento revocado cuando lo hay (CRYPT_E_REVOKED es $80092010), y la bitmask de confianza jamás se disfraza de código de error nativo. El mapeo a TPdfCmsRevocationReason es deliberadamente grueso: pcrrCertificateRevoked con pcvsInvalid para revocación explícita, pcrrChainUntrusted cuando la cadena falló por razones ajenas a la revocación, y pcrrUnknown para todo lo demás. Windows pudo haber intentado OCSP en vez de una CRL, así que un resultado offline o desconocido no se traduce a pcrrCrlExpired. El backend de verificación CMS OpenSSL puede hacer esas distinciones propias de CRL porque solo evalúa CRLs que usted le entrega, mientras que el backend SecTrust de macOS deja los campos en pcrrNone y cero, que significa «sin diagnóstico detallado», no «pasó»
Dónde se detiene la exclusión de la raíz
CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT se salta legítimamente al ancla, ya que nadie publica una CRL que revoque a una raíz contra sí misma. La trampa está en decidir cuál elemento es la raíz. PDFium VCL excluye al último elemento de una cadena simple solo cuando su dwInfoStatus lo marca como self-signed ($00000008) o explícitamente CA-trusted ($00004000). Un host offline a menudo no puede ir por un issuer faltante, así que la cadena termina en un intermedio; tratar al elemento final de esa cadena parcial como una raíz descartaría en silencio al único certificado cuyo estado de revocación es el más probable que no esté en la caché. Ese elemento se queda en el conjunto requerido, no tiene respuesta de proveedor, y el resultado se queda en pcvsIndeterminate
¿Cómo se mantienen separados los resultados de revocación de firma y timestamp?
Como campos separados que jamás se sobrescriben entre sí. El validador PAdES verifica el CMS detached de la firma del documento y el CMS attached del token de timestamp RFC 3161 en dos llamadas independientes, y la v3.119.0 le dio a cada uno sus propios diagnósticos en TPadesSignatureValidation: RevocationReason y NativeRevocationError para el firmante, TimeStampRevocationReason y NativeTimeStampRevocationError para el TSA. Un certificado TSA revocado por tanto no puede hacerse pasar por un firmante revocado, y un fallo de timestamp no borra un resultado de integridad ya establecido. Cuando CheckRevocation es False, o la validación nunca llegó a esa etapa, los campos quedan en pcrrNone y 0, así que léalos siempre junto a RevocationStatus y 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;
El reporte de evidencia sigue la misma regla. El export CSV agrega revocationReason, nativeRevocationError y las columnas de timestamp hasta nativeTimeStampRevocationError al final del orden de columnas existente, así que los parsers viejos siguen funcionando, y el export JSON agrega campos correspondientes sin cambiar lo que significan los viejos. Si la validación offline sigue volviendo indeterminada, el fix durable está aguas arriba: reúna el material de validación al momento de firmar, como se describe en firmas PDF de largo plazo con timestamps RFC 3161 y DSS, en lugar de esperar que la máquina verificadora tenga una caché caliente
Qué prueba y qué no prueba la matriz de tests
La matriz de verificación Windows pasó 30 escenarios controlados de la API de cadenas y un smoke real offline de CMS en cada objetivo Delphi y FPC Win32 y Win64. El smoke real verifica una firma válida bajo una CA privada no confiable, mientras que los desenlaces limpio y explícitamente revocado vienen de respuestas stub de CertGetCertificateChain y no de trust anchors instalados ni recuperación en vivo. Esa es una frontera honesta que vale declarar: el manejo de banderas, el aislamiento de errores y el recorrido de evidencia quedaron clavados, pero lo que la caché de revocación de una máquina particular contiene un día dado sigue siendo asunto de Windows, y una caché vacía ahora produce correctamente «desconocido» en lugar de una petición de red o un «válido» falso
El manejo de revocación offline, los diagnósticos por campo y los exports de evidencia forman parte de la API de validación de firmas PDF en PDFium VCL para Delphi y C++Builder, junto a los backends OpenSSL y macOS para despliegues multiplataforma