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 vía CryptoAPI, en macOS vía el Keychain, y donde haya OpenSSL vía ConfigureSslCmsVerifier. La interfaz es pequeña. Tres comportamientos de OpenSSL por debajo de ella producen respuestas equivocadas dadas con total confianza si usted la implementa de forma ingenua

La motivación es 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. Hard-codear uno y el código no se porta. Abstraerlo mal y cada plataforma reporta una respuesta con forma distinta que quien llama no puede comparar

Lo que la abstracción realmente tiene que cargar

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 /Contents, así que VerifyDetached toma dos segmentos y no un buffer. Un token de timestamp es attached, cargando su propio contenido, así que VerifyAttached toma solo el DER

El resultado se parte en tres estados porque responden 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 hacia algo en lo que usted confía. RevocationStatus dice si el certificado seguía siendo válido en el momento relevante. Un documento con una firma matemáticamente perfecta de un certificado del que usted nunca ha oído hablar es válido, no confiable y desconocido, y colapsar eso en un solo booleano es como los validadores terminan mintiéndole 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 de 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, para luego leer silenciosamente medio valor en LP64. Reportar los anchos asumidos como un string que puede afirmar en un test convierte toda una clase de drift de ABI de plataforma en una comprobación de una línea. Quien haya trabajado el mismo problema con CK_ULONG en un binding PKCS#11 lo reconocerá de inmediato; esa historia está en empaquetado de structs PKCS#11 y 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 archivo, y un BIO que ya fue leído 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, luego 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 la falla aparece como una falla de construcción de cadena en lugar de una falla de contenido, lo que lo manda a inspeccionar certificados y trust stores mientras el problema real es una posición de stream. Reconstruya el memory BIO 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 pueden leerse de inmediato. No hay necesidad de correr una segunda verificación completa solo para obtener el certificado del firmante, que es lo que el nombre del flag lo tienta a asumir

Una regla de ownership va con eso. La referencia al firmante pertenece a la estructura CMS y no debe liberarse de forma independiente. Es válida mientras la estructura lo sea, y liberarla produce una corrupción cuyo síntoma aparece en otro lugar por completo, usualmente durante la limpieza de un objeto sin relación

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

Porque OpenSSL revisa las CRLs solo contra lo que el store ya tiene y no busca nada por su cuenta. No sigue los CRL distribution points y no habla OCSP. Active X509_V_FLAG_CRL_CHECK sobre un store sin CRLs y cada cadena falla con una incapacidad de obtener la CRL del certificado. El resultado parece una revisión de revocación funcionando y encontrando problemas. Es una revisión de revocación que nunca corrió

Por eso el backend activa el flag solo cuando ConfigureSslCrls realmente haya suministrado al menos una CRL. Sin ninguna, RevocationStatus regresa como pcvsUnsupported, que es una declaración honesta de que la pregunta no fue respondida. Por la misma razón OnlineRetrieval no tiene efecto sobre este backend y no se emite ningún checkpoint pcvstOnlineRetrieval: no hay ruta de búsqueda desde la cual reportar progreso

Diagrama del verificador CMS OpenSSL de PDFium VCL con tres trampas: un BIO de contenido compartido leído hasta el fin de archivo 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 revisión de CRL sobre un store vacío rechaza cada cadena sin que la revocación corra jamás
Cada trampa arroja un veredicto equivocado dado con confianza: una posición de stream se disfraza de falla de confianza, el flag no-verify suprime menos de lo que su nombre sugiere, y una revocación jamás corrida parece una revocación que encontró problemas

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

// Los checkpoints le dejan a una UI mostrar qué etapa corre, y le dicen
// qué etapas ejecuta realmente 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; tienen derecho a 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 que no puede fijar

OpenSSL renombró sus accessors de stack entre 1.0 y 1.1, así que la misma función lógica tiene dos posibles nombres de export según la build que la máquina anfitriona tenga. El binding resuelve primero el nombre más nuevo y cae al más viejo, y solo registra un símbolo faltante cuando ninguno resuelve. Esa es la forma correcta para cualquier binding 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 instalado significa que la versión instalada es más vieja que la API a la que esta build apunta, 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 builds 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 al arranque y registrar cuál respondió. En Windows, el backend de plataforma se integra con los certificate stores que una empresa ya administra, que normalmente es lo que usted quiere. En macOS el backend Keychain cabe en 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 trust store de cada plataforma

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

Sea cual instale, registre PadesCmsVerificationBackendName junto a cada veredicto que anote. Un resultado de validación guardado sin el backend que lo produjo no puede reproducirse 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 esto viene como fuente con el PDFium Delphi component, lo que aquí importa más que de costumbre: para un validador de firmas, poder leer exactamente qué flags activa un backend y qué revisiones se salta no es un lujo, es la única forma de saber qué afirma realmente un check verde en su aplicación