Teknisk artikel

FDF Differences i Delphi: tilføj signede formularrevisioner

HotPDF Component anvender en FDF /Differences-stream fra Delphi gennem THPDFHeadlessDocument.SaveFDFDifferencesToFile: metoden tjekker, at den indlæste PDF stadig matcher en basisrevision, du identificerer ved eksakt bytelængde og SHA-256, tilføjer de kumulative revisionsbytes præcis én gang, kører resultatet gennem krypteringstilladelses- og DocMDP/FieldMDP-godkendelse og publicerer det atomart. At gentage samme indsendelse er en byte-for-byte no-op

Workflowet, dette findes til, er ukendeligt og almindeligt. Du sender en kontrakt eller et inspektionsformular ud som PDF, en eller anden udfylder og signerer den i sin egen viewer, og det, der kommer tilbage, er ikke PDF'en men en FDF-fil. Feltværdier kører i /Fields, men den signede revision selv — de bytes, CMS-signaturen reelt dækker — kører i /Differences. Får du merge'en forkert, knækker du enten signaturen eller, værre, accepterer feltværdier, som ingen signatur nogensinde dækkede

Hvad bærer en FDF /Differences-stream egentlig?

En FDF /Differences-stream bærer rå incremental update-bytes til mål-PDF'en, ikke en historik på FDF-niveau. ISO 32000-1 §12.7.7.3, Tabel 243 definerer Differences-posten i FDF-dictionaryen som de trinvise ændringer af den underliggende PDF, foretaget siden den blev åbnet, i samme form som en PDF incremental update under §7.5.6: nye og erstattede objekter, en cross-reference-sektion og en trailer, hvis /Prev peger tilbage ind i basisfilen

FDF-containeren selv er det modsatte. §12.7.7.1 forbyder incremental updates af FDF-filer, så en FDF har én enkelt trailer, kun generation 0-objekter og unikke objektnumre. HotPDF Component håndhæver præcis dét i sin FDF-parser og slap aldrig på det for at gøre /Differences lettere. De trinvise data lever inde i én stream af en strengt ikke-trinvis container, og at holde de to lag adskilt er halvdelen af jobbet

HotPDF FDF /Differences-anatomi-diagram til Delphi: en strengt ikke-trinvis FDF-container med én enkelt trailer og generation 0-objekter bærer én kumulativ /Differences-stream af rå incremental update-bytes, mens /Fields og /Annots forbliver unsigned og umerged
Den signede revision kører i /Differences-streamen, ikke i /Fields: containeren selv forbyder incremental updates, og kun det byte-payload, CMS-signaturen dækker, kan stoles på

To egenskaber ved streamen former enhver downstream-designbeslutning:

  • Streamen er kumulativ: den rummer hver byte tilføjet siden mål-PDF'en blev åbnet, så en anden indsendelse fra samme session gentager revisionen, du allerede har committet, og tilføjer den nye bag den
  • De ydre /Fields- og /Annots-poster i samme FDF er ikke garanteret at stemme med /Differences, så de feltværdier, du let kan læse, er ikke de data, signaturen dækker

Det andet punkt fortjener et ligefremt sprog. Læser din importkode /V fra /Fields og behandler resultatet som signeret, fordi en signatur ankom i samme fil, har du bygget en forfalskningskanal. HotPDF Component holder bevidst stierne adskilt: FDF-annotationimporteren afviser /Differences kategorisk, og kun den dedikerede Differences-transport rører den

At binde tilføjelsen til en eksplicit basisrevision

SaveFDFDifferencesToFile nægter at gætte, hvilken revision FDF'en tilhører. Kalderen giver BaseBytes med, den eksakte længde af den komplette PDF-revision, formularen blev åbnet fra, og BaseSHA256, dets 64-cifrede hexadecimale SHA-256. Metoden rehasher det præfiks af det indlæste dokument, afviser en uoverensstemmelse med "The loaded PDF does not match the explicitly selected base" og tjekker, at præfikset ender ved et rigtigt revisionsendpoint, hvis startxref peger på dets faktiske cross-reference-data

Den praktiske regel er at registrere begge tal, når du sender formularen ud, ikke når FDF'en kommer tilbage. En lille hjælper over SHA256Init/SHA256Update/SHA256Final-rutinerne i HPDFCrypt er nok:

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;

// Ved afsendelse: hele den udgående fil er basisrevisionen
// BaseLength := størrelsen af 'out\contract-0042.pdf' i bytes
// BaseHash   := PrefixSHA256Hex('out\contract-0042.pdf', BaseLength);

Det indlæste dokument behøver ikke at være præcis basen. Det kan være basen plus revisioner, du allerede har committet fra tidligere indsendelser, hvilket er dét, der holder den kumulative stream håndterbar: BaseBytes fortsætter med at navngive det oprindelige præfiks, og alt bagefter behandles som tidligere tilføjelsesbytes

