Artigo Técnico

FDF Differences em Delphi: anexando revisões assinadas

O HotPDF Component aplica um stream /Differences de FDF a partir do Delphi por meio de THPDFHeadlessDocument.SaveFDFDifferencesToFile: o método checa que o PDF carregado ainda casa com uma revisão base que você identifica por comprimento exato de bytes e SHA-256, anexa os bytes cumulativos da revisão exatamente uma vez, submete o resultado a admissão por permissões de criptografia e DocMDP/FieldMDP, e o publica atomicamente. Repetir a mesma submissão é um no-op byte a byte

O fluxo de trabalho para o qual isso existe nada tem de glamouroso e é comum. Você manda um contrato ou um formulário de inspeção para fora como um PDF, alguém o preenche e assina no próprio viewer, e o que volta não é o PDF, mas um arquivo FDF. Os valores de campo viajam em /Fields, mas a revisão assinada em si, os bytes que a assinatura CMS realmente cobre, viaja em /Differences. Errar a mesclagem e você ou quebra a assinatura ou, pior, aceita valores de campo que nenhuma assinatura jamais cobriu

O que um stream /Differences de FDF realmente carrega?

Um stream /Differences de FDF carrega bytes brutos de atualização incremental para o PDF alvo, não um histórico em nível de FDF. A ISO 32000-1 §12.7.7.3, Tabela 243, define a entrada Differences do dicionário FDF como as mudanças incrementais feitas no PDF subjacente desde que foi aberto, na mesma forma de uma atualização incremental PDF sob a §7.5.6: objetos novos e substituídos, uma seção de cross-reference e um trailer cujo /Prev aponta de volta para o arquivo base

O contêiner FDF em si é o oposto. A §12.7.7.1 proíbe atualizações incrementais em arquivos FDF, então um FDF tem um único trailer, apenas objetos de geração 0 e números de objeto únicos. O HotPDF Component impõe exatamente isso em seu parser de FDF e nunca o relaxa para facilitar o /Differences. Os dados incrementais vivem dentro de um único stream de um contêiner estritamente não incremental, e manter essas duas camadas separadas é metade do trabalho

Diagrama de anatomia do FDF /Differences do HotPDF para Delphi: um contêiner FDF estritamente não incremental com um único trailer e objetos de geração 0 carrega um stream /Differences cumulativo de bytes brutos de atualização incremental, enquanto /Fields e /Annots permanecem sem assinatura e sem mesclar
A revisão assinada viaja no stream /Differences, não em /Fields: o contêiner em si proíbe atualizações incrementais, e apenas o payload de bytes que a assinatura CMS cobre pode ser confiável

Duas propriedades do stream moldam toda decisão de projeto a jusante:

  • O stream é cumulativo: ele guarda todo byte anexado desde que o PDF alvo foi aberto, então uma segunda submissão da mesma sessão repete a revisão que você já comprometeu e adiciona a nova atrás dela
  • As entradas externas /Fields e /Annots do mesmo FDF não têm garantia de concordar com o /Differences, então os valores de campo que você lê facilmente não são os dados que a assinatura cobre

Esse segundo ponto merece franqueza. Se seu código de importação lê o /V do /Fields e trata o resultado como assinado porque uma assinatura chegou no mesmo arquivo, você construiu um canal de falsificação. O HotPDF Component mantém os caminhos separados de propósito: o importador de anotações FDF rejeita o /Differences de cara, e apenas o transporte dedicado de Differences o toca

Atando o append a uma revisão base explícita

O SaveFDFDifferencesToFile se recusa a adivinhar a que revisão o FDF pertence. O chamador passa BaseBytes, o comprimento exato da revisão completa do PDF a partir da qual o formulário foi aberto, e BaseSHA256, seu SHA-256 hexadecimal de 64 dígitos. O método recalcula o hash desse prefixo do documento carregado, rejeita um descasamento com "The loaded PDF does not match the explicitly selected base", e checa que o prefixo termina em um endpoint de revisão real cujo startxref aponta para seus dados de cross-reference reais

A regra prática é registrar os dois números quando você manda o formulário para fora, não quando o FDF volta. Um helper pequeno sobre as rotinas SHA256Init / SHA256Update / SHA256Final em 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;

// No envio: o arquivo inteiro de saída é a revisão base
// BaseLength := tamanho de 'out\contract-0042.pdf' em bytes
// BaseHash   := PrefixSHA256Hex('out\contract-0042.pdf', BaseLength);

O documento carregado não precisa ser exatamente a base. Pode ser a base mais as revisões que você já comprometeu de submissões anteriores, que é como o stream cumulativo permanece administrável: BaseBytes continua nomeando o prefixo original, e tudo depois dele é tratado como bytes de append anteriores

