Artículo técnico

Building a Compliance and Signing Workbench in Delphi with PDF Library for Delphi

Un banco de trabajo que encadena la validación de conformidad con la firma digital debe coordinar cuatro pasos, en este orden, y mantenerlos vinculados al mismo conjunto de bytes de principio a fin. Ejecuta una prevalidación de PDF/A o PDF/UA. Aplica las correcciones que exijan los hallazgos y guarda una revisión corregida. Firma esa revisión exacta. Después vuelve a leer el archivo firmado y confirma que la firma realmente lo cubre. El orden no es cosmético. Si omite la lectura de vuelta, estará confiando en su propia ruta de escritura; si deja que la prevalidación se ejecute contra la revisión equivocada, su informe de conformidad describirá un archivo que nunca entregó

La parte que más pipelines hechos internamente resuelven mal es la unión entre la validación y la firma. Ejecútelas como dos herramientas separadas con una pasada de corrección entre ambas y existirán al menos tres revisiones distintas del archivo, cada una con sus propios bytes. El informe de prevalidación que entrega a un auditor describe una de ellas. La firma congela otra. Nada en el archivo indica que sean la misma revisión y, con frecuencia, no lo son. PDF Library for Delphi, la biblioteca de desarrollo PDF de losLab para Delphi y C++Builder, coloca la prevalidación y la firma PAdES detrás de una sola clase de fachada, de modo que toda la secuencia puede vivir en un proceso que nunca pierde de vista de qué bytes está hablando. Todas las llamadas que aparecen a continuación existen hoy en la biblioteca, al igual que cada una de las trampas señaladas junto a ellas

Diagrama de un banco de trabajo de Delphi para cumplimiento y firma donde preflight, remediación, firma PAdES y auditoría de ByteRange registran cada uno un SHA-256 sobre la revisión exacta que tocan
Los hashes registrados junto a cada guardado vinculan el informe de preflight, la firma PAdES y la auditoría a una misma revisión idéntica

Tres revisiones de un documento y cómo se abre la brecha

Cuente los guardados. El original llega desde un sistema anterior. La pasada de corrección lo carga, activa un modo de conformidad y escribe una revisión corregida. La pasada de firma agrega una firma como una actualización incremental, lo que constituye una tercera escritura. Tres guardados, tres distribuciones de bytes, y un informe de prevalidación no significa nada a menos que identifique cuál de los tres cubre. Un SHA-256 del archivo, registrado junto a cada ejecución de prevalidación y cada firma, es el ancla económica que le permite demostrar que la revisión que validó es la revisión que firmó

Un comportamiento de la biblioteca refuerza aún más esa disciplina. Las correcciones de conformidad solicitadas mediante SetPDFAMode o SetPDFUAMode no tienen efecto cuando las llama. Se aplican al guardar. Las reparaciones automáticas, como forzar indicadores de impresión en anotaciones o asignar un orden de pestañas PDF/UA, llegan al archivo de salida y a ningún otro lugar, así que una comprobación ejecutada contra el documento que acaba de "corregir" en memoria no le dice nada sobre los bytes que irán al firmante. Guarde primero y luego ejecute la prevalidación sobre el archivo guardado. El estado en memoria es un borrador; solo el archivo en disco es real

Prevalidación desde disco y el cero que significa dos cosas

El punto de entrada plano para la prevalidación es CheckFileCompliance(FileName, Password, ComplianceTest, Options). La prueba 1 selecciona PDF/A (ISO 19005), la prueba 2 selecciona PDF/UA (ISO 14289). Abre el archivo mediante el lector de streaming de la biblioteca, por lo que no es necesario llamar antes a LoadFromFile, y devuelve un identificador de lista de cadenas con un hallazgo por entrada:

var
  PDF: TPDFlib;
  ListID, I: Integer;
begin
  PDF := TPDFlib.Create;
  try
    ListID := PDF.CheckFileCompliance('invoice-fixed.pdf', '', 1, 0);  // 1 = PDF/A
    if ListID = 0 then
    begin
      if PDF.LastErrorCode <> 0 then
        raise Exception.Create('Preflight could not read the file')
      else
        Writeln('No PDF/A findings');
    end
    else
    begin
      for I := 0 to PDF.GetStringListCount(ListID) - 1 do
        Writeln(PDF.GetStringListItem(ListID, I));
      PDF.ReleaseStringList(ListID);
    end;
  finally
    PDF.Free;
  end;
end;

La trampa está en el valor de retorno y es del tipo que supera todas las pruebas de ruta feliz. Cero significa "sin hallazgos". Cero también significa "no se pudo abrir el archivo", porque la implementación devuelve 0 siempre que la lista de resultados queda vacía, incluso ante un error de lectura. Un banco de trabajo que interprete 0 como luz verde aprobará alegremente un archivo que otro proceso bloqueó. Combinar la llamada con LastErrorCode, como se hizo arriba, es lo que separa los dos casos. El comprobador también abre el archivo con un modo de compartición que niega escritura, por lo que, si el paso de corrección aún mantiene un identificador de escritura, la prevalidación falla por una razón que no tiene nada que ver con la conformidad y sí con un stream que olvidó liberar

