Technisch artikel

FDF Differences in Delphi: ondertekende revisies toevoegen

HotPDF Component past een FDF-/Differences-stream vanuit Delphi toe via THPDFHeadlessDocument.SaveFDFDifferencesToFile: de methode controleert dat de geladen PDF nog steeds matcht met een basisrevisie die u identificeert op exacte bytelengte en SHA-256, voegt de cumulatieve revisiebytes precies één keer toe, laat het resultaat door versleutelingspermissie en DocMDP/FieldMDP-toetsing gaan en publiceert het atomair. Dezelfde inzending herhalen is een no-op byte voor byte

De workflow waarvoor dit bestaat is onopgesmukt en veelvoorkomend. U stuurt een contract of een inspectieformulier de deur uit als PDF, iemand vult het in en tekent het in zijn eigen viewer, en wat terugkomt is niet de PDF maar een FDF-bestand. Veldwaarden reizen in /Fields, maar de ondertekende revisie zelf, de bytes die de CMS-handtekening werkelijk dekt, reist in /Differences. Zit de merge er naast, dan breekt u de handtekening of, erger, accepteert u veldwaarden die geen enkele handtekening ooit heeft gedekt

Wat draagt een FDF /Differences-stream werkelijk?

Een FDF-/Differences-stream draagt rauwe incremental-update-bytes voor de doelpdf, geen geschiedenis op FDF-niveau. ISO 32000-1 §12.7.7.3, Table 243 definieert de Differences-entry van de FDF-dictionary als de incrementele veranderingen aan de onderliggende PDF sinds die werd geopend, in dezelfde vorm als een PDF-incrementele update onder §7.5.6: nieuwe en vervangen objecten, een cross-reference sectie en een trailer waarvan /Prev terugwijst naar het basisbestand

De FDF-container zelf is het tegenovergestelde. §12.7.7.1 verbiedt incrementele updates aan FDF-bestanden, dus een FDF heeft één trailer, alleen generation 0-objecten en unieke objectnummers. HotPDF Component handhaaft precies dat in zijn FDF-parser en maakt het nooit soepeler om /Differences makkelijker te maken. De incrementele data woont binnen één stream van een strikt niet-incrementele container, en die twee lagen uit elkaar houden is de halve klus

Anatomiediagram van HotPDF FDF /Differences voor Delphi: een strikt niet-incrementele FDF-container met één trailer en generation 0-objecten draagt één cumulatieve /Differences-stream van rauwe incremental-update-bytes, terwijl /Fields en /Annots onondertekend en ongemerged blijven
De ondertekende revisie reist in de /Differences-stream, niet in /Fields: de container zelf verbiedt incrementele updates, en alleen de byte-payload die de CMS-handtekening dekt kan worden vertrouwd

Twee eigenschappen van de stream vormen elke ontwerpbeslissing stroomafwaarts:

  • De stream is cumulatief: hij bevat elke byte die is toegevoegd sinds de doelpdf werd geopend, dus een tweede inzending uit dezelfde sessie herhaalt de revisie die u al hebt vastgelegd en voegt de nieuwe erachter toe
  • De buitenste /Fields- en /Annots-entries van dezelfde FDF stemmen niet gegarandeerd overeen met /Differences, dus de veldwaarden die u makkelijk kunt lezen zijn niet de data die de handtekening dekt

Dat tweede punt verdient een botte formulering. Leest uw importcode /V uit /Fields en behandelt het resultaat als ondertekend omdat er een handtekening in hetzelfde bestand zat, dan heeft u een vervalsingskanaal gebouwd. HotPDF Component houdt de paden bewust gescheiden: de FDF-annotatieimporteur wijst /Differences ronduit af, en alleen het dedicated Differences-transport raakt het aan

De append binden aan een expliciete basisrevisie

