Artículo técnico

Firma de archivos PDF con PAdES B-B en Delphi usando PDFium

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 CMS CAdES y añade la firma como una actualización incremental. El motor 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. PDFium Component soluciona esa limitación encargándose de todo el flujo de firma en Pascal, desde la función de hash hasta la inyección a nivel de bytes, y este artículo recorre dicho flujo de principio a fin

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

PDFium expone las firmas como objetos de solo lectura y no ofrece nada 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 para /Contents o escriba un rango de bytes; el guardado incremental existe (FPDF_SaveAsCopy con FPDF_INCREMENTAL) pero carece de un conector de firma. Cualquier componente que firme un PDF utilizando PDFium debe, por lo tanto, generar cada byte de la firma por sí mismo, razón por la cual PDFium Component construye el mecanismo a partir de tres unidades puras de Pascal. FPC 3.2.2 incluye md5 y sha1 pero ningún soporte SHA-2 en absoluto, y la API SHA-256 de System.Hash en Delphi no es compatible con el código fuente de FPC, por lo que FPdfSha256 is a self-contained FIPS 180-4 implementation that keeps every CMS code path on one TSHA256Digest type with no compiler branching. FPdfAsn1 proporciona el codificador y lector DER que necesitan las estructuras CMS, y FPdfCms ensambla la estructura CAdES SignedData sobre ambos

¿Cómo firmar digitalmente un PDF en Delphi?

Cargue el documento, luego llame a SignPades con una huella digital (thumbprint) de certificado. PDFium Component resuelve esa huella digital en el almacén de certificados "MY" de usuario actual, obtiene el certificado correspondiente y su clave privada, y escribe una copia firmada en la ruta que 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;

PadesCryptoAvailable es la comprobación que debe realizar primero, siempre. En Windows devuelve True y el motor crypt32/ncrypt está activo; en cualquier otra plataforma devuelve False y una llamada de firma generaría EPadesCrypto. Tratar esta protección como obligatoria evita que una compilación de Linux o macOS falle en tiempo de ejecución en una ruta que no puede funcionar allí. La huella digital en sí es el hash SHA-1 del certificado, el mismo valor que el administrador de certificados de Windows muestra en su pestaña Detalles, e identifica a un firmante específico sin colocar nunca información de la clave en su código fuente

Qué contiene el CMS: atributos firmados y RFC 5652

Una firma de referencia PAdES no es una firma RSA directa sobre el archivo; es una estructura CMS SignedData CAdES que contiene un conjunto obligatorio de atributos firmados, y FPdfCms.BuildSignedData emite exactamente ese conjunto: content-type, message-digest y signing-certificate-v2, el atributo ESS que vinculas 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 hash 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, procesa en el hash la forma 0x31, y luego reescribe únicamente el byte de la etiqueta inicial a 0xA0 para la 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 de diccionario de firma que define ISO 32000-1 §12.8.1: Reason, Location, ContactInfo y Name, todos opcionales y todos escritos en el diccionario de valor de firma. Existe una restricción con la que es fácil tropezar. Si establece CommitmentTypeOid para añadir un atributo firmado de indicación de tipo de compromiso CAdES, no configure 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 firma misma, y PAdES resuelve esa circularidad con un marcador de posición de ancho fijo que SignPadesBytes gestiona con precisión. Reserva una cadena hexadecimal /Contents de ContentsSize bytes (16384 por defecto, cómodamente mayor que una estructura CMS SignedData típica), serializa la actualización incremental para localizar el desplazamiento exacto del espacio, y luego calcula el /ByteRange como dos tramos que enmarcan dicho 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 tramos. La estructura CMS finalizada se codifica en hexadecimal en el espacio reservado, se rellena con ceros hasta el ancho fijo y se añade la actualización de referencias cruzadas. Debido a que el ancho se fija desde el principio, rellenar el espacio no desplaza ningún byte posterior, que es la razón por la cual el rango de bytes se mantiene válido; los bytes del documento original se conservan textualmente, por lo que una firma anterior en el mismo archivo sobrevive intacta exactamente como requiere la firma incremental de ISO 32000-1 §12.8.1

El motor Windows CNG y sus límites

PDFium Component firma únicamente en Windows, y ese límite es deliberado. 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 la estándar de CNG: abre el almacén "MY", busca el certificado por hash, adquiere el identificador de su 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 una corrección que los demás no necesitan, debido a que NCryptSignHash devuelve el par bruto r y s de IEEE P1363 mientras que CMS espera una SEQUENCE DER ECDSA-Sig-Value, por lo que el motor 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. Un certificado guardado en un archivo PFX funciona solo 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 tiene una ruta PKCS#11 o HSM ni un motor de archivo de clave de software, por lo que cuando PadesCryptoAvailable devuelve False simplemente no es posible realizar firmas en esa máquina

Dónde se detiene PAdES B-B

PAdES B-B es la referencia básica, el nivel mínimo de los cuatro niveles de PAdES: demuestra quién firmó y que los bytes no se han modificado desde entonces, y nada más allá de eso. Una firma B-B no contiene una marca de tiempo de confianza, por lo que no puede probar cuándo ocurrió la firma, y no incrusta datos de revocación, por lo que un validador años después debe obtener por sí mismo la cadena de certificados y su estado. Esas lagunas son precisamente las que cierran los niveles superiores. Cuando necesite una hora de firma que un auditor acepte, la adición de 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 finalizada y confirmar qué nivel alcanzó, la inspección de una firma PDF y su nivel PAdES es la herramienta complementaria; y antes de firmar cualquier cosa, la auditoría de un PDF para detectar riesgos de seguridad le indica qué es lo que está a punto de firmar

Los métodos SignPades que se muestran aquí se distribuyen con PDFium Component para Delphi y C++Builder, junto con la inspección de firma de solo lectura que PDFium proporciona de fábrica