Artículo técnico

Banco de trabajo de conformidad y firma en Delphi

Un banco de trabajo que encadena la validación de conformidad con la firma digital tiene que coordinar cuatro pasos, en este orden, y mantenerlos ligados a un único conjunto de bytes de principio a fin. Ejecuta un preflight PDF/A o PDF/UA. Aplica las correcciones que exijan los hallazgos y guarda una revisión corregida. Firma exactamente esa revisión. Después vuelve a leer el archivo firmado y confirma que la firma realmente lo cubre. El orden no es cosmético. Si se omite la relectura, se está confiando en la propia ruta de escritura; si el preflight se ejecuta contra la revisión equivocada, el informe de conformidad describe un archivo que nunca se entregó

La parte en la que más se equivocan los pipelines caseros es la costura entre validación y firma. Si se ejecutan como dos herramientas separadas con una pasada de corrección en medio, llegan a existir al menos tres revisiones distintas del archivo, cada una con sus propios bytes. El informe de preflight que se entrega a un auditor describe una de ellas. La firma congela otra. Nada en el archivo indica que sean la misma revisión, y a menudo no lo son. PDF Library for Delphi, la PDF Developer Library de losLab para Delphi y C++Builder, coloca el preflight y la firma PAdES detrás de una única clase fachada, de modo que toda la secuencia puede vivir en un solo proceso que nunca pierde de vista de qué bytes está hablando. Cada llamada que aparece abajo existe hoy en la biblioteca, y también cada trampa anotada junto a ella

Diagrama de un banco de trabajo de conformidad y firma en Delphi donde los pasos de preflight, correcció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 atan 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

Cuenta los guardados. El original llega del proceso anterior. La pasada de corrección lo carga, activa un modo de conformidad y escribe una revisión corregida. La pasada de firma añade una firma como actualización incremental, que es una tercera escritura. Tres guardados, tres disposiciones de bytes, y un informe de preflight no significa nada a menos que indique cuál de las tres cubre. Un SHA-256 del archivo, registrado junto a cada ejecución de preflight y cada firma, es el ancla barata que permite demostrar que la revisión validada es la revisión firmada

Un comportamiento de la biblioteca refuerza aún más esa disciplina. Las correcciones de conformidad solicitadas mediante SetPDFAMode o SetPDFUAMode no surten efecto cuando se llaman. Se aplican durante el guardado. Las reparaciones automáticas, como forzar los indicadores de impresión de las anotaciones o asignar un orden de tabulación PDF/UA, aterrizan en el archivo de salida y en ningún otro sitio, así que una comprobación ejecutada contra el documento que se acaba de "corregir" en memoria no dice nada sobre los bytes que van camino del firmante. Guarda primero y después pasa el preflight al archivo guardado. El estado en memoria es un borrador; solo el archivo en disco es real

Preflight desde disco, y el cero que significa dos cosas

El punto de entrada plano del preflight 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 a través del lector en streaming de la biblioteca, así que no hace falta llamar antes a LoadFromFile, y devuelve un identificador de lista de cadenas que lleva 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 del camino 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 vuelve vacía, incluido un fallo de lectura. Un banco de trabajo que interprete el 0 como luz verde aprobará alegremente un archivo que otro proceso tiene bloqueado. Emparejar la llamada con LastErrorCode, como arriba, es lo que separa los dos casos. El comprobador abre además el archivo con un modo de compartición que deniega la escritura, así que si el paso de corrección todavía retiene un handle de escritura, el preflight falla por una razón que no tiene nada que ver con la conformidad y todo que ver con un stream que se 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 en Delphi
Un cero de CheckFileCompliance no significa nada hasta que LastErrorCode separa una lista de hallazgos vacía de un archivo que la biblioteca no pudo abrir

Cuando quien necesita leer los hallazgos es una persona y no un pipeline, CreatePreflightReport los renderiza como un informe legible. ComparePreflightReports compara dos ejecuciones, una forma ordenada de demostrar que la corrección eliminó los hallazgos originales sin introducir otros nuevos a escondidas

Firmar la revisión comprobada con un SignProcess

Una vez que la revisión guardada supera el preflight y su hash queda registrado, se firma exactamente ese archivo y ningún otro. La API SignProcess se lee como un builder. Se abre un handle de proceso, se configura línea a línea, se confirma y después se lee 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');  // perfil PAdES baseline
PDF.SetSignProcessDigestAlgorithm(ProcessID, 2);                      // SHA-256
PDF.SetSignProcessReserveContentsBytes(ProcessID, 8192);              // espacio para un sello de tiempo posterior
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 pesan más de lo que aparentan. SetSignProcessCustomSubFilter con ETSI.CAdES.detached elige una firma PAdES según el perfil de ETSI EN 319 142-1 en lugar de la familia heredada adbe.pkcs7.detached, que es la diferencia entre una firma que un validador europeo acepta y una que marca. SetSignProcessReserveContentsBytes rellena el marcador de posición de /Contents, y el tamaño que se elige aquí es una decisión sobre el futuro: si alguna vez va a seguir un sello de tiempo de firma, el CMS ampliado tiene que caber en el espacio que se reserva ahora, porque el marcador no puede crecer después sin volver a firmar todo. Si se reserva con generosidad, se desperdician unos pocos kilobytes. Si se reserva demasiado ajustado, el paso del sello de tiempo falla dentro de unos meses con un desbordamiento que costará relacionar con esta única línea