SaveFDFDifferencesToFile weigert te gissen bij welke revisie de FDF hoort. De aanroeper geeft BaseBytes mee, de exacte lengte van de complete PDF-revisie waarvan het formulier is geopend, en BaseSHA256, zijn SHA-256 van 64 hexadecimale cijfers. De methode hasht dat prefix van het geladen document opnieuw, wijst een mismatch af met "The loaded PDF does not match the explicitly selected base", en controleert dat het prefix eindigt op een echte revisie-eindpunt waarvan startxref naar de werkelijke cross-reference data wijst

De praktische regel is beide getallen vastleggen op het moment dat u het formulier de deur uit doet, niet wanneer de FDF terugkomt. Een klein hulpje over de routines SHA256Init / SHA256Update / SHA256Final in HPDFCrypt is genoeg:

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;

// Bij verzending: het hele uitgaande bestand is de basisrevisie
// BaseLength := grootte van 'out\contract-0042.pdf' in bytes
// BaseHash   := PrefixSHA256Hex('out\contract-0042.pdf', BaseLength);

Het geladen document hoeft niet exact de basis te zijn. Het mag de basis zijn plus revisies die u al hebt vastgelegd uit eerdere inzendingen, en juist zo blijft de cumulatieve stream hanteerbaar: BaseBytes blijft het originele prefix benoemen, en alles erna wordt behandeld als eerder toegevoegde bytes

Bytelay-out van HotPDF FDF Differences voor Delphi: de geladen PDF splitst in de basisrevisie geverifieerd op BaseBytes-lengte en BaseSHA256 plus al vastgelegde eerder toegevoegde bytes, en een cumulatieve /Differences-payload die die bytes moet herhalen voordat er een nieuw achtervoegsel bijkomt
Het basisprefix is vastgepind op lengte en SHA-256, vastgelegde bytes werken als een append-only contract, en alleen het achterste achtervoegsel is nieuw - een herhaalde inzending landt dan als een no-op byte voor byte

De Differences toepassen met SaveFDFDifferencesToFile

THPDFHeadlessDocument.SaveFDFDifferencesToFile neemt de naam van het uitvoerbestand, de FDF-bytes, de basislengte en -hash, een optionele embedded-FDF-selectie en een THPDFFDFPackageOptions-limietrecord aan, en geeft een THPDFHeadlessFDFDifferencesResult terug. Vanbinnen opent hij de FDF zelf als een THPDFFDFPackage en roept hij zelf DecodeDifferences aan, dus u geeft het FDF-bestand mee, niet een vooraf gedecodeerde stream. Een lege Selection betekent de FDF op topniveau; een niet-lege loopt eerst een embedded child FDF in

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 op topniveau, geen embedded child
    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;

De volgorde van controles telt zwaarder dan de aanroep zelf. Nadat de basishash slaagt, vergelijkt de methode de al vastgelegde append-bytes, alles tussen BaseBytes en het einde van het geladen bestand, met het begin van de gedecodeerde payload. Een inzending die korter is dan wat u al hebt vastgelegd faalt met "A cumulative submission cannot remove committed append bytes", en een die ermee strijdigt faalt met "The cumulative Differences conflict with the committed revision". Alleen het resterende achtervoegsel is nieuw, en alleen dat achtervoegsel komt in AddedBytes terecht

Controlepipeline van HotPDF SaveFDFDifferencesToFile in Delphi: basislengte en SHA-256, vergelijking van het cumulatieve prefix, validatie van de tijdelijke kandidaat, /ID- en handtekeningidentiteit, permissiebits plus DocMDP- en FieldMDP-toetsing, en daarna een atomaire publicatie van de toegevoegde revisie
Zes fail-closed poorten draaien in vaste volgorde voordat ook maar één byte van de toegevoegde revisie wordt gepubliceerd, dus een misvormde FDF of een niet toegestane wijziging laat het geladen document en de bestaande output onaangetast

De ineengezette kandidaat wordt weggeschreven naar een exclusief tijdelijk bestand in de doelmap, geflusht, heropend als onafhankelijke revisiebron en gevalideerd voordat hij de output vervangt. Faalmomenten bij parsen, annuleren, policy, budget of publicatie laten zowel het geladen document als elk bestaand uitvoerbestand onaangetast. Twee voorwaarden vangen mensen vroeg: de output mag niet aliasen met de geladen bron, en het document mag geen onopgeslagen bewerkingen hebben, want een ondoorzichtige append zou die zwijgend weggooien ("Publish pending document edits before applying Differences")

