Artículo técnico

Verificación de revocación offline de firmas PDF en Windows

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

Dos interruptores independientes guardan la validación offline de firmas PDF en Windows: CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL restringe solo la construcción de cadena como fetches de issuer AIA y de raíz, mientras la revocación necesita CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY, porque sin ella los proveedores igual bajan CRLs y mandan peticiones OCSP que revelan qué certificado se está validando
PDFium VCL hace OR de la bandera cache-only de revocación en la pasada de revocación siempre que OnlineRetrieval es False, así que una validación de confianza ptnpOffline se queda fuera de la red en ambas pasadas
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

El recorrido de evidencia que ReadWinRevocationEvidence hace sobre cada elemento de cadena Windows en Delphi: pRevocationInfo debe estar presente, cbSize debe ser lo bastante grande para leerse, dwRevocationResult debe ser cero, y el elemento final se excluye solo cuando está marcado self-signed o CA-trusted, así que una máscara de error de confianza en cero ya no puede esconder un certificado sin verificar
Un veredicto limpio exige al menos un elemento no raíz y una respuesta de cada elemento requerido, con RevocationError conservando el DWORD crudo del proveedor como CRYPT_E_REVOKED
// 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

La revocación de firma y de timestamp quedan separadas en PDFium VCL: el CMS detached de la firma del documento llena RevocationReason y NativeRevocationError, el CMS attached del token RFC 3161 llena TimeStampRevocationReason y NativeTimeStampRevocationError, y las etapas sin verificar dejan pcrrNone y cero junto a sus campos de estado
Un certificado TSA revocado por tanto no puede hacerse pasar por un firmante revocado, y un fallo de timestamp jamás borra un resultado de integridad que la verificación de firma ya estableció
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