HotPDF FDF Differences-byte-layout til Delphi: den indlæste PDF splittes i basisrevisionen verificeret ved BaseBytes-længde og BaseSHA256 plus allerede committede tidligere tilføjelsesbytes, og et kumulativt /Differences-payload, der må gentage de bytes, før et nyt suffiks tilføjes
Basispræfikset er fastlåst af længde og SHA-256, committede bytes fungerer som en append-only-kontrakt, og kun det efterfølgende suffiks er nyt - en gentagen indsendelse lander da som en byte-for-byte no-op

Anvendelse af Differences med SaveFDFDifferencesToFile

THPDFHeadlessDocument.SaveFDFDifferencesToFile tager outputfilnavnet, FDF-bytes, basislængde og -hash, et valgfrit embedded-FDF-udvalg og en THPDFFDFPackageOptions-grænsepost og returnerer en THPDFHeadlessFDFDifferencesResult. Indeni åbner den FDF'en som en THPDFFDFPackage og kalder selv DecodeDifferences, så du giver FDF-filen med, ikke en præ-dekodet stream. En tom Selection betyder FDF'en på topniveau; en ikke-tom går først ind i et embedded child-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;  // FDF på topniveau, intet 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;

Rækkefølgen af tjek betyder mere end kaldet selv. Efter at basis-hashen er bestået, sammenligner metoden de allerede committede tilføjelsesbytes — alt mellem BaseBytes og slutningen af den indlæste fil — med starten af det dekodede payload. En indsendelse, der er kortere end det, du allerede har committet, fejler med "A cumulative submission cannot remove committed append bytes", og en, der er uenig, fejler med "The cumulative Differences conflict with the committed revision". Kun det resterende suffiks er nyt, og kun det suffiks ender i AddedBytes

HotPDF SaveFDFDifferencesToFile-tjekpipeline i Delphi: basislængde og SHA-256, kumulativ præfikssammenligning, midlertidig kandidatvalidering, /ID- og signaturidentitet, tilladelsesbits plus DocMDP- og FieldMDP-godkendelse, derefter en atomar publicering af den tilføjede revision
Seks fail-closed-porte kører i fast rækkefølge, før nogen byte af den tilføjede revision publiceres, så en misdannet FDF eller en ikke-tilladt ændring efterlader det indlæste dokument og det eksisterende output urørt

Den samlede kandidat skrives til en eksklusiv midlertidig fil i målmappen, flushes, genåbnes som en uafhængig revisionskilde og valideres, før den erstatter outputtet. Parse-, annullerings-, policy-, budget- eller publiceringsfejl efterlader både det indlæste dokument og enhver eksisterende outputfil urørt. To forudsætninger fanger folk tidligt: outputtet må ikke aliasere den indlæste kilde, og dokumentet må ikke have ugemsedte redigeringer, fordi en opak tilføjelse lydløst ville kassere dem ("Publish pending document edits before applying Differences")

Hvorfor er en gentagen anvendelse en byte-for-byte no-op?

En gentagen anvendelse af samme kumulative FDF er en no-op, fordi hver byte af dens payload allerede er i filen. Når payloadet er eksakt lig de tidligere tilføjelsesbytes, rapporterer SaveFDFDifferencesToFile NoOp = True og AddedBytes = 0, og det output, den publicerer, er identisk med den aktuelle PDF. Det er en stærkere garanti end "append-only", og det er dét, der gør retries efter en netværkstimeout eller en crashet worker sikre

// Rev 1 allerede committet i contract-0042-r1.pdf; samme FDF ankommer to gange
// (Doc, Again, Fdf, Selection, BaseLength og BaseHash erklæret som ovenfor)
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,
  // og retry-filen er byte-identisk med contract-0042-r1.pdf
finally
  Doc.Free;
end;

Skriv dine acceptansetests, så de matcher. Assert fuld byte-lighed mellem retry-outputtet og den aktuelle revision, ikke blot at filen ikke skrumpede. En test, der kun tjekker append-only-adfærd, vil gladeligt godkende en implementering, der tilføjer samme revision en anden gang, hvilket producerer en gyldigt udseende PDF med en duplikeret update-sektion, som en senere DocMDP-audit så må forklare

Hvilke godkendelsestjek kører, før revisionen publiceres?

Før publicering skal kandidat-PDF'en bestå identitets-, signatur- og tilladelsestjek, og ingen af dem stoler på de ydre FDF-felter. Første element af traileren /ID skal overleve uændret, og hvert signaturfelt, der allerede findes i det indlæste dokument, skal stadig eksistere med en identisk signaturdictionary og samme signaturværdiidentitet. Et Differences-payload, der dropper, erstatter eller omskriver en eksisterende signatur, fejler med hdSignatureModificationUnsupported

Derefter kommer autorisationslagene. For en ikke-tom tilføjelse på et krypteret dokument klassificeres de ændrede objekter efter rolle (katalog og sider, annotationer, AcroForm-felter, appearance streams) og tjekkes mod de autentificerede brugertilladelsesbits, så en revision, der redigerer annotationer, kræver annotationstilladelsen, og så videre. Derefter går kandidaten gennem den samme certificerings- og feltlås-håndhævelse, HotPDF Component bruger andre steder: en DocMDP-certificeringssignatur (ISO 32000-1 §12.8.2.2) begrænser, hvilke slags ændringer senere revisioner må foretage, og FieldMDP-låse (§12.8.2.4) beskytter navngivne felter. DocMDP- og FieldMDP-revisionsanalysen i HotPDF dækker, hvordan de tilladelser klassificeres pr. revision

