Artykuł techniczny

FDF Differences w Delphi: doklejanie podpisanych rewizji

HotPDF Component nakłada strumień FDF /Differences z Delphi przez THPDFHeadlessDocument.SaveFDFDifferencesToFile: metoda sprawdza, że wczytany PDF wciąż pasuje do rewizji bazowej, którą wskazujesz dokładną długością w bajtach i SHA-256, dokleja skumulowane bajty rewizji dokładnie raz, przepuszcza wynik przez uprawnienia szyfrowania i dopuszczenie DocMDP/FieldMDP i publikuje go atomowo. Powtórzenie tej samej submisji jest no-opem bajt w bajt

Proces, dla którego to istnieje, jest nieefektowny i pospolity. Wysyłasz umowę albo formularz kontroli jako PDF, ktoś go wypełnia i podpisuje we własnej przeglądarce, a to, co wraca, to nie PDF, tylko plik FDF. Wartości pól jadą w /Fields, ale podpisana rewizja sama, bajty, które podpis CMS faktycznie obejmuje, jedzie w /Differences. Pomyłka przy scaleniu albo psuje podpis, albo, gorzej, akceptuje wartości pól, których żaden podpis nigdy nie obejmował

Co naprawdę niesie strumień FDF /Differences?

Strumień FDF /Differences niesie surowe bajty aktualizacji przyrostowej dla docelowego PDF, a nie historię na poziomie FDF. ISO 32000-1 §12.7.7.3, tabela 243 definiuje wpis Differences słownika FDF jako przyrostowe zmiany w leżącym pod spodem PDF zrobione od jego otwarcia, w tej samej formie co aktualizacja przyrostowa PDF z §7.5.6: nowe i wymienione obiekty, sekcja odwołań krzyżowych i trailer, którego /Prev wskazuje z powrotem w plik bazowy

Samo pojemnik FDF jest odwrotnością. §12.7.7.1 zakazuje aktualizacji przyrostowych plików FDF, więc FDF ma pojedynczy trailer, wyłącznie obiekty generacji 0 i unikalne numery obiektów. HotPDF Component egzekwuje dokładnie to w swoim parserze FDF i nigdy tego nie luzuje, żeby ułatwić /Differences. Dane przyrostowe mieszkają wewnątrz jednego strumienia ściśle nieprzyrostowego pojemnika i rozdzielenie tych dwóch warstw to połowa roboty

Diagram anatomii FDF /Differences w HotPDF dla Delphi: ściśle nieprzyrostowy pojemnik FDF z pojedynczym trailerem i obiektami generacji 0 niesie jeden skumulowany strumień /Differences z surowych bajtów aktualizacji przyrostowej, podczas gdy /Fields i /Annots zostają niepodpisane i niescalone
Podpisana rewizja jedzie w strumieniu /Differences, nie w /Fields: sam pojemnik zakazuje aktualizacji przyrostowych, a ufna może być tylko ładunek bajtów, który obejmuje podpis CMS

Dwie własności strumienia kształtują każdą dalszą decyzję projektową:

  • Strumień jest skumulowany: trzyma każdy bajt doklejony od otwarcia docelowego PDF, więc druga submisja z tej samej sesji powtarza rewizję, którą już zapisałeś, i dodaje nową za nią
  • Zewnętrzne wpisy /Fields i /Annots tego samego FDF nie mają gwarancji zgody z /Differences, więc wartości pól, które łatwo odczytasz, to nie dane, które obejmuje podpis

Drugi punkt zasługuje na bezpośredniość. Jeśli twój kod importu czyta /V z /Fields i traktuje wynik jako podpisany, bo podpis przyszedł w tym samym pliku, to zbudowałeś kanał fałszerstwa. HotPDF Component trzyma ścieżki osobno celowo: importer adnotacji FDF odrzuca /Differences kategorycznie, a dotyka go tylko dedykowany transport Differences

Wiązanie doklejenia z jawną rewizją bazową

