Artículo técnico

FDF Differences en Delphi: añadir revisiones firmadas

HotPDF Component aplica un stream FDF /Differences desde Delphi mediante THPDFHeadlessDocument.SaveFDFDifferencesToFile: el método comprueba que el PDF cargado todavía coincide con una revisión base que identificáis por longitud exacta en bytes y SHA-256, añade los bytes de revisión acumulados exactamente una vez, pasa el resultado por la admisión de permisos de cifrado y DocMDP/FieldMDP, y lo publica atómicamente. Repetir la misma entrega es un no-op byte a byte

El flujo para el que esto existe es poco glamuroso y común. Enviáis un contrato o un formulario de inspección como PDF, alguien lo rellena y lo firma en su propio visor, y lo que vuelve no es el PDF sino un fichero FDF. Los valores de campo viajan en /Fields, pero la revisión firmada en sí, los bytes que la firma CMS cubre de verdad, viajan en /Differences. Torcer el merge y o rompéis la firma o, peor, aceptáis valores de campo que ninguna firma cubrió

Qué lleva realmente un stream FDF /Differences

Un stream FDF /Differences lleva bytes en crudo de actualización incremental para el PDF objetivo, no un historial a nivel FDF. ISO 32000-1 §12.7.7.3, Tabla 243 define la entrada Differences del diccionario FDF como los cambios incrementales hechos al PDF subyacente desde que se abrió, en la misma forma que una actualización incremental PDF bajo §7.5.6: objetos nuevos y sustituidos, una sección de cross-reference y un trailer cuyo /Prev apunta hacia dentro del fichero base

El contenedor FDF en sí es lo contrario. §12.7.7.1 prohíbe actualizaciones incrementales en ficheros FDF, así que un FDF tiene un único trailer, solo objetos de generación 0 y números de objeto únicos. HotPDF Component exige exactamente eso en su parser FDF y nunca lo relaja para hacer /Differences más fácil. Los datos incrementales viven dentro de un único stream de un contenedor estrictamente no incremental, y mantener esas dos capas separadas es media tarea

Diagrama de anatomía de FDF /Differences en HotPDF para Delphi: un contenedor FDF estrictamente no incremental con un único trailer y objetos de generación 0 lleva un único stream /Differences acumulativo de bytes de actualización incremental en crudo, mientras /Fields y /Annots siguen sin firmar y sin fusionar
La revisión firmada viaja en el stream /Differences, no en /Fields: el contenedor en sí prohíbe actualizaciones incrementales, y solo la carga de bytes que cubre la firma CMS es de fiar

Dos propiedades del stream moldean cada decisión de diseño aguas abajo:

  • El stream es acumulativo: contiene cada byte añadido desde que se abrió el PDF objetivo, así que una segunda entrega de la misma sesión repite la revisión que ya confirmasteis y añade la nueva detrás
  • Las entradas externas /Fields y /Annots del mismo FDF no tienen garantizado coincidir con /Differences, así que los valores de campo que podéis leer fácilmente no son los datos que cubre la firma

Ese segundo punto merece crudeza. Si vuestro código de importación lee /V de /Fields y trata el resultado como firmado porque una firma llegó en el mismo fichero, habéis construido un canal de falsificación. HotPDF Component mantiene las rutas separadas a propósito: el importador de anotaciones FDF rechaza /Differences de plano, y solo el transporte de Differences dedicado lo toca

Ligar el append a una revisión base explícita

SaveFDFDifferencesToFile se niega a adivinar a qué revisión pertenece el FDF. El llamador pasa BaseBytes, la longitud exacta de la revisión PDF completa desde la que se abrió el formulario, y BaseSHA256, su SHA-256 hexadecimal de 64 dígitos. El método recalcula el hash de ese prefijo del documento cargado, rechaza una discrepancia con "The loaded PDF does not match the explicitly selected base", y comprueba que el prefijo termina en un punto final de revisión real cuyo startxref apunta a sus datos de cross-reference reales

La regla práctica es anotar ambos números cuando enviáis el formulario, no cuando el FDF vuelve. Un pequeño helper sobre las rutinas SHA256Init / SHA256Update / SHA256Final de HPDFCrypt basta:

uses
  SysUtils, Classes, HPDFCrypt;

function PrefixSHA256Hex(const FileName: string; PrefixBytes: Int64): AnsiString;
var
  Stream: TFileStream;
  Ctx: TSHA256Context;
  Digest: TSHA256Digest;
  Buffer: array[0..65535] of Byte;
  Remaining: Int64;
  Count, I: Integer;
