Articolo tecnico

FDF Differences in Delphi: accodare revisioni firmate

HotPDF Component applica uno stream FDF /Differences da Delphi attraverso THPDFHeadlessDocument.SaveFDFDifferencesToFile: il metodo controlla che il PDF caricato combaci ancora con una revisione base che identificate per lunghezza in byte esatta e SHA-256, accoda i byte della revisione cumulativa esattamente una volta, passa il risultato attraverso permessi di cifratura e ammissione DocMDP/FieldMDP, e lo pubblica atomicamente. Ripetere la stessa submission è un no-op byte per byte

Il flusso di lavoro per cui esiste è poco appariscente e comune. Mandate fuori un contratto o un modulo di ispezione come PDF, qualcuno lo compila e lo firma nel proprio viewer, e ciò che torna non è il PDF ma un file FDF. I valori dei campi viaggiano in /Fields, ma la revisione firmata stessa, i byte che la firma CMS copre davvero, viaggia in /Differences. Sbagliate il merge e o rompete la firma o, peggio, accettate valori di campo che nessuna firma ha mai coperto

Cosa trasporta davvero uno stream FDF /Differences?

Uno stream FDF /Differences trasporta byte grezzi di aggiornamento incrementale per il PDF target, non una storia a livello FDF. ISO 32000-1 §12.7.7.3, Tabella 243 definisce la voce Differences del dizionario FDF come le modifiche incrementali al PDF sottostante fatte da quando è stato aperto, nella stessa forma di un aggiornamento incrementale PDF ai sensi del §7.5.6: oggetti nuovi e sostituiti, una sezione di riferimenti incrociati e un trailer il cui /Prev punta indietro nel file base

Il contenitore FDF stesso è l'opposto. Il §12.7.7.1 vieta gli aggiornamenti incrementali ai file FDF, quindi un FDF ha un solo trailer, solo oggetti di generazione 0 e numeri di oggetto unici. HotPDF Component impone esattamente questo nel proprio parser FDF e non lo rilassa mai per facilitare /Differences. I dati incrementali vivono dentro un solo stream di un contenitore rigorosamente non incrementale, e tenere separati quei due livelli è metà del lavoro

Diagramma di anatomia degli FDF /Differences di HotPDF per Delphi: un contenitore FDF rigorosamente non incrementale con un solo trailer e oggetti di generazione 0 trasporta uno stream cumulativo /Differences di byte grezzi di aggiornamento incrementale, mentre /Fields e /Annots restano non firmati e non uniti
La revisione firmata viaggia nello stream /Differences, non in /Fields: il contenitore stesso vieta gli aggiornamenti incrementali, e solo il payload di byte che la firma CMS copre può essere fidato

Due proprietà dello stream plasmano ogni decisione di design a valle:

  • Lo stream è cumulativo: contiene ogni byte accodato da quando il PDF target è stato aperto, quindi una seconda submission dalla stessa sessione ripete la revisione che avete già committato e aggiunge la nuova dietro di essa
  • Le voci esterne /Fields e /Annots dello stesso FDF non hanno garanzia di combaciare con /Differences, quindi i valori di campo che potete leggere facilmente non sono i dati che la firma copre

Quel secondo punto merita franchezza. Se il vostro codice di import legge /V da /Fields e tratta il risultato come firmato perché una firma è arrivata nello stesso file, avete costruito un canale di falsificazione. HotPDF Component tiene i percorsi separati di proposito: l'importatore di annotazioni FDF rifiuta /Differences in toto, e solo il trasporto Differences dedicato lo tocca

Legare l'append a una revisione base esplicita

SaveFDFDifferencesToFile si rifiuta di indovinare a quale revisione appartiene l'FDF. Il chiamante passa BaseBytes, la lunghezza esatta della revisione PDF completa da cui il modulo è stato aperto, e BaseSHA256, il suo SHA-256 esadecimale di 64 cifre. Il metodo ricalcola l'hash di quel prefisso del documento caricato, rifiuta una discrepanza con "The loaded PDF does not match the explicitly selected base", e controlla che il prefisso termini su un vero endpoint di revisione il cui startxref punta ai suoi dati reali di riferimenti incrociati

La regola pratica è registrare entrambi i numeri quando mandate fuori il modulo, non quando l'FDF torna. Un piccolo helper sulle routine SHA256Init / SHA256Update / SHA256Final in 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 momento dell'invio: l'intero file in uscita è la revisione base
// BaseLength := dimensione di 'out\contract-0042.pdf' in byte
// BaseHash   := PrefixSHA256Hex('out\contract-0042.pdf', BaseLength);

Il documento caricato non deve essere esattamente la base. Può essere la base più le revisioni che avete già committato da submission precedenti, ed è così che lo stream cumulativo resta gestibile: BaseBytes continua a nominare il prefisso originale, e tutto ciò che viene dopo è trattato come byte di append precedenti