Waarom is een herhaalde apply een no-op byte voor byte?

Een herhaalde apply van dezelfde cumulatieve FDF is een no-op, want elke byte van zijn payload zit al in het bestand. Wanneer de payload exact gelijk is aan de eerder toegevoegde bytes, meldt SaveFDFDifferencesToFile NoOp = True en AddedBytes = 0, en de output die hij publiceert is identiek aan de huidige PDF. Dat is een sterkere garantie dan "append-only", en het is wat retries na een netwerk-timeout of een gecrashte worker veilig maakt

// Rev 1 al vastgelegd in contract-0042-r1.pdf; dezelfde FDF komt twee keer binnen
// (Doc, Again, Fdf, Selection, BaseLength en BaseHash gedeclareerd als hierboven)
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,
  // en het retry-bestand is byte-identiek aan contract-0042-r1.pdf
finally
  Doc.Free;
end;

Schrijf uw acceptatietests daarnaar. Eis volledige byte-gelijkheid tussen de retry-output en de huidige revisie, niet louter dat het bestand niet kromp. Een test die alleen append-only gedrag controleert laat met volle tevredenheid een implementatie slagen die dezelfde revisie een tweede keer toevoegt, wat een geldig ogende PDF oplevert met een gedupliceerde update-sectie die een latere DocMDP-audit moet verklaren

Welke toetsingscontroles draaien voordat de revisie wordt gepubliceerd?

Vóór publicatie moet de kandidaat-PDF identiteits-, handtekening- en permissiecontroles doorstaan, en geen enkele vertrouwt de buitenste FDF-velden. Het eerste element van de trailer-/ID moet onveranderd overleven, en elk handtekeningveld dat al in het geladen document zit moet nog steeds bestaan met een identieke signature dictionary en dezelfde signature value-identiteit. Een Differences-payload die een bestaande handtekening laat vallen, vervangt of herschrijft faalt met hdSignatureModificationUnsupported

Daarna komen de autorisatielagen. Voor een niet-lege append op een versleuteld document worden de gewijzigde objecten per rol geclassificeerd (catalogus en pagina's, annotaties, AcroForm-velden, appearance streams) en getoetst aan de geauthenticeerde user permission bits, dus een revisie die annotaties bewerkt heeft de annotatiepermissie nodig, enzovoort. Dan gaat de kandidaat door dezelfde certificerings- en veldvergrendelingshandhaving die HotPDF Component ook elders gebruikt: een DocMDP-certificeringssignature (ISO 32000-1 §12.8.2.2) beperkt welke soorten verandering latere revisies mogen aanbrengen, en FieldMDP-vergrendelingen (§12.8.2.4) beschermen benoemde velden. De DocMDP- en FieldMDP-revisieanalyse in HotPDF beschrijft hoe die permissies per revisie worden geclassificeerd

Foutafhandeling hoort "deze FDF is misvormd" te scheiden van "deze revisie is niet toegestaan", want het tweede geval heeft meestal een mens nodig:

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);  // basis-mismatch, conflict, slecht eindpunt
    end;
end;

Wat de operatie nooit doet is net zo belangrijk als wat hij controleert. SaveFDFDifferencesToFile haalt de FDF-/F-file specification nooit op, dus de PDF die in de FDF wordt genoemd kan de merge niet omleiden naar een ander bestand op schijf of het netwerk, en hij voert nooit JavaScript of actions uit. Elke invoer komt expliciet binnen: de geladen basis, de FDF-bytes en het trust-materiaal dat u hebt geconfigureerd

Windows-transacties en de C ABI fdf.differences.apply

