Kontrakt zostaje podpisany, dostaje potem jeszcze jedną aktualizację, a skrypt audytowy wtapia tę aktualizację w podpisaną rewizję albo przepuszcza przepisaną stronę jako materiał walidacyjny. Nic z tego nie stanie się, skoro rewizja kończy się tam, gdzie startxref wskazuje sekcję, do której doszedł łańcuch /Prev, a przepisany obiekt zachowuje swoją wcześniejszą klasę — dwie reguły, na których stoi mapa rewizji PDFlibPas
Dlaczego %%EOF to złe miejsce na cięcie pliku
Każda aktualizacja przyrostowa kończy się własnym znacznikiem końca pliku (ISO 32000-1 §7.5.6), więc cięcie pliku przy każdym %%EOF wygląda na oczywisty sposób szukania rewizji. Tyle że znacznik to ledwie pięć bajtów i nic nie przeszkadza tym bajtom wylądować wewnątrz strumienia zawartości, osadzonego pliku albo programu fontowego. Pliki zlinearyzowane dokładają drugi problem: Załącznik F (§F.3.4) pozwala trailerowi pierwszej strony kończyć się własnym startxref i %%EOF blisko początku pliku, dobrze przed główną tablicą cross-reference. Cięcie przy każdym znaczniku wymyśla rewizje, których żadna sekcja cross-reference nie podpiera. Zgadywanie, które znaczniki są fałszywe, jest jeszcze gorsze — ta zgadywanka to dokładnie to, co fałszerz może uformować. Mapa rewizji PDFlibPas działa odwrotnie. Idzie za łańcuchem /Prev, zapisuje offset bajtowy każdej sekcji cross-reference, którą łańcuch faktycznie odwiedził, i dopiero potem skanuje sekwencje startxref N … %%EOF, zostawiając granicę tylko wtedy, gdy N jest jednym z tych zapisanych offsetów. Zbłąkany znacznik wewnątrz strumienia musiałby mieć przed sobą startxref wskazujący prawdziwą sekcję, żeby w ogóle cokolwiek znaczył
Bajty między tymi trzema tokenami są mniej schludne, niż sugeruje §7.5.5. Standard rysuje startxref, offset i znacznik na kolejnych wierszach, a jednak znaczniki końca wiersza i pozostałe białe znaki z §7.2.2 (tabela 1) mogą się powtarzać i żadna reguła nie ogranicza ich liczby. Przeocz jeden wypchany znacznik, a dwie rewizje zleją się w jedną. Podpisana odtąd wygląda, jakby kończyła się po bajtach, których nikt nie podpisał. Mapa rewizji PDFlibPas biegnie od słowa kluczowego do operandu do znacznika, niezależnie od tego, ile bieli je dzieli i gdzie ten wypełniacz w pliku wypada, i liczy każde słowo kluczowe dokładnie raz. Słowo kluczowe trailer ma podobnie poluzowany staw: << jest separatorem (§7.2.2, tabela 2), a §7.5.5 nigdy nie wymaga łamania wiersza po trailer, więc trailer<< w jednym wierszu to składnia zgodna ze standardem. Czytany wiersz po wierszu taki wiersz wygląda jak nagłówek podsekcji cross-reference — i łańcuch staje w połowie drogi
Chodzenie po mapie rewizji z PDFlibPas
Akcesory rewizji są zadeklarowane na TPDFlib i zaimplementowane w wydaniu Small Business: utwórz TPDFlibSmallBusiness z unitu PDFlibSmallBusiness (Enterprise po nim dziedziczy i niesie te same wołania). Na gołym TPDFlib zwracają wartość niepowodzenia (0, 7 albo -1) i ustawiają LastErrorCode na PDFLIB_ERROR_SMALLBUSINESS_REQUIRED. Rewizje są numerowane od 0, czyli od oryginalnego dokumentu. Każde wołanie bierze nazwę pliku i hasło i buduje mapę z bajtów leżących na dysku, więc odpowiedzi opisują plik taki, jaki jest zapisany, nigdy dokument, który akurat masz wczytany i zedytowany. GetDocumentRevisionKind zgłasza najdalej idącą zmianę w rewizji. Aktualizacja, która dodaje odpowiedź OCSP, a przy okazji przepisuje stronę, zostaje zgłoszona jako przepisanie strony:
| Rodzaj | Znaczenie | Typowe źródło |
|---|---|---|
| 0 | Tylko housekeeping: obiekty, które nic nie zmieniają w tym, co widzi czytnik | Strumień cross-reference, nowy pojemnik object stream |
| 1 | Materiał walidacyjny | Document security store, VRI, certyfikaty, CRL-i, odpowiedzi OCSP |
| 2 | Znacznik czasu dokumentu | Podpis /DocTimeStamp i pole, które go niesie |
| 3 | Podpis | Istniejące pole podpisu otrzymujące swoją wartość |
| 4 | Pole formularza | Wartość pola albo jego appearance |
| 5 | Adnotacja | Każda adnotacja inna niż widget |
| 6 | Zawartość dokumentu | Wszystko, co może zmienić to, co dokument pokazuje |
| 7 | Nie dało się ustalić | Czegoś w rewizji nie dało się odczytać; traktuj ją jako niebezpieczną |
uses
System.SysUtils, PDFlibSmallBusiness;
procedure ListRevisions(const FileName, Password: WideString);
const
KindNames: array[0..7] of string = ('housekeeping', 'validation material',
'document time stamp', 'signature', 'form field', 'annotation',
'content', 'undetermined');
var
Pdf: TPDFlibSmallBusiness;
Count, I, J, Kind, Changes: Integer;
begin
Pdf := TPDFlibSmallBusiness.Create;
try
Count := Pdf.GetDocumentRevisionCount(FileName, Password);
if Count = 0 then
raise Exception.Create('the cross-reference chain could not be followed');
if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
Writeln('WARNING: history incomplete, every figure below is a lower bound');
for I := 0 to Count - 1 do
begin
Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
if (Kind < 0) or (Kind > 7) then
Kind := 7;
Writeln(Format('revision %d: bytes %d..%d, %s', [I,
Pdf.GetDocumentRevisionStart(FileName, Password, I),
Pdf.GetDocumentRevisionEnd(FileName, Password, I),
KindNames[Kind]]));
// jeden wiersz 'objnum,change,class,detail' na obiekt
Changes := Pdf.GetDocumentRevisionChanges(FileName, Password, I);
if Changes <> 0 then
try
for J := 1 to Pdf.GetStringListCount(Changes) do // elementy liczone od 1
Writeln(' ' + Pdf.GetStringListItem(Changes, J));
finally
Pdf.ReleaseStringList(Changes);
end;
end;
finally
Pdf.Free;
end;
end;
GetDocumentRevisionsComplete to wołanie, które czyta się, zanim zaufa czemukolwiek innemu. Zwraca 1 tylko wtedy, gdy łańcuch został prześledzony aż do oryginalnej sekcji; zerwany albo cykliczny /Prev, czy łańcuch głębszy niż 256 ogniw, zostawia najstarsze aktualizacje niewidziane, a każdy licznik i indeks staje się wówczas dolnym ograniczeniem. Głębokość 256 zostawia miejsce na historię zapis-i-podpis prawdziwych plików, a łańcuch wskazujący sam na siebie i tak kończy się niemal od razu. Patrz na indeksowanie w pętli. GetStringListItem liczy od 1, podczas gdy rewizje liczą się od 0, i pętla wewnętrzna startująca od 0 wycina pierwszy zmieniony obiekt każdej rewizji bez żadnego błędu
Jak podrobiona aktualizacja podszywa się pod materiał walidacyjny
Długoterminowa walidacja PAdES słusznie dokleja aktualizacje po ostatnim podpisie: document security store (/Type /DSS), słowniki /VRI per podpis, strumienie certyfikatów i unieważnień, znaczniki czasu dokumentu. Poziomy certyfikacji je przepuszczają, co czyni materiał walidacyjny najcenniejszą etykietą, jaką aktualizacja może nosić. Jest też najtańsza do podrobienia. /Type jest taki, jaki aktualizacja wybrała sobie do zapisania. Przeglądarka rysująca stronę nigdy nie zagląda w /Type strumienia zawartości, słownika zasobów ani fontu, więc aktualizacja może przepisać strumień zawartości strony 3, dodać do jego słownika /Type /DSS, a każdy renderer nadal pomaluje nowy tekst, podczas gdy klasyfikator wierzący w /Type widzi housekeeping. /VRI, /Sig i /DocTimeStamp działają tak samo
Mapa rewizji PDFlibPas ignoruje etykietę, którą aktualizacja pisze na przepisanym przez siebie obiekcie. Przepisany obiekt liczy się jako materiał walidacyjny, znacznik czasu albo podpis tylko wtedy, gdy wcześniejsza wersja tego samego obiektu już jednym z nich była. Strumienie certyfikatów, CRL-i i odpowiedzi OCSP nie niosą własnego typu, więc rozpoznaje się je po document security store, który na nie wskazuje — i tylko store zapisany w ocenianej rewizji albo starszej się liczy. Store dodany później nie może sięgnąć wstecz i poręczyć obiektu, który napisała podpisana rewizja. Zmiany w strukturze dokumentu liczą się jako zawartość, ilekroć zmieniają to, co czytnik by rozwiązał:
- wymiana istniejącego object stream, bo każdy skompresowany obiekt wewnątrz rozwiązuje się teraz na wymianę (zupełnie nowy pojemnik pozostaje housekeepingiem, bo każdy jego człon dostaje własny wpis i tam jest oceniany)
- dodanie katalogu albo strony
- trailer, którego
/Rootwskazuje inny katalog, nawet gdy aktualizacja nic z tego, co objął podpis, nie przedefiniowuje, bo podmiana korzenia wymienia cały dokument, nie dotykając ani jednego podpisanego obiektu - przepisany strumień, którego słownik jest bajt w bajt identyczny ze starym: pośredni
/Lengthalbo treść zamiennika wypchana do tego samego rozmiaru zostawia słownik bez zmian, podczas gdy rysunek się zmienia, więc na tym dowodzie tylko obiekty bez treści strumienia traktuje się jako niezmienione
Członkowie object stream nie mają nagłówka do ścięcia
Od PDF 1.5 aktualizacja może przechowywać przepisane obiekty wewnątrz object stream, a §7.5.7 mówi o formacie bez owijania: przechowywane są wyłącznie wartości obiektów, a słowa kluczowe obj i endobj nie mogą być używane. Kod, który czyta obiekt z jego lokalizacji i zdejmuje nagłówek przez szukanie napisu obj, nie znajduje więc nic na czele członka i szuka dalej, wchodząc w jego treść. Podaj słownikowi strony przepisanemu po podpisaniu niewinnie wyglądający napis typu (obj<</Type/DSS>>), a to szukanie zatrzyma się wewnątrz napisu. Dekoder dostaje <</Type/DSS>> — słownik store, którego ta rewizja nigdy nie pisała. Mapa rewizji PDFlibPas parsuje N G obj z czoła nieskompresowanego obiektu, sprawdza, czy liczby nazywają obiekt, którego się spodziewała, i traktuje brakujący albo niespójny nagłówek jako nieczytelny, zamiast polować dalej
Strumienie cross-reference niosą własną niejednoznaczność na poziomie pól. Według §7.5.8.3 (tabela 18) tylko wpis typu 1 trzyma offset bajtowy. Drugie pole wpisu typu 2 to numer obiektu zawierającego go object stream, a trzecie to indeks wewnątrz niego, podczas gdy drugie pole wpisu typu 0 to następny wolny numer obiektu. Odczytaj ten numer obiektu jako offset, a skompresowany wpis wyląduje w dowolnej rewizji rozpiętej na te małe pozycje bajtowe — niemal zawsze w oryginalnym dokumencie, który ukrywa przepisanie wewnątrz podpisanych bajtów. Mapa rewizji umieszcza każdy wpis według sekcji cross-reference, która go zadeklarowała. Ta sama klauzula mówi, że typ wpisu inny niż 0, 1 albo 2 to odwołanie do obiektu null, więc takie wpisy nic nie dodają i niczego nie zwalniają i pozostają poza mapą
Kiedy audyt podpisu powinien odmówić odpowiedzi?
Ilekroć plik przetrzymuje część własnej historii. Mapa rewizji zgłasza to na trzy sposoby: GetDocumentRevisionsComplete zwraca 0, GetDocumentRevisionKind zwraca 7 dla jakiejś rewizji po podpisie, albo GetSignatureRevisionIndex zwraca -1. Ta ostatnia wartość obejmuje ByteRange, który nie kończy się na granicy rewizji — bajty siedzą wewnątrz zakresu, który podpis deklaruje, że obejmuje — i równo też pole, które nie istnieje, nie niesie wartości podpisu albo siedzi w pliku, którego nie dało się odczytać. Wyślij wszystkie trzy do ręcznego przeglądu z dołączonym indeksem rewizji i wierszami GetDocumentRevisionChanges, żeby ten, kto otworzy plik, zaczął od numerów obiektów
// Sprawdza aktualizacje po danym podpisie wobec polityki w stylu DocMDP.
// Rodzaje są uporządkowane wg ciężkości, więc każdy poziom polityki to sufit:
// P 1 toleruje housekeeping, materiał walidacyjny i znaczniki czasu (0..2),
// P 2 dokłada podpisywanie i wypełnianie formularzy (..4), P 3 dokłada adnotacje (..5).
function UpdatesWithinPolicy(Pdf: TPDFlibSmallBusiness;
const FileName, Password, FieldName: WideString; Level: Integer): Boolean;
const
Ceiling: array[1..3] of Integer = (2, 4, 5);
var
Signed, Count, I, Kind: Integer;
begin
Result := False;
if (Level < 1) or (Level > 3) then
Exit;
if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
Exit; // część historii nigdy nie została ujrzana
Signed := Pdf.GetSignatureRevisionIndex(FileName, Password, FieldName);
if Signed < 0 then
Exit; // poza granicą rewizji, brak takiego podpisanego
// pola albo plik nieczytelny
Count := Pdf.GetDocumentRevisionCount(FileName, Password);
for I := Signed + 1 to Count - 1 do
begin
Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
if (Kind = 7) or (Kind > Ceiling[Level]) then
Exit; // 7 jest ponad każdym sufitem; zapisane dla czytającego
end;
Result := True;
end;
Sufity idą za ISO 32000-2 §12.8.2.2 (tabela 257): P 1 nie pozwala na żadną zmianę, 2 pozwala na wypełnianie formularzy i podpisywanie, 3 dokłada adnotacje, a aktualizacje przyrostowe dodające wyłącznie dane DSS albo znaczniki czasu dokumentu nie liczą się jako zmiany na żadnym poziomie. ISO 32000-1 §12.8.2.2 definiuje te same trzy wartości bez tego zwolnienia. Pętlę napisz sam, gdy polityka jest twoja, a nie certyfikatora — na przykład reguła przyjęcia odrzucająca zadnotowane kontrakty niezależnie od tego, jakie P niesie plik. Gdy polityka ma przyjść z pliku, GetSignatureModificationVerdict na tej samej klasie czyta poziom certyfikacji, aplikuje blokady per pole opisane w wykrywaniu edycji pól formularza zaryglowanych FieldMDP i zwraca 5 dla przypadku poza granicą. Oba odpowiadają na inne pytanie niż klasyfikowanie zmian wobec kopii dokumentu, którą zachowałeś: mapie rewizji wystarczy plik leżący przed nią
Doklejanie idzie za tym samym łańcuchem. Trailer aktualizacji przyrostowej musi nieść wpis /Prev podający położenie poprzedniej sekcji cross-reference (§7.5.6), a dokument, który dałoby się otworzyć tylko przez odbudowę tablicy cross-reference z pełnego skanu, nie ma takiego położenia do podania. AppendToFile zwraca dla niego 0 i zostawia plik docelowy nietknięty. Zapisz go w całości i podpisz czystą kopię — pełne przepisanie nie przenosi istniejących podpisów
Dla archiwum podpisanych kontraktów odpal UpdatesWithinPolicy raz na pole podpisu, zanim cokolwiek nowego zostanie doklejone. Plik, którego późniejsza rewizja czyta się jako rodzaj 7, powinien najpierw trafić na człowieka, przed każdym krokiem podpisywania albo długoterminowej walidacji PDFlibPas, który zakopałby nieodczytaną rewizję pod kolejną aktualizacją