Layout di byte degli FDF Differences di HotPDF per Delphi: il PDF caricato si divide nella revisione base verificata per lunghezza BaseBytes e BaseSHA256 più i byte di append precedenti già committati, e un payload cumulativo /Differences che deve ripetere quei byte prima di aggiungere un nuovo suffisso
Il prefisso base è bloccato per lunghezza e SHA-256, i byte committati valgono come contratto append-only, e solo il suffisso in coda è nuovo - una submission ripetuta allora atterra come no-op byte per byte

Applicare le Differences con SaveFDFDifferencesToFile

THPDFHeadlessDocument.SaveFDFDifferencesToFile prende il nome del file di output, i byte FDF, la lunghezza e l'hash della base, una selezione opzionale di FDF incorporato e un record di limiti THPDFFDFPackageOptions, e restituisce un THPDFHeadlessFDFDifferencesResult. Dentro, apre l'FDF come THPDFFDFPackage e chiama da sé DecodeDifferences, quindi voi passate il file FDF, non uno stream pre-decodificato. Un Selection vuoto significa l'FDF di primo livello; uno non vuoto entra prima in un FDF figlio incorporato

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 di primo livello, nessun figlio incorporato
    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;

L'ordine dei controlli conta più della chiamata stessa. Dopo che l'hash della base passa, il metodo confronta i byte di append già committati, tutto ciò che sta tra BaseBytes e la fine del file caricato, con l'inizio del payload decodificato. Una submission più corta di ciò che avete già committato fallisce con "A cumulative submission cannot remove committed append bytes", e una in disaccordo con essa fallisce con "The cumulative Differences conflict with the committed revision". Solo il suffisso rimanente è nuovo, e solo quel suffisso finisce in AddedBytes

Pipeline di controlli di SaveFDFDifferencesToFile di HotPDF in Delphi: lunghezza e SHA-256 della base, confronto del prefisso cumulativo, validazione del candidato temporaneo, identità di /ID e firma, bit di permesso più ammissione DocMDP e FieldMDP, poi una pubblicazione atomica della revisione accodata
Sei gate fail-closed girano in un ordine fisso prima che un solo byte della revisione accodata venga pubblicato, così un FDF malformato o un cambiamento non consentito lascia intatti il documento caricato e l'output esistente

Il candidato assemblato viene scritto su un file temporaneo in esclusiva nella directory di destinazione, svuotato su disco, riaperto come sorgente di revisione indipendente, e validato prima di sostituire l'output. Fallimenti di parsing, cancellazione, policy, budget o pubblicazione lasciano intatti sia il documento caricato sia qualunque file di output esistente. Due precondizioni prendono la gente in anticipo: l'output non deve essere un alias della sorgente caricata, e il documento non deve avere modifiche non salvate, perché un append opaco le scarterebbe silenziosamente ("Publish pending document edits before applying Differences")

Perché un apply ripetuto è un no-op byte per byte?

Un apply ripetuto dello stesso FDF cumulativo è un no-op perché ogni byte del suo payload è già nel file. Quando il payload è identico ai byte di append precedenti, SaveFDFDifferencesToFile riporta NoOp = True e AddedBytes = 0, e l'output che pubblica è identico al PDF corrente. È una garanzia più forte di "append-only", ed è ciò che rende sicuri i retry dopo un timeout di rete o un worker andato in crash

// La Rev 1 è già committata in contract-0042-r1.pdf; lo stesso FDF arriva due volte
// (Doc, Again, Fdf, Selection, BaseLength e BaseHash dichiarati come sopra)
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 il file di retry è byte-identico a contract-0042-r1.pdf
finally
  Doc.Free;
end;

Scrivete i test di accettazione di conseguenza. Asserite piena uguaglianza di byte tra l'output del retry e la revisione corrente, non solo che il file non si sia ridotto. Un test che controlla solo il comportamento append-only passerà felicemente un'implementazione che accoda la stessa revisione una seconda volta, producendo un PDF dall'aspetto valido con una sezione di aggiornamento duplicata che un audit DocMDP successivo deve spiegare

Quali controlli di ammissione girano prima che la revisione sia pubblicata?

Prima della pubblicazione il PDF candidato deve passare controlli di identità, firma e permessi, e nessuno di loro si fida dei campi FDF esterni. Il primo elemento del trailer /ID deve sopravvivere invariato, e ogni campo firma già presente nel documento caricato deve ancora esistere con un dizionario di firma identico e la stessa identità del valore di firma. Un payload Differences che elimina, sostituisce o riscrive una firma esistente fallisce con hdSignatureModificationUnsupported

Poi vengono i livelli di autorizzazione. Per un append non vuoto su un documento cifrato, gli oggetti modificati vengono classificati per ruolo (catalogo e pagine, annotazioni, campi AcroForm, appearance stream) e controllati contro i bit di permesso dell'utente autenticato, così una revisione che modifica annotazioni richiede il permesso sulle annotazioni, e così via. Quindi il candidato passa attraverso la stessa applicazione di certificazione e blocco di campo che HotPDF Component usa altrove: una firma di certificazione DocMDP (ISO 32000-1 §12.8.2.2) limita quali tipi di cambiamento le revisioni successive possono fare, e i blocchi FieldMDP (§12.8.2.4) proteggono i campi nominati. L'analisi delle revisioni DocMDP e FieldMDP in HotPDF copre come quei permessi vengono classificati per revisione