Fejlhåndtering bør adskille "denne FDF er misdannet" fra "denne revision er ikke tilladt", fordi det andet tilfælde som regel kræver et menneske:

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, konflikt, dårligt endpoint
    end;
end;

Det, operationen aldrig gør, er lige så vigtigt som det, den tjekker. SaveFDFDifferencesToFile henter aldrig FDF'ens /F-file specification, så PDF'en navngivet inde i FDF'en kan ikke omdirigere merge'en til en anden fil på disk eller netværket, og den udfører aldrig JavaScript eller actions. Hvert input ankommer eksplicit: den indlæste base, FDF-bytes og det trust-materiale, du konfigurerede

Windows-transaktioner og C ABI'ens fdf.differences.apply

Samme transport er tilgængelig uden selv at skrive headless-kode. THPDFWindowsFormDataTransaction.ApplyFDFDifferences tager FDF-bytes, basislængde, basis-SHA-256, udvalg, package options og en adgangskode, delegerer til SaveFDFDifferencesToFile og tilføjer transaktionens byte-bogholderi, rollback, annullering og same-handle-retry-adfærd. For ikke-Delphi-kaldere eksponerer C ABI'en JSON-operationen fdf.differences.apply, hvor base bærer længde og hash:

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

JSON-svaret rapporterer baseBytes, priorAppendBytes, totalAppendBytes, addedBytes og noop, de samme felter som THPDFHeadlessFDFDifferencesResult. De publicerede acceptgates for denne transport kører på Linux FPC 3.3.1, Windows Delphi 32- og 64-bit og Windows FPC 32- og 64-bit; hvert security target dækker 21 tilfælde med 1.244 native tjek og 382 callback-kommandoer

Grænser og fixture-fælder værd at kende

Differences-transporten er bevidst snæver, og nogle af dens kanter har bidt rigtige testsuiter:

  • Basen skal være en komplet PDF-revision. En længde, der lander midt i filen, eller hvis startxref ikke peger på dens faktiske cross-reference-data, afvises, før nogen byte kopieres
  • Signaturfixtures kræver et indirekte /Lock. Windows' indlæste dokument-tilladelseslæser afviser et signaturfelt, hvis /Lock er en inline-dictionary, med "Signature field /Lock must be an indirect reference", selvom nogle signeringsstier tolererer inline-formen, så skriv lock-dictionaryen som sit eget objekt
  • En test-CA lavet med en nøgen openssl req -x509 mangler CA:TRUE og keyCertSign, så kædevalidering fejler med en certificate verify-fejl, der ligner en biblioteksfejl og ikke er det
  • De ydre /Fields-værdier merges ikke af dette kald. Vil du også have dem i dokumentet, så beslut eksplicit, om de er oplysningsmæssige, og præsentér dem aldrig som signede
  • FDF'en selv forbliver ikke-trinvis. En FDF-fil med flere trailere, nonzero-generationer eller duplikerede objektnumre er en misdannet FDF, ikke en rigere

Til FDF og XFDF's rene vældeside, uden signede revisioner i billedet, er multi-select FDF- og XFDF-roundtrippet i HotPDF og XFDF-annotationimport og -eksport i Delphi de bedre startpunkter

FAQ

Kan jeg give den dekodede Differences-stream i stedet for FDF-filen?

Nej. SaveFDFDifferencesToFile parser den FDF, du giver med, som en THPDFFDFPackage, anvender det valgfri embeddede udvalg og kalder DecodeDifferences internt. Brug THPDFFDFPackage.DecodeDifferences på egen hånd, når du vil inspicere eller arkivere de rå revisionsbytes, men giv FDF-filen med til apply-kaldet

Hvad sker der, når klienten sender en anden, større kumulativ FDF?

Indlæs den PDF, der allerede indeholder den første committede revision, og behold samme BaseBytes og BaseSHA256. Metoden verificerer, at det nye payload starter med præcis de allerede committede bytes, tilføjer kun det nye suffiks og rapporterer det i AddedBytes og kører derefter den fulde tilladelses- og DocMDP/FieldMDP-godkendelse på resultatet

Betyder en vellykket anvendelse, at feltværdierne i /Fields er signede?

Nej. ISO 32000-1 kræver ikke, at /Fields, /Annots og /Differences i én FDF stemmer overens, og HotPDF Component forligør dem ikke. Det, der er signeret, er hvad end CMS-signaturerne i den tilføjede revision dækker, så læs signede værdier fra den mergede PDF, ikke fra FDF-wrapperen

FDF Differences-transporten, headless-signaturpolicy-motoren og Windows form-data-transaktionerne følger alle med i HotPDF Component til Delphi, C++Builder og Free Pascal