GetSignProcessResult responde con un código, no con un booleano, y los códigos merecen conservarse. 1 es éxito. 4 es una contraseña de PDF incorrecta, 7 una contraseña de certificado incorrecta, 9 un PFX que no lleva clave privada, 11 un fallo mientras se aplicaba la firma. Si se colapsan en un verdadero/falso, se tira la única información que distingue un caso de soporte por contraseña incorrecta de uno por clave sin parte privada. Registra el entero

Relectura: auditar el archivo que se 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 reabre 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
  // Capturar el tamaño antes de Open: el objeto de auditoría mantiene un bloqueo compartido 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 corresponden con entradas del diccionario. La clave 0 devuelve el CMS en bruto de /Contents, las claves 2 y 3 los nombres de /Filter y /SubFilter, y de la 11 a la 14 los cuatro números del ByteRange. Los valores de texto se obtienen en cambio mediante GetSignatureTextValueByName: la clave 0 es la hora de firma declarada, y la clave 5 distingue un Sig ordinario de un DocTimeStamp, algo que importa en cuanto un documento lleva ambos

La captura del tamaño del archivo al principio de ese ejemplo es estructural, no una tarea de limpieza. TPDFlibSignDoc.Open mantiene el archivo bajo un bloqueo de compartición restrictivo durante toda su vida, así que cualquier cosa que necesite los bytes en bruto (calcular el hash del rango firmado, recalcular el resumen del CMS) tiene que leer el archivo antes de llamar a Open. La propia demo SigningWorkbench de la biblioteca lee primero el archivo completo en memoria precisamente por esta razón, y un banco de trabajo que ignore el orden falla de forma intermitente, en la máquina que casualmente pierda la carrera

Aritmética de ByteRange que demuestra la cobertura

Un archivo sano con una única firma tiene un ByteRange de la forma [0 a b c]: la cobertura empieza en el desplazamiento 0, salta el marcador hexadecimal de /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 se queda corto, alguien añadió 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 rellenos de formulario posteriores, una segunda firma y un diccionario DSS llegan todos exactamente así. También es precisamente el hecho que una pista de auditoría debería registrar en el momento de la firma en lugar de reconstruir bajo presión durante una disputa

PDF Library for Delphi: anatomía del ByteRange de un PDF firmado que muestra el hueco del marcador de Contents junto a un caso de cobertura completa y un caso de actualización incremental añadida
Un ByteRange de 0 a b c cubre el archivo solo cuando b + c alcanza el final del archivo, así que la auditoría registra cualquier actualización incremental añadida tras la firma

Vigila el ancho del entero mientras haces esta aritmética. El 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 accesor plano trunca en silencio. Recurre al TPDFlibSigner.GetByteRange de la capa de clases, que devuelve Int64, o analiza los valores a partir de GetSignatureValueByName como hace el código de auditoría anterior

Lo que la biblioteca deja en tus manos

Dos límites conviene aprenderlos en la fase de diseño y no en el sprint final. La API plana TPDFlib no lleva ningún envoltorio de verificación de firmas. La verificación criptográfica vive una capa más abajo, en TPDFlibSignatureVerifier, cuyo VerifySignature responde válida, inválida o desconocida. Tampoco hay un cliente HTTP integrado para autoridades de sellado de tiempo RFC 3161. La biblioteca calcula el hash que hay que enviar y vuelve a incrustar el CMS aumentado cuando llega un token, pero el viaje de ida y vuelta por la red hasta la TSA te toca escribirlo a ti. Ambos son sencillos de envolver y realmente desagradables de descubrir ausentes la semana antes de una release, así que diséñalos desde el primer boceto

Una pregunta sobre la conformidad merece zanjarse con claridad, porque decide dónde va la última puerta: ¿añadir una firma rompe PDF/A? No por sí sola. La firma llega como actualización incremental, y desde ISO 19005-2 en adelante se permiten explícitamente los documentos firmados. La pega es la apariencia de la firma, que se rige por las mismas reglas que cualquier otro contenido de página, fuentes incrustadas y ningún color dependiente del dispositivo incluidos. Así que la última puerta del banco de trabajo es una ejecución más del preflight, esta vez contra la salida firmada. Trata CheckFileCompliance como la comprobación rápida dentro del pipeline y verifica igualmente los candidatos a release con una herramienta independiente como veraPDF, ya que los validadores implementan conjuntos de reglas que se solapan pero no son idénticos; cuando los dos discrepan, el texto del hallazgo suele nombrar la cláusula que hay que ir a leer

De todo esto se desprende un punto de secuenciación. Firmar y sellar en el tiempo no son una única pasada: primero se escribe la firma baseline, y después un proceso de sellado de tiempo aparte aumenta el CMS dentro del espacio reservado de /Contents, que es exactamente la razón por la que la línea de bytes de reserva de antes pesaba tanto. Para las capas de sello de tiempo y validación a largo plazo que se construyen sobre este banco de trabajo, el recorrido por la firma y validación PAdES lleva la firma de baseline a B-LT, y la mitad del preflight se profundiza en la guía de preflight 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