La gestione degli errori dovrebbe separare "questo FDF è malformato" da "questa revisione non è consentita", perché il secondo caso di solito richiede un essere umano:

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 non combaciante, conflitto, endpoint sbagliato
    end;
end;

Ciò che l'operazione non fa mai è importante quanto ciò che controlla. SaveFDFDifferencesToFile non recupera mai la file specification /F dell'FDF, quindi il PDF nominato dentro l'FDF non può dirottare il merge verso un altro file su disco o sulla rete, e non esegue mai JavaScript o azioni. Ogni input arriva esplicitamente: la base caricata, i byte FDF e il materiale di trust che avete configurato

Transazioni Windows e il C ABI fdf.differences.apply

Lo stesso trasporto è disponibile senza scrivere direttamente codice headless. THPDFWindowsFormDataTransaction.ApplyFDFDifferences prende i byte FDF, la lunghezza della base, lo SHA-256 della base, la selezione, le opzioni del pacchetto e una password, delega a SaveFDFDifferencesToFile, e aggiunge la contabilità dei byte della transazione, rollback, cancellazione e comportamento di retry sullo stesso handle. Per i chiamanti non Delphi il C ABI espone l'operazione JSON fdf.differences.apply, dove base porta lunghezza e hash:

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

La risposta JSON riporta baseBytes, priorAppendBytes, totalAppendBytes, addedBytes e noop, gli stessi campi di THPDFHeadlessFDFDifferencesResult. I gate di accettazione pubblicati per questo trasporto girano su Linux FPC 3.3.1, Windows Delphi 32-bit e 64-bit e Windows FPC 32-bit e 64-bit; ogni security target copre 21 casi con 1.244 controlli nativi e 382 comandi di callback

Limiti e trappole dei fixture da conoscere

Il trasporto Differences è volutamente ristretto, e qualcuno dei suoi bordi ha morso suite di test reali:

  • La base deve essere una revisione PDF completa. Una lunghezza che atterra a metà file, o il cui startxref non punta ai suoi dati reali di riferimenti incrociati, viene rifiutata prima che un solo byte venga copiato
  • I fixture di firma richiedono un /Lock indiretto. Il lettore di permessi dei documenti caricati su Windows rifiuta un campo firma il cui /Lock è un dizionario inline con "Signature field /Lock must be an indirect reference", anche se alcuni percorsi di firma tollerano la forma inline, quindi scrivete il dizionario di blocco come oggetto a sé
  • Una CA di test fatta con un openssl req -x509 nudo è priva di CA:TRUE e keyCertSign, quindi la validazione della catena fallisce con un certificate verify error che sembra un bug della libreria e non lo è
  • I valori esterni di /Fields non vengono uniti da questa chiamata. Se li volete anche nel documento, decidete esplicitamente se sono informativi, e non presentateli mai come firmati
  • L'FDF stesso resta non incrementale. Un file FDF con più trailer, generazioni diverse da zero o numeri di oggetto duplicati è un FDF malformato, non uno più ricco

Per il lato dei semplici valori di FDF e XFDF, senza revisioni firmate in ballo, il round trip multi-select FDF e XFDF in HotPDF e import ed export di annotazioni XFDF in Delphi sono i punti di partenza migliori

FAQ

Si può passare lo stream Differences decodificato invece del file FDF?

No. SaveFDFDifferencesToFile esegue il parsing dell'FDF che passate come THPDFFDFPackage, applica la selezione opzionale incorporata e chiama DecodeDifferences internamente. Usate THPDFFDFPackage.DecodeDifferences da sola quando volete ispezionare o archiviare i byte grezzi della revisione, ma consegnate il file FDF alla chiamata di apply

Cosa accade quando il client invia un secondo FDF cumulativo più grande?

Caricate il PDF che contiene già la prima revisione committata e mantenete gli stessi BaseBytes e BaseSHA256. Il metodo verifica che il nuovo payload cominci esattamente con i byte già committati, accoda solo il nuovo suffisso e lo riporta in AddedBytes, poi esegue la piena ammissione di permessi e DocMDP/FieldMDP sul risultato

Un apply riuscito significa che i valori dei campi in /Fields sono firmati?

No. ISO 32000-1 non richiede che /Fields, /Annots e /Differences in un solo FDF combacino, e HotPDF Component non li riconcilia. Ciò che è firmato è tutto ciò che le firme CMS nella revisione accodata coprono, quindi leggete i valori firmati dal PDF unito, non dal wrapper FDF

Il trasporto FDF Differences, il motore di policy di firma headless e le transazioni Windows dei dati dei moduli sono tutti inclusi in HotPDF Component per Delphi, C++Builder e Free Pascal