begin
  Stream := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    if (PrefixBytes <= 0) or (PrefixBytes > Stream.Size) then
      raise EArgumentException.Create('Base length is outside the file');
    SHA256Init(Ctx);
    Remaining := PrefixBytes;
    while Remaining > 0 do
    begin
      Count := SizeOf(Buffer);
      if Remaining < Count then
        Count := Integer(Remaining);
      Stream.ReadBuffer(Buffer, Count);
      SHA256Update(Ctx, Buffer, Count);
      Dec(Remaining, Count);
    end;
    SHA256Final(Ctx, Digest);
  finally
    Stream.Free;
  end;
  Result := '';
  for I := 0 to High(Digest) do
    Result := Result + AnsiString(LowerCase(IntToHex(Digest[I], 2)));
end;

// En el envío: todo el fichero saliente es la revisión base
// BaseLength := tamaño de 'out\contract-0042.pdf' en bytes
// BaseHash   := PrefixSHA256Hex('out\contract-0042.pdf', BaseLength);

El documento cargado no tiene que ser exactamente la base. Puede ser la base más las revisiones que ya confirmasteis de entregas anteriores, que es como el stream acumulativo se mantiene manejable: BaseBytes sigue nombrando el prefijo original, y todo lo que va después se trata como bytes de append previos

Disposición de bytes de FDF Differences en HotPDF para Delphi: el PDF cargado se divide en la revisión base verificada por la longitud BaseBytes y BaseSHA256 más los bytes de append previos ya confirmados, y una carga /Differences acumulativa que debe repetir esos bytes antes de añadir un sufijo nuevo
El prefijo base queda fijado por longitud y SHA-256, los bytes confirmados actúan como un contrato de solo-append, y solo el sufijo final es nuevo - una entrega repetida aterriza entonces como un no-op byte a byte

Aplicar las Differences con SaveFDFDifferencesToFile

THPDFHeadlessDocument.SaveFDFDifferencesToFile toma el nombre del fichero de salida, los bytes del FDF, la longitud y hash base, una selección de FDF embebido opcional y un registro de límites THPDFFDFPackageOptions, y devuelve un THPDFHeadlessFDFDifferencesResult. Dentro, abre el FDF como un THPDFFDFPackage y llama él mismo a DecodeDifferences, así que pasáis el fichero FDF, no un stream pre-decodificado. Un Selection vacío significa el FDF de nivel superior; uno no vacío entra primero en un FDF hijo embebido

uses
  SysUtils, IOUtils, HPDFHeadlessDocument, HPDFHeadlessCMS, HPDFFDFPackage;

procedure CommitReturnedForm(const CurrentPdf, NextPdf, FdfFile: string;
  BaseLength: Int64; const BaseHash: AnsiString);
var
  Doc: THPDFHeadlessDocument;
  Policy: THPDFHeadlessCMSOptions;
  Selection: THPDFFDFChildSelections;
  Applied: THPDFHeadlessFDFDifferencesResult;
begin
  Doc := THPDFHeadlessDocument.Create;
  try
    Policy := THPDFHeadlessCMSOptions.Default;
    Policy.TrustedCAFile := 'trust\issuing-ca.pem';
    Doc.SetSignaturePolicyOptions(Policy);
    Doc.LoadFromFile(CurrentPdf);

    Selection := nil;  // FDF de nivel superior, sin hijo embebido
    Applied := Doc.SaveFDFDifferencesToFile(NextPdf,
      TFile.ReadAllBytes(FdfFile), BaseLength, BaseHash,
      Selection, THPDFFDFPackageOptions.Default);

    Writeln(Format('base %d, prior %d, total %d, added %d, no-op %s',
      [Applied.BaseBytes, Applied.PriorAppendBytes, Applied.TotalAppendBytes,
       Applied.AddedBytes, BoolToStr(Applied.NoOp, True)]));
  finally
    Doc.Free;
  end;
end;

El orden de las comprobaciones importa más que la llamada en sí. Después de que el hash base pasa, el método compara los bytes de append ya confirmados, todo lo que hay entre BaseBytes y el final del fichero cargado, con el inicio de la carga decodificada. Una entrega más corta que lo que ya confirmasteis falla con "A cumulative submission cannot remove committed append bytes", y una que discrepa falla con "The cumulative Differences conflict with the committed revision". Solo el sufijo restante es nuevo, y solo ese sufijo acaba en AddedBytes

Pipeline de comprobaciones de SaveFDFDifferencesToFile en HotPDF para Delphi: longitud base y SHA-256, comparación del prefijo acumulativo, validación del candidato temporal, identidad de /ID y firmas, bits de permisos más admisión DocMDP y FieldMDP, y después una publicación atómica de la revisión añadida
Seis puertas de fallo cerrado corren en un orden fijo antes de que se publique un solo byte de la revisión añadida, así que un FDF malformado o un cambio no permitido dejan el documento cargado y la salida existente intactos