Layout de bytes do FDF Differences do HotPDF para Delphi: o PDF carregado se divide na revisão base verificada pelo comprimento BaseBytes e BaseSHA256 mais bytes de append anteriores já comprometidos, e um payload /Differences cumulativo que precisa repetir esses bytes antes de adicionar um novo sufixo
O prefixo base é fixado por comprimento e SHA-256, os bytes comprometidos agem como um contrato de append-only, e apenas o sufixo final é novo - uma submissão repetida então cai como um no-op byte a byte

Aplicando as Differences com SaveFDFDifferencesToFile

O THPDFHeadlessDocument.SaveFDFDifferencesToFile recebe o nome do arquivo de saída, os bytes do FDF, o comprimento e o hash base, uma seleção opcional de FDF embutido e um registro de limite THPDFFDFPackageOptions, e devolve um THPDFHeadlessFDFDifferencesResult. Por dentro, ele abre o FDF como um THPDFFDFPackage e chama o DecodeDifferences ele mesmo, então você passa o arquivo FDF, não um stream pré-decodificado. Um Selection vazio significa o FDF de nível superior; um não vazio entra primeiro em um FDF filho embutido

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 nível superior, sem filho embutido
    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;

A ordem das checagens importa mais do que a chamada em si. Depois que o hash base passa, o método compara os bytes de append já comprometidos, tudo entre BaseBytes e o fim do arquivo carregado, com o começo do payload decodificado. Uma submissão mais curta do que o que você já comprometeu falha com "A cumulative submission cannot remove committed append bytes", e uma que discorda dele falha com "The cumulative Differences conflict with the committed revision". Apenas o sufixo restante é novo, e só esse sufixo acaba em AddedBytes

Pipeline de checagens do SaveFDFDifferencesToFile do HotPDF em Delphi: comprimento base e SHA-256, comparação do prefixo cumulativo, validação do candidato temporário, identidade de /ID e assinatura, bits de permissão mais admissão DocMDP e FieldMDP, então uma publicação atômica da revisão anexada
Seis portões fail-closed rodam em ordem fixa antes de qualquer byte da revisão anexada ser publicado, então um FDF malformado ou uma mudança não permitida deixa o documento carregado e a saída existente intocados

O candidato montado é gravado em um arquivo temporário exclusivo no diretório alvo, descarregado, reaberto como uma fonte de revisão independente, e validado antes de substituir a saída. Falhas de parsing, cancelamento, política, orçamento ou publicação deixam tanto o documento carregado quanto qualquer arquivo de saída existente intocados. Duas pré-condições pegam as pessoas cedo: a saída não pode ser o mesmo arquivo da origem carregada, e o documento não pode ter edições não salvas, porque um append opaco as descartaria silenciosamente ("Publish pending document edits before applying Differences")

Por que uma aplicação repetida é um no-op byte a byte?

Uma aplicação repetida do mesmo FDF cumulativo é um no-op porque cada byte de seu payload já está no arquivo. Quando o payload é igual aos bytes de append anteriores exatamente, o SaveFDFDifferencesToFile reporta NoOp = True e AddedBytes = 0, e a saída que publica é idêntica ao PDF atual. Essa é uma garantia mais forte que "append-only", e é o que torna seguras as novas tentativas depois de um timeout de rede ou um worker que caiu

// Rev 1 já comprometida em contract-0042-r1.pdf; o mesmo FDF chega duas vezes
// (Doc, Again, Fdf, Selection, BaseLength e BaseHash declarados como acima)
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,
  // e o arquivo de retry é byte-idêntico a contract-0042-r1.pdf
finally
  Doc.Free;
end;

Escreva seus testes de aceitação para casar com isso. Afirme igualdade completa de bytes entre a saída do retry e a revisão atual, não meramente que o arquivo não encolheu. Um teste que só confere o comportamento append-only vai passar feliz por uma implementação que anexa a mesma revisão uma segunda vez, o que produz um PDF de aparência válida com uma seção de atualização duplicada que uma auditoria DocMDP posterior terá de explicar

Que checagens de admissão rodam antes de a revisão ser publicada?

Antes da publicação, o PDF candidato precisa passar por checagens de identidade, assinatura e permissão, e nenhuma delas confia nos campos externos do FDF. O primeiro elemento do /ID do trailer precisa sobreviver inalterado, e todo campo de assinatura já presente no documento carregado precisa continuar existindo com um dicionário de assinatura idêntico e a mesma identidade de valor de assinatura. Um payload de Differences que descarta, substitui ou regrava uma assinatura existente falha com hdSignatureModificationUnsupported

Depois vêm as camadas de autorização. Para um append não vazio em um documento criptografado, os objetos alterados são classificados por papel (catálogo e páginas, anotações, campos AcroForm, appearance streams) e conferidos com os bits de permissão de usuário autenticados, então uma revisão que edita anotações precisa da permissão de anotação, e assim por diante. Então o candidato passa pela mesma imposição de certificação e trava de campo que o HotPDF Component usa em outros lugares: uma assinatura de certificação DocMDP (ISO 32000-1 §12.8.2.2) limita que tipos de mudança revisões posteriores podem fazer, e travas FieldMDP (§12.8.2.4) protegem campos nomeados. A análise de revisões DocMDP e FieldMDP no HotPDF cobre como essas permissões são classificadas por revisão

