Artículo técnico

Firmar PDFs con PAdES B-B en Delphi usando PDFium

El PDFium Component firma un PDF con una firma digital PAdES B-B a través de su método SignPades: carga el documento, calcula el hash del rango de bytes firmado, construye una estructura CAdES CMS ecuación y añade la firma como una actualización incremental. El backend criptográfico es exclusivo de Windows, por lo que debe proteger cada llamada con PadesCryptoAvailable antes de firmar

La situación es familiar. Un PDF de contrato llega a su escritorio, el departamento legal quiere que se firme digitalmente antes de enviarlo, y usted recurre a la misma compilación de PDFium que ya utiliza para renderizar e inspeccionar documentos, solo para descubrir que PDFium no puede escribir una firma en absoluto. Su API de firma es estrictamente de solo lectura. El PDFium Component cierra esa brecha al controlar todo el flujo de firma en Pascal, desde la función hash hasta la inserción a nivel de bytes, y este artículo recorre ese flujo de extremo a extremo

¿Por qué PDFium no puede escribir una firma digital?

PDFium expone las firmas como objetos de solo lectura y no ofrece ninguna funcionalidad para crearlas. La familia FPDFSignatureObj_* le permite enumerar una firma existente, leer su /Contents e inspeccionar su /ByteRange, pero no hay un equivalente que construya un diccionario de firma, reserve un espacio en /Contents o escriba un rango de bytes; el guardado incremental existe (FPDF_SaveAsCopy con FPDF_INCREMENTAL) pero no incluye ningún gancho (hook) de firma. Cualquier componente que firme un PDF utilizando PDFium debe generar cada byte de la firma por sí mismo, razón por la cual el PDFium Component construye el mecanismo a partir de tres unidades puras en Pascal. FPC 3.2.2 incluye md5 y sha1 pero ningún SHA-2 en absoluto, y la API System.Hash SHA-256 de Delphi no es compatible a nivel de código con FPC, por lo que FPdfSha256 es una implementación autónoma FIPS 180-4 que mantiene cada ruta de código CMS en un solo tipo TSHA256Digest sin bifurcaciones del compilador. FPdfAsn1 proporciona el codificador y lector DER que las estructuras CMS necesitan, y FPdfCms ensambla el CAdES SignedData sobre ambos

¿Cómo firmar digitalmente un PDF en Delphi?

Cargue el documento y luego llame a SignPades con la huella digital (thumbprint) del certificado. El PDFium Component resuelve esa huella digital en el almacén de certificados "MY" del usuario actual, obtiene el certificado correspondiente y su clave privada, y escribe una copia firmada en la ruta que usted indique

uses
  PDFium, FPdfCrypto;

procedure SignContract(const AThumbprint: string);
var
  Pdf: TPdf;
begin
  if not PadesCryptoAvailable then
    raise Exception.Create('PAdES signing requires the Windows CNG backend');

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';   // the document to sign
    Pdf.Active := True;
    // Second argument: SHA-1 thumbprint of a certificate in the Current
    // User "MY" store. First argument: destination for the signed copy.
    if not Pdf.SignPades('contract-signed.pdf', AThumbprint) then
      raise Exception.Create('Signing failed');
  finally
    Pdf.Free;
  end;
end;

Qué contiene el CMS: atributos firmados y RFC 5652

Una firma base de PAdES no es una firma RSA directa sobre el archivo; es una estructura CAdES CMS SignedData que contiene un conjunto obligatorio de atributos firmados, y FPdfCms.BuildSignedData genera exactamente ese conjunto: content-type, message-digest y signing-certificate-v2, el atributo ESS que vincula la firma al certificado del firmante mediante hash. Un detalle aquí complica casi cualquier implementación manual de CMS. La norma RFC 5652 §5.4 requiere que el resumen de los atributos firmados se calcule sobre la codificación DER SET OF, etiqueta 0x31, mientras que los mismos atributos viajan dentro de SignerInfo bajo la etiqueta IMPLICIT [0], 0xA0. El PDFium Component codifica el conjunto de atributos una vez, calcula el hash de la forma 0x31 y luego reescribe únicamente el byte de etiqueta inicial a 0xA0 para su emisión, de modo que un único búfer sirve para ambos propósitos sin una segunda pasada sobre el árbol

var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;

    Opts := TPadesSignOptions.Default;
    Opts.CertificateThumbprint := 'a1b2c3d4e5f6...';  // signer in the MY store
    Opts.Reason := 'I approve this agreement';
    Opts.Location := 'Berlin, DE';
    Opts.ContentsSize := 16384;                        // hex width of /Contents

    if not Pdf.SignPades('contract-signed.pdf', Opts) then
      raise Exception.Create('Signing failed');
  finally
    Pdf.Free;
  end;
end;

