Artykuł techniczny

Podpis w zaszyfrowanym PDF: wartość /Contents w cleartext

Kontrakt zaszyfrowany AES, podpisany gdzie indziej, wczytuje się z wartością podpisu, która dekoduje się na losowe bajty, a w wariancie AES-GCM — ze słownikiem podpisu, którego nie da się odszyfrować. Poza trailerowym /ID i słownikiem szyfrowania ISO 32000-2 §7.6.2 zostawia niezaszyfrowany wyłącznie podpisowy /Contents, i PDFlibPas trzyma go jawnie przy odczycie i zapisie, podczas gdy /Reason i /M pozostają zaszyfrowane

Wyjątek, który ISO 32000-2 dodało do reguł szyfrowania

ISO 32000-2 §7.6.2 mówi, że szyfrowanie obejmuje każdy napis i każdy strumień w pliku, z czterema wyjątkami:

  • wartości wpisu /ID w trailerze
  • napisy wewnątrz słownika /Encrypt
  • napisy wewnątrz strumieni, które są szyfrowane w całości
  • każdy napis szesnastkowy będący wartością /Contents w słowniku podpisu

Pierwsze trzy są stare. ISO 32000-1 §7.6.1 wymienia tylko te trzy, więc odszyfrowywacz pisany pod tekst z 2008 roku nie ma żadnego polecenia pomijania czegokolwiek w słowniku podpisu. Narzędzia podpisujące i tak zapisywały wartość jawnie. Powód jest mechaniczny — podpisujący rezerwuje heksowy placeholder, zapisuje cały plik, haszuje wszystko poza placeholderem, buduje CMS i wtapia jego heks w lukę. Ten digest obejmuje bajty takimi, jakie leżą na dysku, ciphertext włącznie. Zaszyfrowanie wartości podpisu nic by nie chroniło, a zmusiłoby krok wtapywania, by znał klucz pliku i wcisnął IV wraz z paddingiem w placeholder wymiarowany, zanim hash w ogóle powstał. Reszta słownika podlega normalnym regułom:

Wpis słownika podpisuTyp PDFZapisywany zaszyfrowany?
/Contentsnapis szesnastkowyNie, zwolniony na mocy ISO 32000-2 §7.6.2
/Reason, /Location, /ContactInfo, /Namenapis tekstowyTak
/Mnapis datyTak
/ByteRangetablica liczb całkowitychNie, liczb całkowitych się nie szyfruje
/Type, /Filter, /SubFilternazwaNie, nazw się nie szyfruje

Czytnik, który przeoczy ten wyjątek, puszcza szyfr przez goły DER. To, co wyjdzie, zależy od algorytmu. RC4 to szyfr strumieniowy, więc wynik zachowuje długość i zamienia się w szum; parser CMS widzi pierwszy bajt, który już nie jest 0x30, i zgłasza uszkodzony podpis. AES-CBC bierze pierwsze 16 bajtów za wektor inicjalizujący, a resztę za ciphertext, co kończy się błędem paddingu albo krótszą bryłą śmieci. AES-GCM, zdefiniowany dla PDF w ISO/TS 32003, to przypadek najbrutalniejszy. Każdy zaszyfrowany napis niesie własny nonce i tag uwierzytelniający, a wartość jawna nie przejdzie kontroli tagu. Odszyfrowywacz, który jednego nieudanego tagu traktuje jako fatalny dla całego słownika, traci przez to /Reason i /M razem z wartością podpisu

Słownik podpisu zaszyfrowanego PDF w Delphi z PDFlibPas: szesnastkowy /Contents zostaje jawny na mocy ISO 32000-2 §7.6.2, podczas gdy /Reason i /M pozostają zaszyfrowane, plus tryby awarii RC4, AES-CBC i AES-GCM
Podpisowy /Contents pozostaje czytelnym DER w zaszyfrowanym PDF, podczas gdy /Reason i /M to ciphertext, a odszyfrowanie tego DER i tak zawodzi inaczej pod każdym szyfrem

Jak odróżnić podpisowy /Contents od każdego innego /Contents?