O tratamento de erro deveria separar "este FDF está malformado" de "esta revisão não é permitida", porque o segundo caso geralmente precisa de um 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 destoante, conflito, endpoint inválido
    end;
end;

O que a operação nunca faz é tão importante quanto o que ela checa. O SaveFDFDifferencesToFile nunca busca a especificação de arquivo /F do FDF, então o PDF nomeado dentro do FDF não pode redirecionar a mesclagem para outro arquivo no disco ou na rede, e ele nunca executa JavaScript ou ações. Toda entrada chega explicitamente: a base carregada, os bytes do FDF e o material de confiança que você configurou

Transações Windows e a C ABI fdf.differences.apply

O mesmo transporte está disponível sem escrever código headless diretamente. O THPDFWindowsFormDataTransaction.ApplyFDFDifferences recebe os bytes do FDF, comprimento base, SHA-256 base, seleção, opções de pacote e uma senha, delega ao SaveFDFDifferencesToFile, e adiciona a contabilidade de bytes da transação, rollback, cancelamento e comportamento de retry pelo mesmo handle. Para chamadores fora do Delphi, a C ABI expõe a operação JSON fdf.differences.apply, em que base carrega o comprimento e o hash:

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

A resposta JSON reporta baseBytes, priorAppendBytes, totalAppendBytes, addedBytes e noop, os mesmos campos do THPDFHeadlessFDFDifferencesResult. Os portões de aceitação publicados para esse transporte rodam em Linux FPC 3.3.1, Windows Delphi 32 bits e 64 bits e Windows FPC 32 bits e 64 bits; cada alvo de segurança cobre 21 casos com 1.244 checagens nativas e 382 comandos de callback

Limites e armadilhas de fixture que valem conhecer

O transporte de Differences é deliberadamente estreito, e algumas de suas bordas já morderam suítes de teste reais:

  • A base precisa ser uma revisão PDF completa. Um comprimento que cai no meio do arquivo, ou cujo startxref não aponta para seus dados de cross-reference reais, é rejeitado antes de qualquer byte ser copiado
  • Fixtures de assinatura precisam de um /Lock indireto. O leitor de permissões de documento carregado do Windows rejeita um campo de assinatura cujo /Lock é um dicionário inline com "Signature field /Lock must be an indirect reference", mesmo que alguns caminhos de assinatura tolerem a forma inline, então escreva o dicionário de trava como seu próprio objeto
  • Uma CA de teste criada com um openssl req -x509 bare carece de CA:TRUE e keyCertSign, então a validação da cadeia falha com um erro de verificação de certificado que parece um bug da biblioteca e não é
  • Os valores externos do /Fields não são mesclados por esta chamada. Se você também os quer no documento, decida explicitamente se são informativos, e nunca os apresente como assinados
  • O FDF em si permanece não incremental. Um arquivo FDF com múltiplos trailers, gerações diferentes de zero ou números de objeto duplicados é um FDF malformado, não um mais rico

Para o lado de valores simples de FDF e XFDF, sem revisões assinadas na história, a ida e volta de FDF e XFDF multi-select no HotPDF e o import e export de anotações XFDF em Delphi são os pontos de partida melhores

FAQ

Posso passar o stream Differences decodificado em vez do arquivo FDF?

Não. O SaveFDFDifferencesToFile faz o parse do FDF que você passa como um THPDFFDFPackage, aplica a seleção embutida opcional e chama o DecodeDifferences internamente. Use o THPDFFDFPackage.DecodeDifferences por conta própria quando quiser inspecionar ou arquivar os bytes brutos da revisão, mas entregue o arquivo FDF à chamada de aplicação

O que acontece quando o cliente envia um segundo FDF cumulativo maior?

Carregue o PDF que já contém a primeira revisão comprometida e mantenha o mesmo BaseBytes e BaseSHA256. O método verifica que o novo payload começa exatamente com os bytes já comprometidos, anexa apenas o novo sufixo e o reporta em AddedBytes, então roda a admissão completa de permissão e DocMDP/FieldMDP sobre o resultado

Uma aplicação bem-sucedida significa que os valores de campo em /Fields estão assinados?

Não. A ISO 32000-1 não exige que /Fields, /Annots e /Differences em um mesmo FDF concordem, e o HotPDF Component não os reconcilia. O que está assinado é o que as assinaturas CMS da revisão anexada cobrem, então leia valores assinados do PDF mesclado, não do wrapper FDF

O transporte de FDF Differences, o engine headless de política de assinatura e as transações Windows de dados de formulário vêm todos no HotPDF Component para Delphi, C++Builder e Free Pascal