SaveFDFDifferencesToFile odmawia zgadywania, do której rewizji należy FDF. Wołający przekazuje BaseBytes, dokładną długość kompletnej rewizji PDF, z której formularz został otwarty, i BaseSHA256, jego 64-cyfrowy szesnastkowy SHA-256. Metoda haszuje ponownie ten prefiks wczytanego dokumentu, odrzuca niedopasowanie komunikatem "The loaded PDF does not match the explicitly selected base" i sprawdza, że prefiks kończy się na prawdziwym punkcie końcowym rewizji, którego startxref wskazuje na jego faktyczne dane odwołań krzyżowych

Praktyczna reguła: zapisuj obie liczby w chwili wysyłki formularza, nie gdy FDF wraca. Wystarczy mały pomocnik nad procedurami SHA256Init / SHA256Update / SHA256Final z HPDFCrypt:

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;

// W chwili wysyłki: cały wychodzący plik to rewizja bazowa
// BaseLength := rozmiar 'out\contract-0042.pdf' w bajtach
// BaseHash   := PrefixSHA256Hex('out\contract-0042.pdf', BaseLength);

Wczytany dokument nie musi być dokładnie bazą. Może być bazą plus rewizje, które już zapisałeś z wcześniejszych submisji, i tak skumulowany strumień pozostaje ogarnialny: BaseBytes dalej wskazuje pierwotny prefiks, a wszystko po nim jest traktowane jako wcześniejsze bajty doklejenia

Układ bajtów FDF Differences w HotPDF dla Delphi: wczytany PDF dzieli się na rewizję bazową weryfikowaną długością BaseBytes i BaseSHA256 plus już zapisane wcześniejsze bajty doklejenia, oraz skumulowany ładunek /Differences, który musi powtórzyć te bajty, zanim doda nowy sufiks
Prefiks bazowy jest przypięty długością i SHA-256, zapisane bajty działają jak kontrakt tylko-do-doklejania, a nowy jest tylko końcowy sufiks - powtórzona submisja ląduje wtedy jako no-op bajt w bajt

Nakładanie Differences przez SaveFDFDifferencesToFile

THPDFHeadlessDocument.SaveFDFDifferencesToFile przyjmuje nazwę pliku wyjściowego, bajty FDF, długość i hash bazy, opcjonalny wybór zagnieżdżonego FDF i rekord limitów THPDFFDFPackageOptions, a zwraca THPDFHeadlessFDFDifferencesResult. W środku otwiera FDF jako THPDFFDFPackage i samo wywołuje DecodeDifferences, więc przekazujesz plik FDF, a nie wcześniej zdekodowany strumień. Pusty Selection znaczy FDF najwyższego poziomu; niepusty schodzi najpierw do zagnieżdżonego FDF-dziecka

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 najwyższego poziomu, bez zagnieżdżonego dziecka
    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;

Kolejność sprawdzeń znaczy więcej niż samo wywołanie. Po przejściu hasza bazy metoda porównuje już zapisane bajty doklejenia, wszystko między BaseBytes a końcem wczytanego pliku, ze startem zdekodowanego ładunku. Submisja krótsza niż to, co już zapisałeś, zawodzi z "A cumulative submission cannot remove committed append bytes", a niezgodna z nim zawodzi z "The cumulative Differences conflict with the committed revision". Tylko pozostały sufiks jest nowy i tylko on ląduje w AddedBytes

Potok sprawdzeń SaveFDFDifferencesToFile w HotPDF dla Delphi: długość bazy i SHA-256, porównanie skumulowanego prefiksu, walidacja tymczasowego kandydata, tożsamość /ID i podpisów, bity uprawnień plus dopuszczenie DocMDP i FieldMDP, a potem atomowa publikacja doklejonej rewizji
Sześć bram fail-closed biegnie w stałej kolejności, zanim jakikolwiek bajt doklejonej rewizji zostanie opublikowany, więc zniekształcony FDF albo niedozwolona zmiana zostawiają wczytany dokument i istniejące wyjście nietknięte

Złożony kandydat jest zapisywany do ekskluzywnego pliku tymczasowego w katalogu docelowym, zrzucany na dysk, otwierany ponownie jako niezależne źródło rewizji i walidowany, zanim wymieni wyjście. Porażki parsowania, anulowania, polityki, budżetu albo publikacji zostawiają i wczytany dokument, i istniejący plik wyjściowy nietknięte. Dwa warunki wstępne łapią ludzi wcześnie: wyjście nie może aliasować wczytanego źródła, a dokument nie może mieć niezapisanych edycji, bo nieprzejrzyste doklejenie cicho je wyrzuciłoby ("Publish pending document edits before applying Differences")