El candidato ensamblado se escribe en un fichero temporal exclusivo en el directorio objetivo, se vuelca, se reabre como fuente de revisión independiente, y se valida antes de sustituir a la salida. Los fallos de parsing, cancelación, política, presupuesto o publicación dejan tanto el documento cargado como cualquier fichero de salida existente intactos. Dos precondiciones atrapan a la gente pronto: la salida no debe ser alias de la fuente cargada, y el documento no debe tener ediciones sin guardar, porque un append opaco las descartaría en silencio ("Publish pending document edits before applying Differences")

Por qué un apply repetido es un no-op byte a byte

Un apply repetido del mismo FDF acumulativo es un no-op porque cada byte de su carga ya está en el fichero. Cuando la carga iguala exactamente los bytes de append previos, SaveFDFDifferencesToFile informa NoOp = True y AddedBytes = 0, y la salida que publica es idéntica al PDF actual. Es una garantía más fuerte que «solo-append», y es lo que hace seguros los reintentos tras un timeout de red o un worker caído

// Rev 1 ya confirmada en contract-0042-r1.pdf; el mismo FDF llega dos veces
// (Doc, Again, Fdf, Selection, BaseLength y BaseHash declarados como arriba)
Doc := THPDFHeadlessDocument.Create;
try
  Doc.LoadFromFile('store\contract-0042-r1.pdf');
  Again := Doc.SaveFDFDifferencesToFile('store\contract-0042-r1-retry.pdf',
    Fdf, BaseLength, BaseHash, Selection, THPDFFDFPackageOptions.Default);
  // Again.NoOp = True, Again.AddedBytes = 0,
  // y el fichero de reintento es byte-idéntico a contract-0042-r1.pdf
finally
  Doc.Free;
end;

Escribid vuestras pruebas de aceptación para que casen. Afirmad igualdad de bytes completa entre la salida del reintento y la revisión actual, no meramente que el fichero no encogió. Una prueba que solo comprueba el comportamiento de solo-append pasará contenta una implementación que añade la misma revisión una segunda vez, lo que produce un PDF de aspecto válido con una sección de actualización duplicada que una auditoría DocMDP posterior tiene que explicar

Qué comprobaciones de admisión corren antes de publicar la revisión

Antes de la publicación el PDF candidato tiene que pasar comprobaciones de identidad, firma y permisos, y ninguna se fía de los campos externos del FDF. El primer elemento del /ID del trailer debe sobrevivir sin cambios, y cada campo de firma ya presente en el documento cargado debe seguir existiendo con un diccionario de firma idéntico y la misma identidad de valor de firma. Una carga Differences que suelta, sustituye o reescribe una firma existente falla con hdSignatureModificationUnsupported

Vienen luego las capas de autorización. Para un append no vacío sobre un documento cifrado, los objetos cambiados se clasifican por rol (catálogo y páginas, anotaciones, campos AcroForm, appearance streams) y se comprueban contra los bits de permiso de usuario autenticados, así que una revisión que edita anotaciones necesita el permiso de anotaciones, y así sucesivamente. Después el candidato pasa por el mismo enforcement de certificación y bloqueo de campos que HotPDF Component usa en otros sitios: una firma de certificación DocMDP (ISO 32000-1 §12.8.2.2) limita qué clases de cambio pueden hacer las revisiones posteriores, y los bloqueos FieldMDP (§12.8.2.4) protegen campos nombrados. El análisis de revisiones DocMDP y FieldMDP en HotPDF cubre cómo se clasifican esos permisos por revisión

El manejo de errores debería separar «este FDF está malformado» de «esta revisión no está permitida», porque el segundo caso normalmente necesita una persona:

try
  Applied := Doc.SaveFDFDifferencesToFile(NextPdf, Fdf, BaseLength, BaseHash,
    Selection, THPDFFDFPackageOptions.Default);
except
  on E: EHPDFFDFPackageError do
    QuarantineFdf(FdfFile, 'No usable Differences stream: ' + E.Message);
  on E: EHPDFHeadlessDocumentError do
    case E.Code of
      hdPermissionDenied, hdSignatureModificationUnsupported:
        EscalateForReview(FdfFile, E.Message);
      hdLimitExceeded:
        QuarantineFdf(FdfFile, 'Over budget: ' + E.Message);
    else
      QuarantineFdf(FdfFile, E.Message);  // base no coincidente, conflicto, endpoint erróneo
    end;
