Teknisk artikel

FDF Differences i Delphi: lägg till signerade formrevisioner

HotPDF Component applicerar en FDF-/Differences-ström från Delphi via THPDFHeadlessDocument.SaveFDFDifferencesToFile: metoden kontrollerar att den laddade PDF:en fortfarande matchar en basrevision du anger med exakt bytelängd och SHA-256, lägger till de kumulativa revisionsbytena exakt en gång, kör resultatet genom krypteringsbehörighets- och DocMDP/FieldMDP-godkännanden, och publicerar det atomärt. Att upprepa samma inlämning är en no-op byte för byte

Arbetsflödet det här finns för är oglamoröst och vanligt. Du skickar ut ett avtal eller en besiktningsblankett som PDF, någon fyller i den och signerar den i sin egen visare, och det som kommer tillbaka är inte PDF:en utan en FDF-fil. Fältvärden reser i /Fields, men själva den signerade revisionen, de byte CMS-signaturen faktiskt täcker, reser i /Differences. Får du ihop sammanslagningen fel bryter du antingen signaturen eller, värre, accepterar fältvärden som ingen signatur någonsin täckte

Vad bär en FDF-/Differences-ström egentligen?

En FDF-/Differences-ström bär råa inkrementella uppdateringsbyte för mål-PDF:en, inte en FDF-nivå-historik. ISO 32000-1 §12.7.7.3, tabell 243 definierar posten Differences i FDF-ordboken som de inkrementella ändringarna av den underliggande PDF:en sedan den öppnades, i samma form som en PDF-inkrementell uppdatering enligt §7.5.6: nya och ersatta objekt, en korsreferenssektion och en trailer vars /Prev pekar tillbaka in i basfilen

FDF-containern i sig är motsatsen. §12.7.7.1 förbjuder inkrementella uppdateringar av FDF-filer, så en FDF har en enda trailer, bara generation 0-objekt och unika objektnummer. HotPDF Component tvingar fram exakt det i sin FDF-parser och släpper aldrig på det för att göra /Differences enklare. De inkrementella uppgifterna bor inne i en ström av en strikt icke-inkrementell container, och att hålla de två lagren isär är halva jobbet

Anatomin hos HotPDF FDF /Differences i Delphi: en strikt icke-inkrementell FDF-container med en enda trailer och generation 0-objekt bär en kumulativ /Differences-ström av råa inkrementella uppdateringsbyte, medan /Fields och /Annots förblir osignerade och osammanslagna
Den signerade revisionen reser i /Differences-strömmen, inte i /Fields: containern i sig förbjuder inkrementella uppdateringar, och bara den bytenyttolast CMS-signaturen täcker kan litas på

Två egenskaper hos strömmen formar varefterföljande designbeslut:

  • Strömmen är kumulativ: den håller varje byte lagt till sedan mål-PDF:en öppnades, så en andra inlämning från samma session upprepar revisionen du redan tillfört och lägger till den nya bakom den
  • De yttre posterna /Fields och /Annots i samma FDF är inte garanterade att stämma med /Differences, så de fältvärden du kan läsa enkelt är inte de data signaturen täcker

Den andra punkten förtjänar rakt tal. Om din importkod läser /V från /Fields och behandlar resultatet som signerat bara för att en signatur anlände i samma fil har du byggt en förfalsningskanal. HotPDF Component håller vägarna åtskilda med flit: FDF-annoteringsimportern avvisar /Differences kategoriskt, och bara den dedikerade Differences-transporten rör den

Att binda bilagan till en explicit basrevision

SaveFDFDifferencesToFile vägrar gissa vilken revision FDF:en tillhör. Anroparen skickar BaseBytes, den exakta längden på den kompletta PDF-revision formuläret öppnades från, och BaseSHA256, dess 64-siffriga hexadecimala SHA-256. Metoden hasherar om det prefixet av det laddade dokumentet, avvisar en avvikelse med "The loaded PDF does not match the explicitly selected base", och kontrollerar att prefixet slutar vid en riktig revisionsslutpunkt vars startxref pekar på dess faktiska korsreferensdata

Den praktiska regeln är att registrera båda talen när du skickar ut formuläret, inte när FDF:en kommer tillbaka. En liten hjälpfunktion över rutinerna SHA256Init / SHA256Update / SHA256Final i HPDFCrypt räcker:

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;

// Vid sändning: hela utgående filen är basrevisionen
// BaseLength := storleken på 'out\contract-0042.pdf' i byte
// BaseHash   := PrefixSHA256Hex('out\contract-0042.pdf', BaseLength);

Det laddade dokumentet behöver inte vara exakt basen. Det får vara basen plus revisioner du redan tillfört från tidigare inlämningar, vilket är hur den kumulativa strömmen hålls hanterbar: BaseBytes fortsätter namnge det ursprungliga prefixet, och allt efter det behandlas som tidigare bilagebyte