La sobrecarga de opciones añade los metadatos del diccionario de firma definidos por ISO 32000-1 §12.8.1: Reason, Location, ContactInfo y Name, todos opcionales y todos escritos en el diccionario de valor de la firma. Hay una restricción con la que es fácil tropezar. Si establece CommitmentTypeOid para añadir un atributo firmado CAdES de indicación de tipo de compromiso (commitment-type-indication), no establezca también Reason; la norma ETSI EN 319 142-1 §6.3 prohíbe llevar ambos, porque los dos expresan la misma intención por medios diferentes

¿Cómo encajan el ByteRange y el espacio /Contents?

Una firma debe cubrir todo el archivo excepto los bytes que contienen la propia firma, y PAdES resuelve esa circularidad con un marcador de posición de ancho fijo que SignPadesBytes para gestionar con precisión. Reserva una cadena hexadecimal /Contents de ContentsSize bytes (16384 por defecto, cómodamente mayor que un CMS SignedData típico), serializa la actualización incremental para localizar el desplazamiento exacto del espacio y luego calcula el /ByteRange como dos intervalos que delimitan el espacio: todo lo anterior al delimitador de apertura de la cadena hexadecimal y todo lo posterior a su delimitador de cierre. El SHA-256 se calcula únicamente sobre esos dos intervalos. El CMS terminado se codifica en hexadecimal en el espacio reservado, se rellena con ceros hasta alcanzar el ancho fijo y se añade la actualización de la tabla de referencias cruzadas. Dado que el ancho se define de antemano, llenar el espacio no desplaza ningún byte posterior, que es la razón por la cual el rango de bytes sigue siendo válido; los bytes del documento original se conservan intactos, de modo que una firma anterior en el mismo archivo sobrevive sin alteraciones exactamente como lo requiere la firma incremental de ISO 32000-1 §12.8.1

El backend de Windows CNG y sus límites

El PDFium Component firma únicamente en Windows, y ese límite es intencional. FPdfCryptoWin vincula crypt32.dll y ncrypt.dll dinámicamente, sin añadir dependencias de DLL en tiempo de compilación, y la cadena de firma es CNG estándar: abre el almacén MY, busca el certificado por hash, adquiere su identificador de clave privada a través de CryptAcquireCertificatePrivateKey y llama a NCryptSignHash. RSA con PKCS#1 v1.5, RSA-PSS y ECDSA son todos compatibles. ECDSA requiere un ajuste que los otros no necesitan, porque NCryptSignHash devuelve el par r-and-s binario de IEEE P1363 mientras que CMS espera una SEQUENCE DER ECDSA-Sig-Value SEQUENCE, por lo que el backend lo vuelve a codificar según RFC 5480

var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
  Output: TFileStream;
begin
  if not PadesCryptoAvailable then
    Exit;   // no signing backend on this platform

  Opts := TPadesSignOptions.Default;
  Opts.CertificateThumbprint := ReadThumbprintFromConfig;

  Pdf := TPdf.Create(nil);
  Output := TFileStream.Create('contract-signed.pdf', fmCreate);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;
    Pdf.SignPadesToStream(Output, Opts);
  finally
    Output.Free;
    Pdf.Free;
  end;
end;

La consecuencia práctica es que la clave privada debe residir en el almacén de certificados de Windows. A certificado almacenado en un archivo PFX funciona únicamente después de importarlo al almacén de usuario actual, momento en el cual su huella digital es el valor que se pasa a SignPades. Esta versión no cuenta con ruta PKCS#11 o HSM ni backend de archivo de clave de software, por lo que cuando PadesCryptoAvailable devuelve False, simplemente no es posible realizar firmas en esa computadora

Dónde se detiene PAdES B-B

PAdES B-B es el punto de partida, la base de los cuatro niveles de PAdES: demuestra quién firmó y que los bytes no han cambiado desde entonces, y nada más allá de eso. Una firma B-B no incluye una marca de tiempo de confianza, por lo que no puede probar cuándo se realizó la firma, y no integra datos de revocación, por lo que un verificador años después deberá recuperar la cadena de certificados y su estado por sí mismo. Esas brechas son precisamente las que cierran los niveles superiores. Cuando necesite una hora de firma que un auditor acepte, añadir una marca de tiempo RFC 3161 y DSS para validación a largo plazo eleva la firma a B-T y superiores; cuando desee leer una firma existente y confirmar qué nivel alcanzó, inspeccionar una firma PDF y su nivel PAdES es la herramienta adecuada; y antes de firmar cualquier documento, auditar un PDF en busca de riesgos de seguridad le indica lo que está a punto de respaldar con su firma

Los métodos SignPades mostrados aquí se distribuyen con el PDFium Component para Delphi y C++Builder, junto con la inspección de firma de solo lectura que PDFium ofrece de forma predeterminada