Dlaczego powtórzone nakładanie jest no-opem bajt w bajt?

Powtórzone nakładanie tego samego skumulowanego FDF jest no-opem, bo każdy bajt jego ładunku jest już w pliku. Gdy ładunek równa się wcześniejszym bajtom doklejenia dokładnie, SaveFDFDifferencesToFile zgłasza NoOp = True i AddedBytes = 0, a publikowane wyjście jest identyczne z bieżącym PDF. To mocniejsza gwarancja niż "append-only" i to ona czyni ponowienia po timeoutie sieciowym albo padniętym workerze bezpiecznymi

// Rewizja 1 już zapisana do contract-0042-r1.pdf; ten sam FDF przychodzi dwa razy
// (Doc, Again, Fdf, Selection, BaseLength i BaseHash zadeklarowane jak wyżej)
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 plik ponowienia jest bajt w bajt identyczny z contract-0042-r1.pdf
finally
  Doc.Free;
end;

Pisz testy odbioru pod to. Asercjonuj pełną równość bajtów między wyjściem ponowienia a bieżącą rewizją, nie tylko to, że plik nie schudł. Test sprawdzający wyłącznie zachowanie append-only z radością przepuści implementację, która dokleja tę samą rewizję drugi raz, co produkuje wyglądający ważnie PDF ze zduplikowaną sekcją aktualizacji, którą późniejszy audyt DocMDP musi wyjaśnić

Jakie sprawdzenia dopuszczające biegną przed publikacją rewizji?

Przed publikacją kandydat PDF musi przejść sprawdzenia tożsamości, podpisów i uprawnień, i żadne z nich nie ufa zewnętrznym polom FDF. Pierwszy element trailera /ID musi przeżyć bez zmian, a każde pole podpisu obecne już w wczytanym dokumencie musi dalej istnieć z identycznym słownikiem podpisu i tą samą tożsamością wartości podpisu. Ładunek Differences, który gubi, wymienia albo przepisuje istniejący podpis, zawodzi z hdSignatureModificationUnsupported

Potem przychodzą warstwy autoryzacji. Dla niepustego doklejenia na zaszyfrowanym dokumencie zmienione obiekty są klasyfikowane rolą (katalog i strony, adnotacje, pola AcroForm, strumienie wyglądu) i sprawdzane względem uwierzytelnionych bitów uprawnień użytkownika, więc rewizja edytująca adnotacje potrzebuje uprawnienia do adnotacji i tak dalej. Potem kandydat przechodzi przez to samo egzekwowanie certyfikacji i blokad pól, jakiego HotPDF Component używa gdzie indziej: podpis certyfikacji DocMDP (ISO 32000-1 §12.8.2.2) ogranicza, jakie rodzaje zmian mogą robić późniejsze rewizje, a blokady FieldMDP (§12.8.2.4) chronią nazwane pola. Analiza rewizji DocMDP i FieldMDP w HotPDF opisuje, jak te uprawnienia są klasyfikowane per rewizja

Obsługa błędów powinna rozdzielać "ten FDF jest zniekształcony" od "ta rewizja jest niedozwolona", bo drugi przypadek zwykle potrzebuje człowieka:

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);  // niedopasowanie bazy, konflikt, zły punkt końcowy
    end;
end;

To, czego operacja nigdy nie robi, jest równie ważne jak to, co sprawdza. SaveFDFDifferencesToFile nigdy nie pobiera specyfikacji pliku FDF /F, więc PDF nazwany wewnątrz FDF nie może przekierować scalenia na inny plik na dysku albo w sieci, i nigdy nie wykonuje JavaScriptu ani akcji. Każde wejście przychodzi jawnie: wczytana baza, bajty FDF i materiał zaufania, który skonfigurowałeś

Transakcje Windows i C ABI fdf.differences.apply