Bytelayouten för HotPDF FDF Differences i Delphi: den laddade PDF:en delas i basrevisionen verifierad av BaseBytes-längd och BaseSHA256 plus redan tillförda tidigare bilagebyte, och en kumulativ /Differences-nyttolast som måste upprepa de bytena innan den lägger till ett nytt suffix
Basprefixet är fastspikat av längd och SHA-256, tillförda byte fungerar som ett bilaga-enbart-kontrakt, och bara det avslutande suffixet är nytt - en upprepad inlämning landar då som en no-op byte för byte

Att applicera Differences med SaveFDFDifferencesToFile

THPDFHeadlessDocument.SaveFDFDifferencesToFile tar utdatafilnamnet, FDF-bytena, baslängd och hash, ett valbart inbäddat-FDF-val och en THPDFFDFPackageOptions-gränspost, och returnerar en THPDFHeadlessFDFDifferencesResult. Invändigt öppnar den FDF:en som en THPDFFDFPackage och anropar själv DecodeDifferences, så du skickar FDF-filen, inte en föravkodad ström. En tom Selection betyder toppnivå-FDF; en icke-tom vandrar först in i ett inbäddat barn-FDF

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;  // toppnivå-FDF, inget inbäddat barn
    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;

Ordningen på kontrollerna spelar större roll än anropet i sig. Efter att bas-hashen godkänts jämför metoden de redan tillförda bilagebytena, allt mellan BaseBytes och slutet av den laddade filen, mot början av den avkodade nyttolasten. En inlämning som är kortare än vad du redan tillfört misslyckas med "A cumulative submission cannot remove committed append bytes", och en som motsäger det misslyckas med "The cumulative Differences conflict with the committed revision". Bara det kvarvarande suffixet är nytt, och bara det suffixet hamnar i AddedBytes

Kontrollpipelinen i HotPDF SaveFDFDifferencesToFile i Delphi: baslängd och SHA-256, jämförelse av kumulativt prefix, validering av tillfällig kandidat, /ID- och signaturidentitet, behörighetsbitar plus DocMDP- och FieldMDP-godkännande, sedan en atomär publicering av den tillförda revisionen
Sex fail-closed-portar körs i fast ordning innan någon byte av den tillförda revisionen publiceras, så en felformad FDF eller en icke tillåten ändring lämnar det laddade dokumentet och befintliga utdata orörda

Den ihopsatta kandidaten skrivs till en exklusiv temporär fil i målkatalogen, spolas, återöppnas som en oberoende revisionskälla och valideras innan den ersätter utdata. Parsnings-, avbrotts-, policy-, budget- eller publiceringsfel lämnar både det laddade dokumentet och eventuell befintlig utdatafil orörda. Två förutsättningar fångar folk tidigt: utdata får inte aliasa den laddade källan, och dokumentet får inga osparade redigeringar, eftersom en opak bilaga tyst skulle kasta dem ("Publish pending document edits before applying Differences")

Varför är en upprepad applicering en no-op byte för byte?

En upprepad applicering av samma kumulativa FDF är en no-op eftersom varje byte av dess nyttolast redan är i filen. När nyttolasten är exakt lika med de tidigare bilagebytena rapporterar SaveFDFDifferencesToFile NoOp = True och AddedBytes = 0, och den utdata den publicerar är identisk med den nuvarande PDF:en. Det är ett starkare löfte än "enbart-bilaga", och det är det som gör omförsök efter en nätverks-timeout eller en kraschad arbetare säkra

// Rev 1 redan tillförd i contract-0042-r1.pdf; samma FDF anländer två gånger
// (Doc, Again, Fdf, Selection, BaseLength och BaseHash deklarerade som ovan)
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,
  // och retriesfilen är byte-identisk med contract-0042-r1.pdf
finally
  Doc.Free;
end;

Skriv dina acceptanstester för att matcha. Asserta full byte-likhet mellan retries-utdata och den nuvarande revisionen, inte bara att filen inte krympte. Ett test som bara kontrollerar bilaga-enbart-beteende passerar gärna en implementation som lägger till samma revision en andra gång, vilket producerar en gilltigsedande PDF med en duplicerad uppdateringssektion som en senare DocMDP-granskning måste förklara

Vilka godkännandekontroller körs innan revisionen publiceras?

Innan publiceringen måste kandidat-PDF:en passera identitets-, signatur- och behörighetskontroller, och ingen av dem litar på de yttre FDF-fälten. Första elementet i trilerns /ID måste överleva oförändrat, och varje signaturfält redan närvarande i det laddade dokumentet måste fortfarande finnas med en identisk signaturordbok och samma signaturvärdesidentitet. En Differences-nyttolast som tappar, ersätter eller skriver om en befintlig signatur misslyckas med hdSignatureModificationUnsupported

Sedan kommer auktorisationslagren. För en icke-tom bilaga på ett krypterat dokument klassificeras de ändrade objekten efter roll (katalog och sidor, annoteringar, AcroForm-fält, utseendeströmmar) och kontrolleras mot de autentiserade användarbehörighetsbitarna, så en revision som redigerar annoteringar behöver annoteringsbehörigheten, och så vidare. Därefter går kandidaten genom samma certifierings- och fältlås-tvång som HotPDF Component använder annorstädes: en DocMDP-certifieringssignatur (ISO 32000-1 §12.8.2.2) begränsar vilka slags ändringar senare revisioner får göra, och FieldMDP-lås (§12.8.2.4) skyddar namngivna fält. DocMDP- och FieldMDP-revisionsanalysen i HotPDF täcker hur de behörigheterna klassificeras per revision