Diagrama de decisión que muestra cómo LastErrorCode separa los dos significados de un retorno cero de CheckFileCompliance en un preflight PDF de Delphi
Un cero de CheckFileCompliance no significa nada hasta que LastErrorCode distinga una lista de hallazgos vacía de un archivo que la biblioteca no pudo abrir

Cuando una persona, en lugar de un pipeline, necesita leer los hallazgos, CreatePreflightReport los presenta como un informe legible. ComparePreflightReports compara dos ejecuciones, una forma ordenada de mostrar que la corrección resolvió los hallazgos originales sin introducir discretamente otros nuevos

Firmar la revisión comprobada con un SignProcess

Una vez que la revisión guardada supera la prevalidación y su hash queda registrado, firme ese archivo exacto y ningún otro. La API de SignProcess se lee como un generador. Abra un identificador de proceso, configúrelo línea por línea, confirme y luego lea el código de resultado

ProcessID := PDF.NewSignProcessFromFile('invoice-fixed.pdf', '');
if ProcessID = 0 then
  raise Exception.Create('Cannot open source for signing');
PDF.SetSignProcessField(ProcessID, 'ApprovalSig');
PDF.SetSignProcessPFXFromFile(ProcessID, 'company.pfx', PfxPassword);
PDF.SetSignProcessInfo(ProcessID, 'Invoice approval', 'Berlin', 'billing@example.com');
PDF.SetSignProcessCustomSubFilter(ProcessID, 'ETSI.CAdES.detached');  // PAdES baseline
PDF.SetSignProcessDigestAlgorithm(ProcessID, 2);                      // SHA-256
PDF.SetSignProcessReserveContentsBytes(ProcessID, 8192);              // room for a later timestamp
PDF.EndSignProcessToFile(ProcessID, 'invoice-signed.pdf');
if PDF.GetSignProcessResult(ProcessID) <> 1 then
  Writeln('Sign failed, code ', PDF.GetSignProcessResult(ProcessID));
PDF.ReleaseSignProcess(ProcessID);

Dos líneas de esa secuencia tienen más peso del que aparentan. SetSignProcessCustomSubFilter con ETSI.CAdES.detached elige una firma PAdES con el perfil de ETSI EN 319 142-1, en lugar de la familia heredada adbe.pkcs7.detached; esa es la diferencia entre una firma que acepta un validador europeo y otra que marca. SetSignProcessReserveContentsBytes rellena el marcador de posición de /Contents, y el tamaño que elija aquí es una decisión sobre el futuro: si después habrá una marca de tiempo de firma, el CMS ampliado debe caber en el espacio que reserve ahora, porque el marcador no puede crecer más adelante sin volver a firmar todo. Reserve con generosidad y desperdiciará unos kilobytes. Reserve demasiado poco y el paso de marca de tiempo fallará meses después con un desbordamiento que le costará relacionar con esta única línea

GetSignProcessResult responde con un código, no con un booleano, y vale la pena conservar los códigos. 1 es éxito. 4 es una contraseña PDF incorrecta, 7 una contraseña de certificado incorrecta, 9 un PFX sin clave privada, 11 un fallo mientras se aplicaba la firma. Si los reduce a verdadero/falso, descartará la única información que distingue un caso de soporte de contraseña equivocada de uno de clave sin parte privada. Registre el entero

Lectura de vuelta: auditar el archivo que acaba de producir

Ningún banco de trabajo debería confiar en la ruta que escribió el archivo que está a punto de certificar. La clase de auditoría TPDFlibSignDoc vuelve a abrir la salida firmada y lee las entradas del diccionario de firma directamente desde el disco:

var
  Doc: TPDFlibSignDoc;
  Names: TStringList;
  FS: TFileStream;
  I: Integer;
  SourceSize, RangeStart, GapStart, TailStart, TailLen: Int64;
begin
  // Captura el tamaño antes de Open: el objeto de auditoría deja un share lock sobre el archivo
  FS := TFileStream.Create('invoice-signed.pdf', fmOpenRead or fmShareDenyNone);
  SourceSize := FS.Size;
  FS.Free;
  Doc := TPDFlibSignDoc.Create;
  Names := TStringList.Create;
  try
    if not Doc.Open('invoice-signed.pdf', '', False) then Exit;
    Doc.GetSignatureFieldNames(Names);
    for I := 0 to Names.Count - 1 do
      if Doc.GetSignatureValueObjNum(Names[I]) > 0 then  // > 0 significa que el campo está firmado
      begin
        RangeStart := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 11)));
        GapStart   := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 12)));
        TailStart  := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 13)));
        TailLen    := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 14)));
        if (RangeStart = 0) and (TailStart + TailLen = SourceSize) then
          Writeln(Names[I], ': signature covers the file to EOF')
        else
          Writeln(Names[I], ': earlier revision, or unusual ByteRange layout');
      end;
    Doc.Close;
  finally
    Names.Free;
    Doc.Free;
  end;