end;

Lo que la operación nunca hace es tan importante como lo que comprueba. SaveFDFDifferencesToFile nunca obtiene la especificación de fichero /F del FDF, así que el PDF nombrado dentro del FDF no puede redirigir el merge a otro fichero del disco o de la red, y nunca ejecuta JavaScript ni actions. Cada entrada llega explícita: la base cargada, los bytes del FDF y el material de confianza que configurasteis

Transacciones de Windows y el fdf.differences.apply de la ABI C

El mismo transporte está disponible sin escribir código headless directamente. THPDFWindowsFormDataTransaction.ApplyFDFDifferences toma los bytes del FDF, la longitud base, el SHA-256 base, la selección, las opciones de paquete y una contraseña, delega en SaveFDFDifferencesToFile, y añade la contabilidad de bytes de la transacción, el rollback, la cancelación y el comportamiento de reintento con el mismo handle. Para llamadores que no son Delphi, la ABI C expone la operación JSON fdf.differences.apply, donde base lleva la longitud y el hash:

{ "schemaVersion": 1, "type": "fdf.differences.apply", "format": "fdf",
  "base": { "bytes": 673, "sha256": "..." },
  "signaturePolicy": { "trustedCAPEM": "..." } }

La respuesta JSON informa baseBytes, priorAppendBytes, totalAppendBytes, addedBytes y noop, los mismos campos que THPDFHeadlessFDFDifferencesResult. Las puertas de aceptación publicadas para este transporte corren en Linux FPC 3.3.1, Windows Delphi 32 y 64 bits y Windows FPC 32 y 64 bits; cada objetivo de seguridad cubre 21 casos con 1244 comprobaciones nativas y 382 comandos de callback

Límites y trampas de fixtures que conviene conocer

El transporte de Differences es deliberadamente estrecho, y algunos de sus bordes han mordido suites de pruebas reales:

  • La base debe ser una revisión PDF completa. Una longitud que aterriza a mitad de fichero, o cuyo startxref no apunta a sus datos de cross-reference reales, se rechaza antes de copiar un solo byte
  • Las fixtures de firma necesitan un /Lock indirecto. El lector de permisos del documento cargado de Windows rechaza un campo de firma cuyo /Lock es un diccionario inline con "Signature field /Lock must be an indirect reference", aunque algunas rutas de firma toleran la forma inline, así que escribid el diccionario de bloqueo como su propio objeto
  • Una CA de prueba hecha con un openssl req -x509 pelado carece de CA:TRUE y keyCertSign, así que la validación de cadena falla con un error de verificación de certificado que parece un bug de la biblioteca y no lo es
  • Los valores externos de /Fields no se fusionan con esta llamada. Si también los queréis en el documento, decid explícitamente si son informativos, y nunca los presentéis como firmados
  • El FDF en sí sigue siendo no incremental. Un fichero FDF con múltiples trailers, generaciones distintas de cero o números de objeto duplicados es un FDF malformado, no uno más rico

Para el lado de valores plano de FDF y XFDF, sin revisiones firmadas en la imagen, la ida y vuelta de FDF y XFDF multiselección en HotPDF y la importación y exportación de anotaciones XFDF en Delphi son los puntos de partida mejores

FAQ

¿Puedo pasar el stream Differences decodificado en lugar del fichero FDF?

No. SaveFDFDifferencesToFile parsea el FDF que pasáis como un THPDFFDFPackage, aplica la selección embebida opcional y llama internamente a DecodeDifferences. Usad THPDFFDFPackage.DecodeDifferences por su cuenta cuando queráis inspeccionar o archivar los bytes de revisión en crudo, pero entregad el fichero FDF a la llamada de apply

¿Qué pasa cuando el cliente envía un segundo FDF acumulativo más grande?

Cargad el PDF que ya contiene la primera revisión confirmada y mantened el mismo BaseBytes y BaseSHA256. El método verifica que la nueva carga empieza exactamente con los bytes ya confirmados, añade solo el sufijo nuevo y lo informa en AddedBytes, y después ejecuta la admisión completa de permisos y DocMDP/FieldMDP sobre el resultado

¿Un apply con éxito significa que los valores de /Fields están firmados?

No. ISO 32000-1 no exige que /Fields, /Annots y /Differences en un FDF coincidan, y HotPDF Component no los reconcilia. Lo firmado es lo que cubran las firmas CMS de la revisión añadida, así que leed los valores firmados del PDF fusionado, no del envoltorio FDF

El transporte FDF Differences, el motor de política de firma headless y las transacciones de form-data de Windows vienen todos en HotPDF Component para Delphi, C++Builder y Free Pascal