Felhanteringen bör skilja "den här FDF:en är felformad" från "den här revisionen är inte tillåten", eftersom det andra fallet oftast behöver en människa:

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);  // basavvikelse, konflikt, fel slutpunkt
    end;
end;

Vad operationen aldrig gör är lika viktigt som vad den kontrollerar. SaveFDFDifferencesToFile hämtar aldrig FDF:ens /F-filspecifikation, så PDF:en namngiven inne i FDF:en kan inte omdirigera sammanslagningen till någon annan fil på disken eller nätverket, och den kör aldrig JavaScript eller åtgärder. Varje indata anländer explicit: den laddade basen, FDF-bytena och det förtroendematerial du konfigurerat

Windows-transaktioner och C-ABI:n fdf.differences.apply

Samma transport är tillgänglig utan att skriva headless-kod direkt. THPDFWindowsFormDataTransaction.ApplyFDFDifferences tar FDF-bytena, baslängd, bas-SHA-256, urval, paketalternativ och ett lösenord, delegerar till SaveFDFDifferencesToFile, och lägger till transaktionens byte-redovisning, återställning, avbrott och omförsök på samma handtag. För icke-Delphi-anropare exponerar C-ABI:n JSON-operationen fdf.differences.apply, där base bär längd och hash:

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

JSON-svaret rapporterar baseBytes, priorAppendBytes, totalAppendBytes, addedBytes och noop, samma fält som THPDFHeadlessFDFDifferencesResult. De publicerade acceptansporterna för den här transporten kör på Linux FPC 3.3.1, Windows Delphi 32- och 64-bitars och Windows FPC 32- och 64-bitars; varje säkerhetsmål täcker 21 fall med 1 244 nativa kontroller och 382 callback-kommandon

Gränser och testfallsgropar värda att känna till

Differences-transporten är medvetet smal, och några av dess kanter har bitit i verkliga testsviter:

  • Basen måste vara en komplett PDF-revision. En längd som landar mitt i filen, eller vars startxref inte pekar på dess faktiska korsreferensdata, avvisas innan någon byte kopieras
  • Signaturfixturer behöver en indirekt /Lock. Behörighetsläsaren för laddade Windows-dokument avvisar ett signaturfält vars /Lock är en inline-ordbok med "Signature field /Lock must be an indirect reference", fastän vissa signeringsvägar tolererar inline-formen, så skriv låsordboken som sitt eget objekt
  • En test-CA gjord med blott openssl req -x509 saknar CA:TRUE och keyCertSign, så kedjevalideringen misslyckas med ett certifikatverifieringsfel som ser ut som en biblioteksbugg och inte är det
  • De yttre /Fields-värdena sammanslås inte av det här anropet. Om du även vill ha dem i dokumentet, bestäm explicit om de är informativa, och presentera dem aldrig som signerade
  • FDF:en i sig förblir icke-inkrementell. En FDF-fil med flera trailrar, icke-noll generationer eller duplicerade objektnummer är en felformad FDF, inte en rikare

För den rena värdesidan av FDF och XFDF, utan signerade revisioner i bilden, är multi-select FDF- och XFDF-rundturen i HotPDF och XFDF-annoteringsimport och export i Delphi bättre startpunkter

FAQ

Kan jag skicka den avkodade Differences-strömmen i stället för FDF-filen?

Nej. SaveFDFDifferencesToFile parsar den FDF du skickar som en THPDFFDFPackage, applicerar det valbara inbäddade urvalet och anropar DecodeDifferences internt. Använd THPDFFDFPackage.DecodeDifferences på egen hand när du vill inspektera eller arkivera de råa revisionsbytena, men ge FDF-filen till apply-anropet

Vad händer när klienten skickar en andra, större kumulativ FDF?

Ladda PDF:en som redan innehåller den första tillförda revisionen och behåll samma BaseBytes och BaseSHA256. Metoden verifierar att den nya nyttolasten börjar med exakt de byte som redan tillförts, lägger till bara det nya suffixet och rapporterar det i AddedBytes, och kör sedan fullt behörighets- och DocMDP/FieldMDP-godkännande på resultatet

Betyder en lyckad applicering att fältvärdena i /Fields är signerade?

Nej. ISO 32000-1 kräver inte att /Fields, /Annots och /Differences i en FDF stämmer överens, och HotPDF Component förlikar dem inte. Det som är signerat är vad CMS-signaturerna i den tillförda revisionen täcker, så läs signerade värden från den sammanslagna PDF:en, inte från FDF-omslaget

FDF Differences-transporten, headless-signaturpolicymotorn och Windows-transaktionerna för formulärdata ingår alla i HotPDF Component för Delphi, C++Builder och Free Pascal