Technický článek

FDF Differences v Delphi: přidat podepsané revize formuláře

HotPDF Component aplikuje stream FDF /Differences z Delphi přes THPDFHeadlessDocument.SaveFDFDifferencesToFile: metoda zkontroluje, že načtené PDF stále odpovídá základní revizi, kterou určíte přesnou délkou v bajtech a SHA-256, připojí kumulativní bajty revize přesně jednou, pustí výsledek přes kontrolu šifrovacích oprávnění a vpuštění DocMDP/FieldMDP a publikuje ho atomicky. Zopakování téhož odeslání je no-op na bajt přesně

Pracovní postup, pro který tohle existuje, je nevýrazný a běžný. Pošlete smlouvu nebo kontrolní formulář ven jako PDF, někdo ho vyplní a podepíše ve svém vlastním prohlížeči a co se vrátí, není PDF, ale soubor FDF. Hodnoty polí jedou v /Fields, ale samotná podepsaná revize — bajty, které CMS podpis doopravdy pokrývá — jede v /Differences. Splést merge špatně a buď rozbijete podpis, nebo, hůř, přijmete hodnoty polí, které žádný podpis nikdy nepokryl

Co stream FDF /Differences doopravdy nese?

Stream FDF /Differences nese syrové bajty inkrementálního updatu cílového PDF, ne historii na úrovni FDF. ISO 32000-1 §12.7.7.3, tabulka 243 definuje položku Differences slovníku FDF jako inkrementální změny základního PDF provedené od jeho otevření, ve stejné formě jako PDF inkrementální update podle §7.5.6: nové a nahrazené objekty, sekce cross-reference a trailer, jehož /Prev ukazuje zpět do základního souboru

Samotný kontejner FDF je opak. §12.7.7.1 zakazuje inkrementální updaty souborů FDF, takže FDF má jediný trailer, jen objekty generace 0 a unikátní čísla objektů. HotPDF Component tohle přesně vynucuje ve svém FDF parseru a nikdy to nepovolí, aby byl /Differences jednodušší. Inkrementální data žijí uvnitř jednoho streamu přísně neinkrementálního kontejneru a držet ty dvě vrstvy oddělené je polovina práce

Anatomie FDF /Differences v HotPDF pro Delphi: přísně neinkrementální kontejner FDF s jediným trailerem a objekty generace 0 nese jeden kumulativní stream /Differences se syrovými bajty inkrementálního updatu, zatímco /Fields a /Annots zůstávají nepodepsané a nesloučené
Podepsaná revize jede ve streamu /Differences, ne v /Fields: kontejner sám zakazuje inkrementální updaty a důvěřovat lze jen bajtovému payloadu, který CMS podpis pokrývá

Dvě vlastnosti streamu určují každé designové rozhodnutí dál:

  • Stream je kumulativní: drží každý bajt přidaný od otevření cílového PDF, takže druhé odeslání ze stejné session opakuje revizi, kterou už jste commitli, a přidává novou za ni
  • Vnější položky /Fields a /Annots téhož FDF se nemusí shodovat s /Differences, takže hodnoty polí, které snadno přečtete, nejsou data, která podpis pokrývá

Ten druhý bod si zaslouží přímost. Pokud váš importní kód čte /V z /Fields a bere výsledek jako podepsaný, protože podpisy přišly ve stejném souboru, postavil jste kanál pro padělky. HotPDF Component drží cesty oddělené úmyslně: importér FDF anotací odmítá /Differences rovnou a jen dedikovaný Differences transport se ho dotkne

Vázání appendu na explicitní základní revizi

SaveFDFDifferencesToFile odmítá hádat, ke které revizi FDF patří. Volající předá BaseBytes, přesnou délku kompletní PDF revize, ze které se formulář otevřel, a BaseSHA256, jeho 64místné hexadecimální SHA-256. Metoda znovu zahashuje ten prefix načteného dokumentu, odmítne nesoulad hlášením „The loaded PDF does not match the explicitly selected base" a zkontroluje, že prefix končí na skutečném konci revize, jehož startxref ukazuje na jeho skutečná cross-reference data

Praktické pravidlo je zapsat obě čísla, když formulář posíláte ven, ne když se FDF vrací. Malý helper nad rutinami SHA256Init / SHA256Update / SHA256Final v HPDFCrypt stačí:

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;

// Při odeslání: celý odchozí soubor je základní revize
// BaseLength := velikost 'out\contract-0042.pdf' v bajtech
// BaseHash   := PrefixSHA256Hex('out\contract-0042.pdf', BaseLength);

Načtený dokument nemusí být přesně tím základem. Může to být základ plus revize, které jste už commitli z dřívějších odeslání, a právě tak kumulativní stream zůstává zvládnutelný: BaseBytes pořád pojmenovává původní prefix a všechno za ním se bere jako dřívější append bajty