Hetzelfde transport is beschikbaar zonder rechtstreeks headless code te schrijven. THPDFWindowsFormDataTransaction.ApplyFDFDifferences neemt de FDF-bytes, basislengte, basis-SHA-256, selectie, package-opties en een wachtwoord aan, delegeert aan SaveFDFDifferencesToFile, en voegt de byte-administratie, rollback, annulering en het same-handle retry-gedrag van de transactie toe. Voor niet-Delphi-aanroepers stelt de C ABI de JSON-operatie fdf.differences.apply bloot, waarin base de lengte en hash draagt:

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

Het JSON-antwoord meldt baseBytes, priorAppendBytes, totalAppendBytes, addedBytes en noop, dezelfde velden als THPDFHeadlessFDFDifferencesResult. De gepubliceerde acceptatiegates voor dit transport draaien op Linux FPC 3.3.1, Windows Delphi 32-bit en 64-bit en Windows FPC 32-bit en 64-bit; elk security target dekt 21 gevallen met 1.244 native controles en 382 callback-commando's

Grenzen en fixture-valkuilen die het kennen waard zijn

Het Differences-transport is met opzet smal, en een paar van zijn randen hebben echte testsuites gebeten:

  • De basis moet een complete PDF-revisie zijn. Een lengte die middenin het bestand landt, of waarvan startxref niet naar de werkelijke cross-reference data wijst, wordt geweigerd voordat er ook maar één byte wordt gekopieerd
  • Handtekening-fixtures hebben een indirecte /Lock nodig. De Windows-permissielezer voor geladen documenten wijst een handtekeningveld waarvan /Lock een inline dictionary is af met "Signature field /Lock must be an indirect reference", ook al tolereren sommige ondertekenpaden de inline vorm, dus schrijf de lock dictionary als zijn eigen object
  • Een test-CA gemaakt met kale openssl req -x509 mist CA:TRUE en keyCertSign, dus ketenvalidatie faalt met een certificate verify error die op een library-bug lijkt maar het niet is
  • De buitenste /Fields-waarden worden door deze aanroep niet gemerged. Wilt u ze ook in het document, beslis dan expliciet of ze informatief zijn, en presenteer ze nooit als ondertekend
  • De FDF zelf blijft niet-incrementeel. Een FDF-bestand met meerdere trailers, nonzero generations of dubbele objectnummers is een misvormde FDF, geen rijkere

Voor de kale waardenkant van FDF en XFDF, zonder ondertekende revisies in het spel, zijn de multi-select FDF- en XFDF-roundtrip in HotPDF en XFDF-annotatie-import en -export in Delphi de betere beginpunten

FAQ

Kan ik de gedecodeerde Differences-stream meegeven in plaats van het FDF-bestand?

Nee. SaveFDFDifferencesToFile parst de FDF die u meegeeft als een THPDFFDFPackage, past de optionele embedded selectie toe en roept intern DecodeDifferences aan. Gebruik THPDFFDFPackage.DecodeDifferences op zichzelf wanneer u de rauwe revisiebytes wilt inspecteren of archiveren, maar geef het FDF-bestand aan de apply-aanroep

Wat gebeurt er wanneer de client een tweede, grotere cumulatieve FDF stuurt?

Laad de PDF die de eerste vastgelegde revisie al bevat en houd dezelfde BaseBytes en BaseSHA256 aan. De methode verifieert dat de nieuwe payload begint met exact de bytes die al zijn vastgelegd, voegt alleen het nieuwe achtervoegsel toe en meldt het in AddedBytes, en draait daarna de volledige permissie- en DocMDP/FieldMDP-toetsing op het resultaat

Betekent een geslaagde apply dat de veldwaarden in /Fields ondertekend zijn?

Nee. ISO 32000-1 verplicht niet dat /Fields, /Annots en /Differences in één FDF overeenstemmen, en HotPDF Component stemt ze niet op elkaar af. Ondertekend is wat de CMS-handtekeningen in de toegevoegde revisie dekken, dus lees ondertekende waarden uit de gemergde PDF, niet uit de FDF-wrapper

Het FDF Differences-transport, de headless signature policy-engine en de Windows form-data-transacties zitten allemaal in de HotPDF Component voor Delphi, C++Builder en Free Pascal