Artículo técnico

Anexar revisiones firmadas con FDF /Differences en Delphi

HotPDF Component aplica un flujo /Differences de FDF desde Delphi a través de THPDFHeadlessDocument.SaveFDFDifferencesToFile: el método verifica que el PDF cargado todavía coincida con una revisión base que usted identifica por largo exacto en bytes y SHA-256, anexa los bytes de la revisión acumulativa 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 sumisión es un no-op byte a byte

El flujo de trabajo para el que esto existe no tiene glamor y es común. Usted envía un contrato o un formulario de inspección como PDF, alguien lo llena y lo firma en su propio visor, y lo que vuelve no es el PDF sino un archivo FDF. Los valores de campos viajan en /Fields, pero la revisión firmada misma, los bytes que la firma CMS realmente cubre, viaja en /Differences. Equivoque la fusión y o rompe la firma o, peor, acepta valores de campos que ninguna firma cubrió jamás

¿Qué lleva realmente un flujo /Differences de FDF?

Un flujo /Differences de FDF lleva bytes crudos de actualización incremental para el PDF de destino, no una historia a nivel FDF. ISO 32000-1 §12.7.7.3, Tabla 243 define la entrada Differences del diccionario FDF como los cambios incrementales al PDF subyacente hechos desde que se abrió, en la misma forma que una actualización incremental de PDF bajo §7.5.6: objetos nuevos y reemplazados, una sección de referencias cruzadas y un trailer cuyo /Prev apunta de vuelta al archivo base

El contenedor FDF mismo es lo contrario. §12.7.7.1 prohíbe actualizaciones incrementales a archivos FDF, así que un FDF tiene un solo trailer, solo objetos de generación 0 y números de objeto únicos. HotPDF Component aplica exactamente eso en su parser de FDF y nunca lo relaja para facilitar las cosas a /Differences. Los datos incrementales viven dentro de un solo flujo de un contenedor estrictamente no incremental, y mantener esas dos capas separadas es la mitad del trabajo

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

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

  • El flujo es acumulativo: contiene cada byte anexado desde que el PDF de destino se abrió, así que una segunda sumisión de la misma sesión repite la revisión que usted ya comprometió y agrega la nueva detrás
  • Las entradas externas /Fields y /Annots del mismo FDF no tienen garantía de coincidir con /Differences, así que los valores de campos que usted puede leer fácilmente no son los datos que la firma cubre

Ese segundo punto merece franqueza. Si su código de importación lee /V de /Fields y trata el resultado como firmado porque una firma llegó en el mismo archivo, usted ha 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

Atar el anexo a una revisión base explícita

SaveFDFDifferencesToFile se rehúsa a adivinar a qué revisión pertenece el FDF. El llamador pasa BaseBytes, el largo exacto 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 un desfase con "The loaded PDF does not match the explicitly selected base", y verifica que el prefijo termine en un endpoint de revisión real cuyo startxref apunte a sus datos reales de referencias cruzadas

La regla práctica es registrar ambos números cuando envía el formulario, no cuando el FDF regresa. Un helper pequeño sobre las rutinas SHA256Init / SHA256Update / SHA256Final en 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;

// Al enviar: el archivo saliente completo 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 revisiones que usted ya comprometió de sumisiones anteriores, que es como el flujo acumulativo se mantiene manejable: BaseBytes sigue nombrando el prefijo original, y todo lo que va después se trata como bytes de anexo previos

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

Aplicar los Differences con SaveFDFDifferencesToFile

THPDFHeadlessDocument.SaveFDFDifferencesToFile toma el nombre del archivo de salida, los bytes del FDF, el largo y hash de la base, una selección opcional de FDF incrustado y un registro de límites THPDFFDFPackageOptions, y devuelve un THPDFHeadlessFDFDifferencesResult. Por dentro, abre el FDF como un THPDFFDFPackage y llama a DecodeDifferences por su cuenta, así que usted pasa el archivo FDF, no un flujo predecodificado. Un Selection vacío significa el FDF de nivel superior; uno no vacío entra primero a un FDF hijo incrustado

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 incrustado
    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 verificaciones importa más que la llamada misma. Después de que el hash base pasa, el método compara los bytes de anexo ya comprometidos, todo lo que hay entre BaseBytes y el final del archivo cargado, contra el inicio de la carga decodificada. Una sumisión más corta que lo que usted ya comprometió falla con "A cumulative submission cannot remove committed append bytes", y una que no coincide 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 verificaciones de SaveFDFDifferencesToFile de HotPDF en Delphi: largo 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 luego una publicación atómica de la revisión anexada
Seis compuertas fail-closed corren en un orden fijo antes de que se publique cualquier byte de la revisión anexada, así que un FDF malformado o un cambio no permitido deja el documento cargado y la salida existente intactos