Byte layout FDF Differences v HotPDF pro Delphi: načtené PDF se dělí na základní revizi ověřenou délkou BaseBytes a BaseSHA256 plus už commitlé dřívější append bajty, a kumulativní payload /Differences, který musí ty bajty zopakovat, než přidá novou příponu
Základní prefix je přibit délkou a SHA-256, commitlé bajty fungují jako append-only smlouva a nová je jen závěrečná přípona - opakované odeslání pak dopadne jako no-op na bajt přesně

Aplikace Differences přes SaveFDFDifferencesToFile

THPDFHeadlessDocument.SaveFDFDifferencesToFile bere název výstupního souboru, bajty FDF, délku a hash základu, volitelný výběr embedded FDF a limitní záznam THPDFFDFPackageOptions a vrací THPDFHeadlessFDFDifferencesResult. Uvnitř otevírá FDF jako THPDFFDFPackage a sama volá DecodeDifferences, takže předáváte soubor FDF, ne předdekódovaný stream. Prázdný Selection znamená top-level FDF; neprázdný se nejdřív projde do vnořeného dětského 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;  // top-level FDF, žádné vnořené dítě
    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;

Pořadí kontrol záleží víc než samotné volání. Po projití hashe základu metoda porovná už commitlé append bajty — všechno mezi BaseBytes a koncem načteného souboru — se začátkem dekódovaného payloadu. Odeslání kratší, než co už máte commitlé, selže hlášením „A cumulative submission cannot remove committed append bytes" a jedno, které se s tím neshoduje, hlášením „The cumulative Differences conflict with the committed revision". Nová je jen zbývající přípona a jen ta skončí v AddedBytes

Pipeline kontrol SaveFDFDifferencesToFile v HotPDF pro Delphi: délka a SHA-256 základu, srovnání kumulativního prefixu, validace dočasného kandidáta, identita /ID a podpisů, bity oprávnění plus vpuštění DocMDP a FieldMDP a pak atomická publikace připojené revize
Šest zábran, které selhávají zavřené, běží v pevném pořadí, než se publikuje jediný bajt připojené revize, takže poškozené FDF nebo nepovolená změna nechá načtený dokument i stávající výstup netknuté

Složený kandidát se zapíše do exkluzivního dočasného souboru v cílovém adresáři, propláchne, znovu otevře jako nezávislý zdroj revize a zvaliduje, než nahradí výstup. Chyby parsování, zrušení, politiky, rozpočtu ani publikace nenechají načtený dokument ani existující výstupní soubor poškozený. Dva předpoklady chytí lidi brzy: výstup nesmí být aliasem načteného zdroje a dokument nesmí mít neuložené úpravy, protože neprůhledný append by je tiše zahodil („Publish pending document edits before applying Differences")

Proč je opakovaná aplikace no-op na bajt přesně?

Opakovaná aplikace téhož kumulativního FDF je no-op, protože každý bajt jeho payloadu už je v souboru. Když se payload rovná dřívějším append bajtům přesně, hlásí SaveFDFDifferencesToFile NoOp = True a AddedBytes = 0 a publikovaný výstup je identický s aktuálním PDF. To je silnější garance než „append-only" a právě to dělá opakování po síťovém timeoutu nebo spadlém workeru bezpečným

// Rev 1 už je commitlá do contract-0042-r1.pdf; stejné FDF přijde dvakrát
// (Doc, Again, Fdf, Selection, BaseLength a BaseHash deklarované jako výše)
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,
  // a retry soubor je bajtově identický s contract-0042-r1.pdf
finally
  Doc.Free;
end;

Pište akceptační testy tak, aby tomu odpovídaly. Assertujte plnou bajtovou rovnost mezi retry výstupem a aktuální revizí, ne jen to, že soubor nezeslábl. Test, který kontroluje jen append-only chování, rád projde implementaci, která tutéž revizi připojí podruhé, což vytvoří validně vypadající PDF s duplikovanou update sekcí, kterou musí pozdější DocMDP audit vysvětlovat

Jaké vpuštěcí kontroly běží před publikací revize?

Před publikací musí kandidátské PDF projít kontrolami identity, podpisů a oprávnění a žádná z nich nevěří vnějším polím FDF. První prvek traileru /ID musí přežít beze změny a každé podpisové pole už přítomné v načteném dokumentu musí dál existovat s identickým podpisovým slovníkem a stejnou identitou hodnoty podpisu. Payload Differences, který podpis zahodí, nahradí nebo přepíše, selže s hdSignatureModificationUnsupported

Pak přijdou vrstvy autorizace. U neprázdného appendu na šifrovaném dokumentu se změněné objekty klasifikují podle role (katalog a stránky, anotace, AcroForm pole, appearance streamy) a kontrolují proti autentizovaným bitům uživatelských oprávnění, takže revize upravující anotace potřebuje anotační oprávnění a tak dále. Pak projde kandidát stejným vynucením certifikace a zámků polí, které HotPDF Component používá jinde: certifikační podpis DocMDP (ISO 32000-1 §12.8.2.2) omezuje, jaké druhy změn mohou pozdější revize udělat, a zámky FieldMDP (§12.8.2.4) chrání pojmenovaná pole. Analýza revizí DocMDP a FieldMDP v HotPDF popisuje, jak se ta oprávnění klasifikují po revizích

Zpracování chyb by mělo oddělit „tohle FDF je poškozené" od „tahle revize není povolená", protože druhý případ obvykle potřebuje člověka:

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);  // nesoulad základu, konflikt, špatný endpoint
    end;
