Artículo técnico

Verificar firmas PDF con OpenSSL en PDFium VCL

PDFium VCL trata la verificación CMS como un backend reemplazable detrás de la interfaz IPdfCmsVerifier, de modo que el validador PAdES puede correr en Windows a través de CryptoAPI, en macOS a través del Keychain, y en cualquier sitio donde haya OpenSSL mediante ConfigureSslCmsVerifier. La interfaz es pequeña. Tres comportamientos de OpenSSL por debajo de ella producen respuestas seguras y equivocadas si usted la implementa de forma ingenua

La motivación es bastante clara en cuanto una aplicación Delphi sale de Windows. La validación de firmas es una de las pocas áreas donde el stack criptográfico de la plataforma no es un detalle de implementación: decide qué certificados son de confianza, qué algoritmos existen y qué significa la revocación. Atornille uno al código y ese código no se porta. Abstráigalo mal y cada plataforma reporta una respuesta con una forma distinta que el llamador no puede comparar

Lo que la abstracción tiene que cargar de verdad

Dos formas de verificación y tres veredictos independientes. Una firma PDF es detached: el contenido firmado son los dos rangos de bytes a cada lado del hueco de /Contents, así que VerifyDetached toma dos segmentos en lugar de un buffer. Un token de marca de tiempo es attached, lleva su propio contenido, así que VerifyAttached toma solo el DER

El resultado se divide en tres estados porque responden a tres preguntas distintas y pueden discrepar. SignatureStatus dice si los bytes fueron firmados por la clave del certificado del firmante. TrustStatus dice si ese certificado encadena a algo en lo que usted confía. RevocationStatus dice si el certificado seguía siendo válido en el momento pertinente. Un documento con una firma matemáticamente perfecta de un certificado del que usted nunca ha oído hablar es válido, no es de confianza y es desconocido, y aplastar eso en un solo booleano es como los validadores acaban mintiendo a los usuarios

uses
  FPdfCrypto, FPdfCryptoSsl;

var
  Options: TPdfCmsVerifyOptions;
begin
  if not SslAvailable then
    raise Exception.Create('libcrypto not usable: ' + SslMissingSymbols);

  ConfigureSslTrustAnchors(LoadCorporateRoots);   // DER, puede estar vacío
  ConfigureSslCrls(LoadFreshCrls);                // DER, puede estar vacío
  ConfigureSslCmsVerifier;                        // instala el backend

  Writeln('backend  : ', PadesCmsVerificationBackendName);
  Writeln('library  : ', SslLibraryPath, ' ', SslLibraryVersion);
  Writeln('ABI      : ', SslAbiLayout);           // ulong=<n> long=<n>

  Options := TPdfCmsVerifyOptions.Default;
  Options.CheckRevocation := True;
  Options.CollectChainCertificates := True;
end;

SslAbiLayout parece una curiosidad y no lo es. Cada código de error de OpenSSL y cada flag del store cruza la frontera como un unsigned long de C, que son cuatro bytes en Windows y ocho en Linux y macOS. Declárelo como un tipo fijo de 32 bits y el código funciona en Windows, y después lee en silencio medio valor en LP64. Reportar los anchos supuestos como una cadena sobre la que puede hacer assert en un test convierte toda una clase de deriva de ABI de plataforma en una comprobación de una línea. Cualquiera que haya peleado con el mismo problema con CK_ULONG en un binding PKCS#11 lo reconocerá de inmediato; esa historia está en el empaquetado de structs PKCS#11 y el ancho de CK_ULONG

¿Por qué la segunda pasada de verificación ve contenido vacío?

Porque CMS_verify lee el BIO del contenido detached hasta el fin de fichero, y un BIO que ya se leyó no se rebobina solo. Verificar en dos pasadas es un diseño razonable, primero la firma criptográfica sola con la evaluación de cadena suprimida, después la evaluación completa, y falla de una forma inusualmente engañosa si ambas pasadas comparten un BIO

La segunda pasada recibe cero bytes de contenido. En modo detached eso no es un error, porque un buffer de contenido vacío es una entrada legal. El digest simplemente no coincide, y el fallo se manifiesta como un fallo de construcción de cadena y no como un fallo de contenido, lo que le manda a inspeccionar certificados y almacenes de confianza mientras el problema real es una posición de stream. Reconstruya el BIO de memoria con BIO_new_mem_buf en cada pasada. Cuesta una asignación y elimina la posibilidad por completo

Lo que el flag no-verify suprime y lo que no

CMS_NO_SIGNER_CERT_VERIFY suprime la evaluación de cadena, no la búsqueda del certificado del firmante. Internamente OpenSSL resuelve y adjunta los certificados del firmante antes de consultar el flag, así que después de una primera pasada que lleva ese flag el firmante ya está disponible y sus identificadores de algoritmo se pueden leer directamente. No hace falta correr una segunda verificación completa solo para obtener el certificado del firmante, que es justo lo que el nombre del flag le tienta a suponer

Una regla de propiedad va con eso. La referencia al firmante pertenece a la estructura CMS y no debe liberarse por su cuenta. Es válida mientras la estructura lo sea, y liberarla produce una corrupción cuyo síntoma aparece en un sitio totalmente distinto, normalmente durante la limpieza de un objeto sin relación

¿Por qué activar la comprobación de CRL rechaza todas las firmas?

Porque OpenSSL comprueba las CRL solo contra lo que el store ya contiene y no descarga nada por su cuenta. No sigue los CRL distribution points y no habla OCSP. Active X509_V_FLAG_CRL_CHECK en un store sin CRLs y todas las cadenas fallan con una incapacidad de obtener la CRL del certificado. El resultado parece una comprobación de revocación que funciona y encuentra problemas. Es una comprobación de revocación que nunca llega a correr

