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
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
/Fieldsi/Annotstego 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
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
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
startxrefnie 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/Lockjest 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 -x509nie maCA:TRUEanikeyCertSign, 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
/Fieldsnie 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