end;

To, co operace nikdy nedělá, je stejně důležité jako to, co kontroluje. SaveFDFDifferencesToFile nikdy nenačte souborovou specifikaci FDF /F, takže PDF pojmenované uvnitř FDF nemůže přesměrovat merge na jiný soubor na disku nebo síti, a nikdy nespustí JavaScript ani akce. Každý vstup přijde explicitně: načtený základ, bajty FDF a trust materiál, který jste nakonfigurovali

Windows transakce a C ABI fdf.differences.apply

Stejný transport je dostupný i bez psaní headless kódu přímo. THPDFWindowsFormDataTransaction.ApplyFDFDifferences bere bajty FDF, délku základu, základní SHA-256, výběr, volby balíčku a heslo, deleguje na SaveFDFDifferencesToFile a přidává bajtové účtování transakce, rollback, zrušení a chování retry se stejným handlem. Pro volající mimo Delphi vystavuje C ABI JSON operaci fdf.differences.apply, kde base nese délku a hash:

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

JSON odpověď hlásí baseBytes, priorAppendBytes, totalAppendBytes, addedBytes a noop, ta samá pole jako THPDFHeadlessFDFDifferencesResult. Publikované akceptační brány tohoto transportu běží na Linux FPC 3.3.1, Windows Delphi 32bit i 64bit a Windows FPC 32bit i 64bit; každý bezpečnostní cíl pokrývá 21 případů s 1 244 nativními kontrolami a 382 příkazy callbacku

Limity a pasti fixture, které stojí za znalost

Differences transport je záměrně úzký a pár jeho hran už pokousal skutečné testovací sady:

  • Základ musí být kompletní PDF revize. Délka dopadající doprostřed souboru, nebo taková, jejíž startxref neukazuje na skutečná cross-reference data, se odmítne dřív, než se zkopíruje jediný bajt
  • Podpisové fixture potřebují indirect /Lock. Čtečka oprávnění načteného dokumentu ve Windows odmítá podpisové pole, jehož /Lock je inline slovník, hlášením „Signature field /Lock must be an indirect reference", i když některé podpisové cesty inline formu tolerují, takže zapište slovník zámku jako vlastní objekt
  • Testovací CA udělaná holým openssl req -x509 postrádá CA:TRUE a keyCertSign, takže validace řetězce selže chybou ověření certifikátu, která vypadá jako bug knihovny a není
  • Vnější hodnoty /Fields tohle volání neslučuje. Pokud je chcete i v dokumentu, rozhodněte se explicitně, zda jsou informativní, a nikdy je neprezentujte jako podepsané
  • Samotné FDF zůstává neinkrementální. Soubor FDF s více trailery, nenulovými generacemi nebo duplicitními čísly objektů je poškozené FDF, ne bohatší

Pro prostou hodnotovou stranu FDF a XFDF, bez podepsaných revizí ve hře, jsou lepšími starty round trip multi-select FDF a XFDF v HotPDF a import a export anotací XFDF v Delphi

FAQ

Můžu předat dekódovaný stream Differences místo souboru FDF?

Ne. SaveFDFDifferencesToFile parsuje FDF, které předáte, jako THPDFFDFPackage, aplikuje volitelný embedded výběr a volá DecodeDifferences interně. THPDFFDFPackage.DecodeDifferences použijte samostatně, když chcete syrové bajty revize prohlédnout nebo archivovat, ale volání apply podávejte soubor FDF

Co se stane, když klient pošle druhé, větší kumulativní FDF?

Načtěte PDF, které už obsahuje první commitlou revizi, a držte stejná BaseBytes a BaseSHA256. Metoda ověří, že nový payload začíná přesně bajty, které už jsou commitlé, připojí jen novou příponu a nahlásí ji v AddedBytes, pak pustí plné vpuštění oprávnění a DocMDP/FieldMDP na výsledku

Znamená úspěšný apply, že hodnoty polí v /Fields jsou podepsané?

Ne. ISO 32000-1 nevyžaduje, aby /Fields, /Annots a /Differences v jednom FDF souhlasily, a HotPDF Component je nesrovnává. Podepsáno je to, co pokrývají CMS podpisy v připojené revizi, takže podepsané hodnoty čtěte ze sloučeného PDF, ne z obalu FDF

Differences transport, headless engine podpisových politik i Windows transakce formulářových dat dodává HotPDF Component pro Delphi, C++Builder a Free Pascal