PDFium VCL comprueba la revocación de firmas PDF offline en Windows añadiendo CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY a la pasada de revocación de CertGetCertificateChain, porque el flag cache-only usado 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 no toca 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 montaje que destapa el problema es de lo más normal. Un servicio de validación corre en un host Windows blindado, TPadesTrustValidationOptions.NetworkPolicy vale ptnpOffline (que además es el default), y el operador espera que cada respuesta salga de la caché local de certificados. Entonces alguien nota en el log del firewall peticiones salientes a un punto de distribución de la CA, o un trabajo por lotes que se atasca los UrlRetrievalTimeoutMs completos, 15000 ms, en cada firma. Nada del código pidió la red. Windows fue allí por su cuenta
¿Por qué una construcción de cadena offline sigue descargando CRL en Windows?
Porque CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL solo restringe la recuperación de URLs que hace la construcción de la cadena: los fetch de issuers AIA, las actualizaciones de raíces y de CTL. La documentación de Microsoft de CertGetCertificateChain dice explícitamente que el flag no aplica a la comprobació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 están en su derecho de descargar una CRL o mandar una petición OCSP aunque la llamada de alrededor parezca offline. PDFium VCL ahora hace OR con ese flag en la pasada de revocación siempre que OnlineRetrieval sea False, encima de los flags de cadena, CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT y CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT. Eso importa para más que la latencia: una petición OCSP le dice al respondedor qué certificado estás mirando, que es justo lo que un validador air-gapped 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 la revocación: respuestas CRL y OCSP
// cacheadas solo, no se levanta ningún checkpoint pcvstOnlineRetrieval
Report := Pdf.ValidatePadesTrust(Options);
end;
Dos construcciones de cadena, dos campos de error
El backend de Windows construye la cadena dos veces, y cada construcción ahora tiene su propia ranura de error. La primera llamada a CertGetCertificateChain corre sin flags de revocación y alimenta CertVerifyCertificateChainPolicy con la política base, lo que produce TrustStatus y TrustError. La segunda llamada añade los flags 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 de confianza podía volver con cara de no confiable porque un proveedor de revocación tropezó. El fix lee GetLastError inmediatamente y lo guarda en TPdfCmsVerifyResult.RevocationError, dejando el veredicto de la primera pasada en paz. Y un retorno True de la segunda llamada tampoco se trata como éxito; solo significa que Windows devolvió un contexto de cadena que merece inspección
¿Qué prueba de verdad una máscara de error de confianza a cero?
Por sí sola, nada. Un TrustStatus.dwErrorStatus agregado de cero tras la pasada de revocación dice que no se levantó ningún bit de error, y una cadena en la que ningún elemento llevaba información de revocación puede producir exactamente eso. El código anterior mapeaba «ni bit de revocado, ni bit de desconocido, ni bit de offline» directamente a válido, que es la manera clásica con la que un validador reporta un certificado sin comprobar como uno limpio. La nueva rutina ReadWinRevocationEvidence recorre cada simple chain y cada elemento, rechaza estructuras cuyo cbSize es demasiado pequeño para leerse con seguridad, y reporta éxito solo cuando existe al menos un elemento que no es raíz y cada uno de esos elementos lleva una CERT_REVOCATION_INFO cuyo dwRevocationResult es cero
// Condensado del recorrido de evidencia: un elemento solo cuenta cuando
// un proveedor de revocación respondió de verdad 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 guarda crudo. RevocationError contiene el DWORD dwRevocationResult exactamente como lo devolvió el proveedor, prefiriendo el error del elemento revocado cuando lo hay (CRYPT_E_REVOKED es $80092010), y la máscara de bits de confianza jamás se viste 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 motivos ajenos a la revocación, y pcrrUnknown para todo lo demás. Windows puede haber probado 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 las CRL que tú le entregas, mientras que el backend SecTrust de macOS deja los campos a pcrrNone y cero, que significa «sin diagnóstico detallado», no «aprobado»
Dónde se detiene la exclusión de la raíz
CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT se salta legítimamente el ancla, porque nadie publica una CRL que revoque una raíz contra sí misma. La trampa es decidir qué elemento es la raíz. PDFium VCL excluye el último elemento de una simple chain solo cuando su dwInfoStatus lo marca como self-signed ($00000008) o explícitamente CA-trusted ($00004000). Un host offline a menudo no puede descargar un issuer que falta, así que la cadena termina en un intermedio; tratar el último elemento de esa cadena parcial como una raíz dejaría caer en silencio al único certificado cuyo estado de revocación es 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 dio a cada uno sus propios diagnósticos en TPadesSignatureValidation: RevocationReason y NativeRevocationError para el firmante, TimeStampRevocationReason y NativeTimeStampRevocationError para el TSA. Un certificado de TSA revocado no puede por tanto 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 se quedan en pcrrNone y 0, así que léelos 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 report de evidencia sigue la misma regla. El export CSV añade 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 añade campos equivalentes sin cambiar lo que significan los antiguos. Si la validación offline sigue volviendo indeterminada, el fix duradero está aguas arriba: reúne el material de validación en el momento de firmar, como describe las firmas PDF de larga duración 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 de Windows pasó 30 escenarios controlados de la API de cadenas y un smoke real de CMS offline en cada target Win32 y Win64 de Delphi y FPC. El smoke real verifica una firma válida bajo una CA privada no confiable, mientras que los resultados limpio y revocado explícito salen de respuestas stubbed de CertGetCertificateChain en lugar de anclas de confianza instaladas o recuperación en vivo. Esa frontera honesta merece enunciarse: el manejo de flags, el aislamiento de errores y el recorrido de evidencia están clavados, pero lo que contenga la caché de revocación de una máquina concreta 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 del PDFium VCL para Delphi y C++Builder, junto a los backends OpenSSL y macOS para despliegues multiplataforma