Dopasowanie po nazwie klucza to oczywisty pierwszy ruch. Wywraca się w obie strony. Słowniki adnotacji też mają wpis /Contents — napis tekstowy z komentarzem, który wpisał recenzent — i ten napis musi być zaszyfrowany. Pomiń go, a każda żółta karteczka w poufnym pliku jest czytelna bez hasła. Dopasowanie po /Type /Sig wywraca się w drugą stronę. ISO 32000-2 w tabeli 255 czyni /Type opcjonalnym w słowniku podpisu, z Sig jako wartością domyślną, i wymaga go tylko dla /DocTimeStamp, więc zgodny producent może go pominąć. PDFlibPas traktuje więc napis /Contents jako wartość podpisu tylko wtedy, gdy ten sam słownik niesie tablicę /ByteRange plus albo /Type równe Sig lub DocTimeStamp, albo w ogóle brak /Type obok nazwy /Filter. /Filter jest wymagany w każdym słowniku podpisu, a /ByteRange w każdym podpisie należącym do pola podpisu; adnotacja nie ma ani jednego, ani drugiego. Biblioteka robi ten sam test, gdy szyfruje przy zapisie i gdy odszyfrowuje przy wczytaniu, więc /Contents wychodzi z wczytania z bajtami, które zapisał producent

Test szyfrowania PDFlibPas w Delphi odróżniający podpisowy /Contents od adnotacyjnego /Contents po /ByteRange, /Type Sig lub DocTimeStamp i /Filter, zanim napis zostanie zaszyfrowany albo odszyfrowany
Komentarze adnotacji pozostają zaszyfrowane, bo PDFlibPas traktuje /Contents jako wartość podpisu tylko obok /ByteRange i pasującego /Type albo nazwy /Filter, gdy /Type nie występuje

Najpierw szyfruj, potem podpisuj z PDFlibPas

Podpisuj na końcu. Digest podpisu obejmuje zapisane bajty, więc każda operacja, która przepisuje plik po podpisaniu, łamie każdy podpis w nim zawarty. Nałożenie szyfrowania na podpisany dokument zaszyfrowuje od nowa każdy napis i strumień; zdjęcie szyfrowania przepisuje je równie gruntownie. Sekwencja, która działa, to: zaszyfruj, zapisz, a potem podpisz zaszyfrowany plik. Proces podpisu dokłada aktualizację przyrostową, której nowe napisy są szyfrowane istniejącym kluczem pliku, a której placeholder /Contents zostaje jawny. Otwórz plik w procesie podpisu hasłem owner; hasło user wystarczy tylko wtedy, gdy uprawnienia obejmują bit 9 tabeli 22 ISO 32000-2, który wymienia pola podpisu wprost. Encrypt i proces podpisu są dostępne w każdym wydaniu PDFlibPas

uses
  SysUtils, PDFlibrary;

const
  OwnerPwd = 'owner-secret';
  UserPwd  = 'reader-secret';

procedure EncryptCopy(const Source, Target: string);
var
  Lib: TPDFlib;
  Perms: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile(Source, '') <> 1 then
      raise Exception.CreateFmt('Load failed (%d)', [Lib.LastErrorCode]);
    // druk, wypełnianie pól (bit 9 obejmuje pola podpisu), kopia dla dostępności, druk high-res
    Perms := Lib.EncodePermissions(1, 0, 0, 0, 1, 1, 0, 1);
    // Siła 4 = AES-256 revision 6: klucz pliku używany wprost, /ID nie jest jego częścią
    if Lib.Encrypt(OwnerPwd, UserPwd, 4, Perms) <> 1 then
      raise Exception.CreateFmt('Encrypt failed (%d)', [Lib.LastErrorCode]);
    if Lib.SaveToFile(Target) <> 1 then
      raise Exception.CreateFmt('Save failed (%d)', [Lib.LastErrorCode]);
  finally
    Lib.Free;
  end;
end;

procedure SignField(Lib: TPDFlib; const Source, Target, FieldName,
  PFXFile, PFXPassword: string);
var
  SP, R: Integer;