El backend por eso activa el flag solo cuando ConfigureSslCrls ha suministrado de verdad al menos una CRL. Sin ninguna, RevocationStatus vuelve como pcvsUnsupported, que es una declaración honesta de que la pregunta no quedó respondida. Por la misma razón OnlineRetrieval no tiene efecto en este backend y no se emite ningún checkpoint pcvstOnlineRetrieval: no hay ruta de descarga desde la que reportar progreso

Diagrama del verificador CMS de OpenSSL de PDFium VCL con tres trampas: un BIO de contenido compartido leído hasta el fin de fichero deja a la segunda pasada de verificación con cero bytes, CMS_NO_SIGNER_CERT_VERIFY suprime la evaluación de cadena pero no la búsqueda del firmante, y la comprobación de CRL sobre un store vacío rechaza todas las cadenas sin que la revocación llegue a correr
Cada trampa produce un veredicto erróneo seguro de sí mismo: una posición de stream se disfraza de fallo de confianza, el flag no-verify suprime menos de lo que su nombre sugiere, y la revocación que nunca corrió parece una revocación que encontró problemas

Esta es una postura de diseño que conviene defender en general. Un validador que no puede comprobar la revocación debería decirlo. Reportar un certificado sin comprobar como no revocado es la manera más común de que las herramientas de validación de firmas despistan a sus usuarios, y es exactamente la clase de confusión que se explora en por qué los validadores rechazan firmas PAdES

// Los checkpoints dejan a una UI mostrar qué etapa corre, y le dicen
// qué etapas realiza de verdad un backend
type
  TSignatureProbe = class
    procedure Checkpoint(Stage: TPdfCmsVerifyStage);
  end;

procedure TSignatureProbe.Checkpoint(Stage: TPdfCmsVerifyStage);
begin
  case Stage of
    pcvstCryptographicSignature: Status('checking the signature');
    pcvstChainBuild:             Status('building the certificate chain');
    pcvstOnlineRetrieval:        Status('fetching validation data');
    pcvstRevocationCheck:        Status('checking revocation');
  end;
end;

// Lea los tres veredictos por separado; se les permite discrepar
if Result.SignatureStatus = pcvsValid then
  case Result.TrustStatus of
    pcvsValid:         Report('signed and trusted');
    pcvsInvalid:       Report('signed, chain rejected');
    pcvsUnsupported,
    pcvsIndeterminate: Report('signed, trust not established');
  end;
if Result.RevocationStatus = pcvsUnsupported then
  Report('revocation was not checked on this backend');

Enlazarse a una biblioteca cuya versión no puede fijar

OpenSSL renombró sus accesores del stack entre 1.0 y 1.1, así que la misma función lógica tiene dos posibles nombres de exportación según la compilación que la máquina anfitriona tenga. El binding resuelve primero el nombre nuevo y recurre al viejo, y solo registra un símbolo ausente cuando no resuelve ninguno. Esa es la forma correcta de cualquier enlace dinámico contra una biblioteca que usted no distribuye: preferir los nombres actuales, tolerar los históricos y reportar solo la ausencia genuina

SslMissingSymbols es lo que convierte una carga fallida en un evento diagnosticable. Un resultado no vacío en una máquina que claramente tiene libcrypto instalada significa que la versión instalada es más vieja que la API a la que apunta esta compilación, que es una conversación de soporte completamente distinta a la de una biblioteca que falta. ConfigureSslLibraryPath cubre el otro caso común, una máquina con varias compilaciones de OpenSSL donde la que está en la ruta de búsqueda por defecto no es la que usted quiere

Elegir un backend por plataforma

El arreglo práctico es seleccionar en el arranque y registrar cuál respondió. En Windows, el backend de plataforma se integra con los almacenes de certificados que una empresa ya gestiona, que es normalmente lo que usted quiere. En macOS el backend de Keychain encaja con el mismo razonamiento y está descrito en verificar firmas con SecTrust en macOS. OpenSSL es la opción portable, y también es la elección correcta cuando necesita una política de validación idéntica entre plataformas en lugar de una que siga el almacén de confianza de cada plataforma

Diagrama de PDFium VCL de la abstracción IPdfCmsVerifier llevando VerifyDetached sobre los dos rangos de bytes alrededor del hueco de Contents y VerifyAttached para tokens de marca de tiempo, los tres veredictos independientes SignatureStatus, TrustStatus y RevocationStatus, y backends por plataforma seleccionados en el arranque mediante CryptoAPI, SecTrust o ConfigureSslCmsVerifier
La interfaz lleva dos formas de verificación y tres veredictos porque responden a preguntas distintas y pueden discrepar, y el backend instalado queda registrado junto a cada veredicto para que los resultados guardados se puedan reproducir

Instale el que instale, registre PadesCmsVerificationBackendName junto a cada veredicto que anote. Un resultado de validación guardado sin el backend que lo produjo no se puede reproducir después, porque los tres valores de estado significan cosas sutilmente distintas según qué stack respondió. La capa de inspección de firmas por encima de todo esto, incluido cómo se reportan los niveles PAdES, está cubierta en inspeccionar firmas digitales PDF y niveles PAdES

Todo ello se envía como fuente con el componente PDFium para Delphi, lo que aquí importa más que de costumbre: para un validador de firmas, poder leer exactamente qué flags activa un backend y qué comprobaciones se salta no es un lujo, es la única manera de saber qué afirma de verdad un check verde en su aplicación