El candidato ensamblado se escribe en un archivo temporal exclusivo en el directorio de destino, se vacía a disco, se reabre como fuente de revisión independiente, y se valida antes de reemplazar la salida. Las fallas de parseo, cancelación, política, presupuesto o publicación dejan tanto el documento cargado como cualquier archivo de salida existente intactos. Dos precondiciones atrapan a la gente temprano: la salida no debe ser alias de la fuente cargada, y el documento no debe tener ediciones sin guardar, porque un anexo opaco las descartaría en silencio ("Publish pending document edits before applying Differences")

¿Por qué una aplicación repetida es un no-op byte a byte?

Una aplicación repetida del mismo FDF acumulativo es un no-op porque cada byte de su carga ya está en el archivo. Cuando la carga es igual a los bytes de anexo previos exactamente, SaveFDFDifferencesToFile reporta NoOp = True y AddedBytes = 0, y la salida que publica es idéntica al PDF actual. Esa es una garantía más fuerte que "solo anexo", y es lo que hace seguros los reintentos tras un timeout de red o un worker caído

// La rev 1 ya está comprometida 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 archivo de reintento es byte-idéntico a contract-0042-r1.pdf
finally
  Doc.Free;
end;

Escriba sus pruebas de aceptación para que coincidan. Afirme igualdad completa de bytes entre la salida del reintento y la revisión actual, no meramente que el archivo no se encogió. Una prueba que solo verifica comportamiento de solo anexo pasará felizmente una implementación que anexa 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é verificaciones de admisión corren antes de publicar la revisión?

Antes de la publicación el PDF candidato tiene que pasar verificaciones de identidad, firmas y permisos, y ninguna de ellas confía en 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 elimina, reemplaza o reescribe una firma existente falla con hdSignatureModificationUnsupported

Luego vienen las capas de autorización. Para un anexo no vacío sobre un documento cifrado, los objetos cambiados se clasifican por rol (catálogo y páginas, anotaciones, campos AcroForm, flujos de apariencia) y se verifican contra los bits de permisos del usuario autenticado, así que una revisión que edita anotaciones necesita el permiso de anotaciones, y así sucesivamente. Después el candidato pasa por la misma aplicación de certificación y bloqueo de campos que HotPDF Component usa en otros lados: una firma de certificación DocMDP (ISO 32000-1 §12.8.2.2) limita qué tipos 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 un humano:

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 que no coincide, conflicto, endpoint malo
    end;
end;

Lo que la operación nunca hace es tan importante como lo que verifica. SaveFDFDifferencesToFile nunca busca la especificación de archivo /F del FDF, así que el PDF nombrado dentro del FDF no puede redirigir la fusión a otro archivo en disco o en la red, y nunca ejecuta JavaScript ni acciones. Cada entrada llega explícitamente: la base cargada, los bytes del FDF y el material de confianza que usted configuró

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

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

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

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

Límites y trampas de fixtures que vale conocer

El transporte de Differences es deliberadamente angosto, y algunas de sus aristas han mordido suites de pruebas reales:

  • La base debe ser una revisión PDF completa. Un largo que cae a mitad de archivo, o cuyo startxref no apunta a sus datos reales de referencias cruzadas, se rechaza antes de copiar cualquier byte
  • Los fixtures de firmas necesitan un /Lock indirecto. El lector de permisos de documentos cargados 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 escriba 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 quiere en el documento, decida explícitamente si son informativos, y nunca preséntelos como firmados
  • El FDF mismo se mantiene no incremental. Un archivo 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 simples de FDF y XFDF, sin revisiones firmadas de por medio, el viaje de ida y vuelta de FDF y XFDF multi-selección en HotPDF y la importación y exportación de anotaciones XFDF en Delphi son mejores puntos de partida

FAQ

¿Puedo pasar el flujo Differences decodificado en lugar del archivo FDF?

No. SaveFDFDifferencesToFile parsea el FDF que usted pasa como un THPDFFDFPackage, aplica la selección opcional de incrustado y llama a DecodeDifferences internamente. Use THPDFFDFPackage.DecodeDifferences por su cuenta cuando quiera inspeccionar o archivar los bytes crudos de la revisión, pero entregue el archivo FDF a la llamada de aplicación

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

Cargue el PDF que ya contiene la primera revisión comprometida y conserve los mismos BaseBytes y BaseSHA256. El método verifica que la nueva carga empiece exactamente con los bytes ya comprometidos, anexa solo el sufijo nuevo y lo reporta en AddedBytes, luego corre la admisión completa de permisos y DocMDP/FieldMDP sobre el resultado

¿Una aplicación exitosa significa que los valores de campos en /Fields están firmados?

No. ISO 32000-1 no exige que /Fields, /Annots y /Differences en un mismo FDF coincidan, y HotPDF Component no los concilia. Lo firmado es lo que las firmas CMS de la revisión anexada cubren, así que lea los valores firmados del PDF fusionado, no del envoltorio FDF

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