Ten sam transport jest dostępny bez pisania kodu headless bezpośrednio. THPDFWindowsFormDataTransaction.ApplyFDFDifferences przyjmuje bajty FDF, długość bazy, SHA-256 bazy, wybór, opcje pakietu i hasło, deleguje do SaveFDFDifferencesToFile i dodaje księgowanie bajtów transakcji, rollback, anulowanie i zachowanie ponowienia na tym samym uchwycie. Dla wołających spoza Delphi C ABI wystawia operację JSON fdf.differences.apply, gdzie base niesie długość i hash:

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

Odpowiedź JSON zgłasza baseBytes, priorAppendBytes, totalAppendBytes, addedBytes i noop, te same pola co THPDFHeadlessFDFDifferencesResult. Opublikowane bramy odbioru dla tego transportu biegną na Linux FPC 3.3.1, Windows Delphi 32- i 64-bit oraz Windows FPC 32- i 64-bit; każdy cel bezpieczeństwa pokrywa 21 przypadków z 1244 natywnymi sprawdzeniami i 382 poleceniami callback

Limity i pułapki fixtur warte znajomości

Transport Differences jest celowo wąski, a kilka jego krawędzi ugryzło prawdziwe zestawy testów:

  • Baza musi być kompletną rewizją PDF. Długość, która ląduje w połowie pliku, albo której startxref nie wskazuje na jej faktyczne dane odwołań krzyżowych, jest odrzucana, zanim jakikolwiek bajt zostanie skopiowany
  • Fixtury podpisów potrzebują pośredniego /Lock. Czytnik uprawnień wczytanego dokumentu w Windows odrzuca pole podpisu, którego /Lock jest słownikiem inline, komunikatem "Signature field /Lock must be an indirect reference", choć niektóre ścieżki podpisywania tolerują formę inline, więc zapisuj słownik blokady jako osobny obiekt
  • Testowe CA zrobione gołym openssl req -x509 nie ma CA:TRUE ani keyCertSign, więc walidacja łańcucha zawodzi błędem weryfikacji certyfikatu, który wygląda jak błąd biblioteki i nim nie jest
  • Zewnętrzne wartości /Fields nie są scalane przez to wywołanie. Jeśli chcesz je też w dokumencie, zdecyduj jawnie, czy są informacyjne, i nigdy nie prezentuj ich jako podpisanych
  • Sam FDF zostaje nieprzyrostowy. Plik FDF z wieloma trailerami, niezerowymi generacjami albo zdublowanymi numerami obiektów to zniekształcony FDF, nie bogatszy

Dla zwykłej strony wartościowej FDF i XFDF, bez podpisanych rewizji w grze, lepszymi punktami startu są round trip multi-select FDF i XFDF w HotPDF i import i eksport adnotacji XFDF w Delphi

FAQ

Czy mogę przekazać zdekodowany strumień Differences zamiast pliku FDF?

Nie. SaveFDFDifferencesToFile parsuje przekazany FDF jako THPDFFDFPackage, nakłada opcjonalny wybór zagnieżdżony i wewnętrznie wywołuje DecodeDifferences. Używaj THPDFFDFPackage.DecodeDifferences na osobność, gdy chcesz obejrzeć albo zarchiwizować surowe bajty rewizji, ale plik FDF dawaj wywołaniu nakładającemu

Co się dzieje, gdy klient wysyła drugi, większy skumulowany FDF?

Wczytaj PDF, który już zawiera pierwszą zapisaną rewizję, i trzymaj te same BaseBytes i BaseSHA256. Metoda weryfikuje, że nowy ładunek zaczyna się dokładnie od bajtów już zapisanych, dokleja tylko nowy sufiks i zgłasza go w AddedBytes, a potem puszcza pełne dopuszczenie uprawnień i DocMDP/FieldMDP na wyniku

Czy udane nakładanie znaczy, że wartości pól w /Fields są podpisane?

Nie. ISO 32000-1 nie wymaga, żeby /Fields, /Annots i /Differences w jednym FDF się zgadzały, a HotPDF Component ich nie pojednaje. Podpisane jest to, co obejmują podpisy CMS w doklejonej rewizji, więc podpisane wartości czytaj ze scalonego PDF, a nie z opakowania FDF

Transport FDF Differences, silnik polityki podpisów headless i transakcje formularzy Windows wszystkie są w HotPDF Component dla Delphi, C++Builder i Free Pascal