begin
  SP := Lib.NewSignProcessFromFile(Source, OwnerPwd);
  if SP = 0 then
    raise Exception.CreateFmt('Input not found: %s', [Source]);
  try
    Lib.SetSignProcessField(SP, FieldName);   // tworzony niewidocznie, jeśli brakuje
    Lib.SetSignProcessPFXFromFile(SP, PFXFile, PFXPassword);
    Lib.SetSignProcessInfo(SP, 'Approved for release', 'Head office', '');
    // zapas w placeholdrze na znacznik czasu podpisu dodawany później
    Lib.SetSignProcessReserveContentsBytes(SP, 16384);
    Lib.EndSignProcessToFile(SP, Target);     // zawsze zwraca 0
    R := Lib.GetSignProcessResult(SP);
    if R <> 1 then
      raise Exception.CreateFmt('Signing %s failed (result %d)', [FieldName, R]);
  finally
    Lib.ReleaseSignProcess(SP);
  end;
end;

Siła 4 wybiera AES-256 revision 6, i ten wybór usuwa dwa tryby awarii. Pod RC4 i AES-128 Algorithm 1 (ISO 32000-1 §7.6.2) wyprowadza osobny klucz dla każdego obiektu z jego numeru obiektu i numeru generacji. Algorithm 2 (ISO 32000-1 §7.6.3.3, krok e) dodatkowo wtryca pierwszy element /ID do klucza pliku. Aktualizacja podpisująca przepisuje słownik AcroForm i tablicę /Annots strony, więc każdy przepisany obiekt musi zachować swój oryginalny numer generacji, a trailer aktualizacji musi nieść ten sam pierwszy element /ID. Pomyłka w generacji i ten obiekt przestaje się odszyfrowywać. Pomyłka w /ID i nie odszyfrowuje się już nic. Revision 6 używa klucza pliku wprost (ISO 32000-2 §7.6.3.3, Algorithm 1.A) i odzyskuje go bez zaglądania w /ID (§7.6.4.3.3, Algorithm 2.A). PDFlibPas zachowuje numery generacji na przepisywanych obiektach przy każdej sile i wyprowadza klucz z /ID takim, jaki jest zapisany, nawet gdy uszkodzony plik niesie /ID 7 0 R zamiast bezpośredniej tablicy, której wymaga tabela 15 ISO 32000-2; puste /ID [] obala kontrolę hasła. Ścieżkę hasło-klucz prześledzono głębiej obok otwierania zaszyfrowanych PDF kluczem surowego pliku

Zwykły edytor tekstu wystarczy, by zobaczyć wynik. Słownik podpisu siedzi nieskompresowany w aktualizacji, która go dodała, bo podpisujący wtapia /ByteRange i /Contents po offsetcie plikowym. Poszukaj /ByteRange i wylądujesz na nim. Heks /Contents zwykle otwiera się 3082 — nagłówkiem DER sekwencji CMS SignedData — i kończy długim ciągiem zer tam, gdzie placeholder był większy niż podpis. /Reason to ciphertext, który zmienia się z jednego przebiegu podpisywania na następny, nawet gdy tekst się nie zmienia, bo AES losuje świeży IV dla każdego napisu. Oczekiwane przy świeżym qpdf: qpdf --password=owner-secret --show-object=14 signed.pdf wypisuje odszyfrowane /Reason obok niezmienionego /Contents. Do tej kontroli weź plik o sile 4. Oczekiwany kształt w surowym pliku, przy zmiennych offsetach i bajtach:

14 0 obj
<</Type /Sig /Filter /Adobe.PPKLite
  /ByteRange [0 48211 81093 2304]
  /Contents <308240A206092A864886F70D010702A0...000000000000>
  /Reason <7F21C9E04B...>
  /M <D35A0F98...>
  ...>>
endobj

Które podpisy pękają, gdy wtapiamy znacznik czasu?

