Artykuł techniczny

Koniec rewizji PDF: podrobione aktualizacje po podpisaniu

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ł

Mapa rewizji PDFlibPas w Delphi idąca za łańcuchem /Prev i zestawiająca offsety startxref, by znajdować granice rewizji aktualizacji przyrostowych, ignorując zbłąkane znaczniki %%EOF wewnątrz strumieni i trailerów pierwszej strony plików zlinearyzowanych
Granice rewizji oznaczają wyłącznie offsety startxref, które łańcuch /Prev faktycznie odwiedził, więc zbłąkane bajty %%EOF wewnątrz strumieni nie mają nic do gadania o tym, gdzie PDFlibPas tnie plik

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:

RodzajZnaczenieTypowe źródło
0Tylko housekeeping: obiekty, które nic nie zmieniają w tym, co widzi czytnikStrumień cross-reference, nowy pojemnik object stream
1Materiał walidacyjnyDocument security store, VRI, certyfikaty, CRL-i, odpowiedzi OCSP
2Znacznik czasu dokumentuPodpis /DocTimeStamp i pole, które go niesie
3PodpisIstniejące pole podpisu otrzymujące swoją wartość
4Pole formularzaWartość pola albo jego appearance
5AdnotacjaKażda adnotacja inna niż widget
6Zawartość dokumentuWszystko, co może zmienić to, co dokument pokazuje
7Nie 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ł:

GetDocumentRevisionKind w PDFlibPas zgłasza podrobioną aktualizację /Type /DSS przepisującą strumień zawartości strony jako zawartość dokumentu, bo mapa rewizji klasyfikuje przepisany obiekt po jego wcześniejszej wersji
Naklejenie na przepisany strumień zawartości strony etykiety /Type /DSS nic nie zmienia dla renderera, a PDFlibPas nadal zgłasza aktualizację jako zawartość dokumentu, bo obiekt był zawartością strony i wcześniej
  • 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 /Root wskazuje 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 /Length albo 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ą

Audyt podpisu w stylu DocMDP z PDFlibPas w Delphi używający sufitów GetDocumentRevisionKind dla P 1, P 2 i P 3, z GetDocumentRevisionsComplete i GetSignatureRevisionIndex sprawdzanymi najpierw
Każdy poziom DocMDP staje się sufitem dla rodzaju rewizji: P 1 toleruje rodzaje 0 do 2, P 2 do 4, P 3 do 5, a rewizja niedookreślona obala każdy poziom

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ą