end;

Los argumentos ValueKey se asignan a entradas del diccionario. La clave 0 devuelve el CMS sin procesar de /Contents, las claves 2 y 3 los nombres /Filter y /SubFilter, y de 11 a 14 los cuatro números ByteRange. En cambio, los valores de texto regresan mediante GetSignatureTextValueByName: la clave 0 es la hora de firma declarada, y la clave 5 distingue una Sig común de una DocTimeStamp, algo importante cuando un documento contiene ambas

La captura del tamaño de archivo al inicio de ese ejemplo es esencial, no una tarea administrativa. TPDFlibSignDoc.Open mantiene el archivo bajo un bloqueo de compartición restrictivo durante toda su vida útil, por lo que cualquier tarea que necesite los bytes sin procesar (calcular el hash del rango firmado, volver a calcular el digest CMS) debe leer el archivo antes de llamar a Open. La propia demostración SigningWorkbench de la biblioteca lee todo el archivo en memoria antes por esta razón exacta, y un banco de trabajo que ignore el orden fallará de manera intermitente, en la máquina que resulte perder la carrera

Aritmética ByteRange que demuestra la cobertura

Un archivo sano con una sola firma tiene un ByteRange de la forma [0 a b c]: la cobertura comienza en el desplazamiento 0, omite el marcador hexadecimal /Contents entre a y b, y luego continúa hasta el byte b+c. Cuando b+c es igual al tamaño del archivo, la firma cubre todo hasta el final del archivo, que es el resultado deseado. Cuando queda corto, alguien agregó una actualización incremental después de escribir la firma. Eso es perfectamente legítimo según ISO 32000-1§12.8, ya que los llenados posteriores de formularios, una segunda firma y un diccionario DSS llegan exactamente de esta manera. También es precisamente el hecho que una pista de auditoría debe registrar al momento de firmar, en vez de reconstruir bajo presión durante una disputa

PDF Library for Delphi: anatomía de ByteRange de un PDF firmado que muestra el hueco del marcador de posición de Contents, más un caso de cobertura total y un caso de actualización incremental adjunta
Un ByteRange de 0 a b c cubre el archivo solo cuando b + c alcanza el final del archivo, por lo que la auditoría registra cualquier actualización incremental agregada después de la firma

Vigile el ancho de los enteros al hacer esta aritmética. GetSignProcessByteRange de la API plana devuelve un Integer de 32 bits, pero los valores subyacentes son Int64, así que en un archivo de más de 2 GB el acceso plano trunca silenciosamente. Use TPDFlibSigner.GetByteRange de la capa de clases, que devuelve Int64, o analice los valores de GetSignatureValueByName como hace el código de auditoría anterior

Lo que la biblioteca deja en sus manos

Es mejor aprender dos límites en la etapa de diseño que durante el sprint final. La API plana TPDFlib no incluye ningún contenedor para verificación de firmas. La verificación criptográfica vive una capa más abajo, en TPDFlibSignatureVerifier, cuyo VerifySignature responde válido, no válido o desconocido. Tampoco hay un cliente HTTP integrado para autoridades de sellado de tiempo RFC 3161. La biblioteca calcula el hash que debe enviarse y vuelve a incrustar el CMS aumentado cuando llega un token, pero el viaje de red de ida y vuelta hacia la TSA debe escribirlo usted. Ambos son sencillos de encapsular y verdaderamente desagradables de descubrir ausentes la semana previa a un lanzamiento, así que inclúyalos desde el primer boceto

Conviene resolver con claridad una pregunta sobre conformidad, porque decide dónde va la última puerta: ¿agregar una firma rompe PDF/A? No por sí solo. La firma llega como una actualización incremental, e ISO 19005-2 en adelante permite explícitamente documentos firmados. El detalle es la apariencia de la firma, que sigue las mismas reglas que cualquier contenido de página, con fuentes incrustadas y sin color dependiente del dispositivo. Por ello, la puerta final del banco de trabajo es otra ejecución de prevalidación, esta vez contra la salida firmada. Trate CheckFileCompliance como la comprobación rápida dentro del pipeline y aun así verifique los candidatos de lanzamiento con una herramienta independiente como veraPDF, ya que los validadores implementan conjuntos de reglas superpuestos pero no idénticos; cuando discrepan, el texto del hallazgo suele indicar la cláusula que debe consultar

De todo esto se desprende un punto sobre la secuencia. La firma y el sellado de tiempo no son una sola pasada: primero se escribe la firma base y luego un proceso de marca de tiempo independiente aumenta el CMS dentro del espacio /Contents reservado, justo por eso la línea anterior de bytes reservados tenía tanto peso. Para las capas de marca de tiempo y validación a largo plazo que se construyen sobre este banco de trabajo, la guía de firma y validación PAdES lleva la firma desde la línea base hasta B-LT, y la parte de prevalidación se desarrolla más en la guía de prevalidación PDF/A y PDF/UA. La documentación completa de la API y las descargas de prueba están en la página del producto PDF Library for Delphi