Każdy podpis dodany po tym znacznikowanym. Znacznik czasu podpisu PAdES to token RFC 3161 przechowywany jako atrybut unsigned podpisującego (RFC 5652 §5.3, unsignedAttrs), więc mieszka wewnątrz CMS-a w /Contents. Dodanie go oznacza przepisanie tego heksu w miejscu. W zaszyfrowanym pliku to przepisanie działa tylko dlatego, że /Contents jest jawny: bez ponownego szyfrowania, bez nowego obiektu — token musi się tylko zmieścić w miejscu zarezerwowanym w chwili podpisywania. Podpis właścicielski przeżywa, bo jego /ByteRange omija te bajty. Późniejsze podpisy takiego zwolnienia nie dostają. ISO 32000-2 §12.8.1 każe zakresowi bajtów każdego podpisu biec od nagłówka %PDF- do końca aktualizacji przyrostowej, która go dodała. Drugi podpis albo znacznik czasu dokumentu haszuje więc /Contents pierwszego podpisu razem z całą resztą. Luka należy do jednego podpisu. Wtapianie jej po tym, jak drugi podpisujący złożył już swój, sprawia, że digest drugiego podpisu przestaje się zgadzać. Samo żądanie tokena jest częścią podpisywania i walidacji PAdES z Delphi; ta sekcja mówi wyłącznie o tym, które bajty się ruszają

PDFlibPas sprawdza nakładanie, zanim cokolwiek zhaszuje. Proces znacznika czasu podpisu czyta /ByteRange każdego innego podpisu w dokumencie i zestawia każdą parę spanów z luką /Contents celu. Jakiekolwiek nakładanie zatrzymuje przygotowanie z wynikiem procesu 17, a żaden plik wyjściowy nie powstaje. Proces należy do wydania Enterprise, który instancjonujesz jako TPDFlibEnterprise z unitu PDFlibEnterprise; inne wydania zachowują te metody, które zwracają 0 i ustawiają LastErrorCode na 900. Patrz na liczbę 17 w logach. Z GetPAdESSignatureTimeStampProcessResult znaczy, że późniejszy podpis przykrywa ten; z GetSignProcessResult znaczy, że poziom certyfikacji albo blokada pola zakazuje podpisywania. Mapuj te dwa przypadki osobno. Pomocnik poniżej używa SignField i OwnerPwd z poprzedniego listingu

Kolejność znacznika czasu podpisu PAdES w Delphi z PDFlibPas: późniejszy signature's /ByteRange przykrywający wcześniejszą lukę /Contents sprawia, że GetPAdESSignatureTimeStampProcessResult zwraca 17 i żaden plik nie powstaje
Gdy Approver złoży już podpis, jego zakres bajtów przykrywa lukę /Contents Autora, a PDFlibPas odmawia spóźnionego znacznika czasu Autora z wynikiem procesu 17, zamiast łamać późniejszy podpis
uses
  SysUtils, PDFlibrary, PDFlibEnterprise;

const
  STS_COVERED_BY_LATER_SIGNATURE = 17;

function StampSignature(Lib: TPDFlibEnterprise; const Source, Target,
  FieldName: string): Integer;
var
  STS: Integer;
begin
  Result := 0;
  STS := Lib.NewPAdESSignatureTimeStampProcessFromFile(Source, OwnerPwd);
  if STS = 0 then
    Exit;
  try
    Lib.SetPAdESSignatureTimeStampField(STS, FieldName);
    if Lib.ApplyPAdESSignatureTimeStamp(STS, 0) <> 1 then
    begin
      Result := Lib.GetPAdESSignatureTimeStampProcessResult(STS);
      if Result = 1 then
        Result := -1;  // wartość zhaszowana, wymiana z TSA nie powiodła się: zobacz GetTimeStampError
      Exit;
    end;
    Result := Lib.EndPAdESSignatureTimeStampProcessToFile(STS, Target);
  finally
    Lib.ReleasePAdESSignatureTimeStampProcess(STS);
  end;
end;

procedure SignAndStampInOrder(const EncryptedFile: string);
var
  Lib: TPDFlibEnterprise;
  R: Integer;
begin
  Lib := TPDFlibEnterprise.Create;
  try
    Lib.SetTimeStampServer('https://tsa.example.com/tsa', '', '');

    SignField(Lib, EncryptedFile, 'step1.pdf', 'Author', 'author.pfx', 'pfx-1');
    R := StampSignature(Lib, 'step1.pdf', 'step1-ts.pdf', 'Author');
    if R <> 1 then
      raise Exception.CreateFmt('Author time stamp failed (%d)', [R]);

    SignField(Lib, 'step1-ts.pdf', 'step2.pdf', 'Approver', 'approver.pfx', 'pfx-2');
    R := StampSignature(Lib, 'step2.pdf', 'final.pdf', 'Approver');
    if R <> 1 then
      raise Exception.CreateFmt('Approver time stamp failed (%d)', [R]);

    // Oczekiwane: 17. Zakres bajtów Approver przykrywa /Contents Authora w
    // step2.pdf, więc proces odmawia, a restamped.pdf nigdy nie powstaje
    R := StampSignature(Lib, 'step2.pdf', 'restamped.pdf', 'Author');
    if R <> STS_COVERED_BY_LATER_SIGNATURE then
      raise Exception.CreateFmt('Unexpected result %d', [R]);
  finally
    Lib.Free;
  end;
end;

Znacznikuj każdy podpis, zanim następny podpisujący dotknie pliku. Kosztuje to jedną podróż round-trip do TSA na podpisującego i daje każdemu podpisowi własny zaufany czas. Plik, który przychodzi z kilkoma podpisami już na miejscu, to inna sprawa: wcześniejsze nie mogą już dostać znacznika czasu podpisu. Dodaj zamiast tego znacznik czasu dokumentu — podpis /Type /DocTimeStamp z /SubFilter /ETSI.RFC3161 w nowej rewizji, którego zakres bajtów przykrywa wszystkie wcześniejsze podpisy i dowodzi, że w tej chwili istniały. ApplyPAdESLongTermArchiveValidation dodaje taki po dołączeniu materiału walidacyjnego. Rezerwuj miejsce na znacznik czasu podpisu już przy podpisywaniu, przez SetSignProcessReserveContentsBytes jak w pierwszym listingu. Token, który osadza łańcuch certyfikatów TSA, bez trudu sięga kilku kilobajtów, a placeholder bez tego zapasu sprawia, że późniejsze rozszerzenie zawodzi z wynikiem 13

Odszyfrowywanie podpisanego pliku i to, co haszuje weryfikator

Podpisane zakresy zaszyfrowanego pliku to ciphertext. Weryfikator porównuje digest zapisanego ciphertextu z digestem wewnątrz CMS-a: /ByteRange to liczby całkowite, /Contents to tekst jawny, a kontrola integralności nie potrzebuje klucza — klucz potrzebuje dopiero nazwa pola i /Reason. Ten sam fakt wyjaśnia, dlaczego odszyfrowanie podpisanego pliku i zapisanie go w postaci jawnej unieważnia każdy podpis w nim, choć ani jeden widoczny piksel się nie zmienił. Zachowaj zaszyfrowany, podpisany oryginał jako kopię wzorcową. Strumienie cross-reference też nigdy nie są szyfrowane (ISO 32000-2 §7.5.8.2), więc aktualizacja zapisana strumieniem xref zachowuje czytelne offsety, zanim jakikolwiek klucz zostanie wyprowadzony. Układ opisuje osobny tekst object streams i cross-reference streams w Delphi. Gdy podpisany zakres jest za duży, by wczytać go do pamięci w celu porównania, PDFlibPas zgłasza kontrolę integralności jako nierozstrzygniętą, a nie nieprawdziwą, bo o bajtach, których nigdy nie porównano, nie udowodniono, że się różnią

Wartość podpisu zaszyfrowanego pliku pozostaje zapisywalna po podpisaniu, dopóki nie przykryje jej inny podpis albo znacznik czasu dokumentu. Ulokuj krok znacznika czasu w tym oknie, a sekwencja najpierw-szyfruj-potem-podpisz z pierwszego listingu przenosi się na pliki wielu podpisujących w PDF Library for Delphi bez zmian