Artykuł techniczny

Filtry szyfrowania PDF w Delphi: StmF, StrF i EFF

Komponent PDF HotPDF dla Delphi implementuje model filtrów szyfrowania z ISO 32000-1 §7.6.5 jako trzy niezależne polityki, a nie jeden przełącznik: ConfigureCryptFilterDefaults osobno przypisuje filtr łańcuchów /StrF, filtr strumieni /StmF i filtr osadzonych plików /EFF, SetStreamCryptFilter nadpisuje pojedynczy strumień, a GetLoadedCryptFilterInfo raportuje deklarację pliku wejściowego. Większość problemów interoperacyjności zaszyfrowanych PDF-ów żyje właśnie w lukach między tymi trzema elementami

Oto awaria, która kieruje ludzi do tej warstwy. Zespół dostarcza dokument, w którym treść strony ma pozostać czytelna dla narzędzia downstream, ale dołączony ładunek nie, więc ustawia /EFF /StdCF i pozostawia /StmF /Identity. Acrobat otwiera plik poprawnie. Zgodny czytnik zewnętrzny zwraca załącznik jako śmieciowy szyfrogram, ponieważ /EFF jest polityką po stronie producenta określającą filtr dla plików osadzonych, a ogólny czytnik nadal rozwiązuje nieoznaczony strumień przez /StmF. Poprawką nie jest inna wartość /EFF. Poprawką jest jawny filtr /Crypt bezpośrednio na strumieniu osadzonego pliku

Co naprawdę kontroluje warstwa filtrów szyfrowania

Filtry szyfrowania znajdują się między algorytmem szyfrowania a grafem obiektów i decydują, których obiektów algorytm dotknie, a nie jak algorytm działa. Słownik /CF wewnątrz słownika szyfrowania mapuje nazwy na definicje filtrów, z których każda ma metodę /CFM, opcjonalne /Length i /AuthEvent. Trzy wpisy najwyższego poziomu /StrF, /StmF i /EFF wybierają następnie nazwany filtr dla łańcuchów, dla strumieni bez jawnego filtra oraz dla plików osadzonych. HotPDF celowo ogranicza to, co jego wbudowane handlery będą zapisywać. ConfigureCryptFilterDefaults przyjmuje wyłącznie zarezerwowane nazwy aktywnego handlera: Standard security handler emituje /StdCF albo /Identity, handler klucza publicznego emituje /DefaultCryptFilter albo /Identity, a każda inna wartość wywołuje EArgumentException w miejscu wywołania. Filtry zapisane przez zewnętrznych producentów pod innymi nazwami są jednak zachowywane podczas ładowania, inspekcji i zapisu kompatybilności, więc HotPDF jest konserwatywny jako writer i pobłażliwy jako reader. Obowiązują jeszcze dwa zabezpieczenia: wywołanie zgłasza EInvalidOpException po rozpoczęciu serializacji dokumentu oraz ponownie, gdy dokument jest aktualizacją przyrostową, ponieważ polityki szyfrowania nie można zmieniać między rewizjami tego samego pliku

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'wrapper.pdf';
    Pdf.OwnerPassword := 'owner-secret';
    Pdf.UserPassword := 'open-secret';
    Pdf.CryptKeyLength := aes128;
    // łańcuchy zaszyfrowane, strumienie stron jawne, załączniki zaszyfrowane
    Pdf.ConfigureCryptFilterDefaults('StdCF', 'Identity', 'StdCF');
    Pdf.ActivateProtection := True;
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(50, 50, 0, 'Visible stream operators');
    Pdf.AddDocumentAttachment('payload.bin', 'Encrypted payload');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Warto od razu nazwać jedno ograniczenie, ponieważ jest sprawdzane późno i zaskakuje użytkowników. Nazwane filtry szyfrowania w HotPDF wymagają szyfrowania dokumentu aes128, aes256 albo aesgcm. Skonfiguruj politykę filtra na RC4 k40 albo k128, a walidacja wykonywana przy włączeniu szyfrowania zgłosi błąd zamiast po cichu promować typ klucza. To ta sama postawa projektowa, która obowiązuje w całej ścieżce szyfrowania PDF AES-256 w Delphi: odrzucić niejednoznaczną konfigurację, zamiast zgadywać, co wywołujący miał na myśli

Dlaczego wpis /Length oznacza dwie różne rzeczy?

Bo specyfikacja definiuje go w dwóch różnych jednostkach zależnie od handlera bezpieczeństwa, a HotPDF musi respektować oba znaczenia. W słowniku filtra szyfrowania, którego /CFM to /V2, wpis /Length jest wyrażony w bajtach w Standard security handlerze i w bitach w handlerze klucza publicznego. /Length w słowniku szyfrowania, umieszczony obok /V (ISO 32000-1 §7.6.2), zawsze podaje bity. Odczytaj słownik filtra z /Length 16, a otrzymasz klucz 128-bitowy w pliku z handlerem Standard i odrzucony plik w wariancie klucza publicznego. HotPDF normalizuje to podczas przechwytywania załadowanej konfiguracji. Mnoży /Length filtra /V2 przez osiem wyłącznie wtedy, gdy plik nie jest zaszyfrowany kluczem publicznym, korzysta z /Length na poziomie dokumentu, gdy filtr nie ma własnego wpisu, i zapisuje wynik w THPDFCryptFilterInfo.KeyLengthBits. AESV2 jest przypięty do 128 bitów, a AESV3 i AESV4 do 256, ponieważ te metody nie mają negocjowalnego rozmiaru klucza. Dalej pojawia się rygorystyczna część: akceptowane są tylko 40-bitowe i 128-bitowe /V2. Filtr rozwiązujący się do dowolnej innej długości jest raportowany jako niedostępny, a operacja kończy się błędem zamiast zaokrąglania do 128 na podstawie założenia, że większość producentów miała na myśli 128. Ciche normalizowanie długości klucza prowadzi do pliku, który odszyfrowuje się na twoim komputerze i nigdzie indziej

var
  Reader: THotPDF;
  Info: THPDFCryptFilterInfo;
  I: Integer;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    if Reader.LoadFromFile('incoming.pdf', 'open-secret') <> 1 then
      Exit;
    // /StrF i /StmF domyślnie wskazują Identity; /EFF domyślnie wskazuje /StmF
    WriteLn(Reader.LoadedStringCryptFilterName);        // StdCF
    WriteLn(Reader.LoadedStreamCryptFilterName);        // Identity
    WriteLn(Reader.LoadedEmbeddedFileCryptFilterName);  // StdCF
    for I := 0 to Reader.GetLoadedCryptFilterCount - 1 do
      if Reader.GetLoadedCryptFilterInfo(I, Info) then
        if (Info.Method = hcfmV2) and
           not (Info.KeyLengthBits in [40, 128]) then
          raise Exception.CreateFmt(
            'crypt filter /%s: unsupported V2 key length %d',
            [String(Info.Name), Info.KeyLengthBits]);
  finally
    Reader.Free;
  end;
end;

Co gwarantuje /CFM /None i czym różni się /Identity?

Do tego samego wyniku dochodzą różnymi drogami, a pomieszanie ich psuje wyszukiwanie. Nazwany filtr, którego /CFM to /None, oraz nazwany filtr bez /CFM oznaczają, że filtr nie wykonuje szyfrowania ani deszyfrowania — HotPDF mapuje brakujący wpis na None przed rozwiązaniem, więc oba kończą jako hcfmNone z zapisanym rozmiarem klucza równym zero. /Identity jest czymś innym: to zarezerwowana nazwa, która całkowicie omija wyszukiwanie w /CF, dlatego dokument może wskazywać /Identity bez definiowania go w /CF. Nazwy PDF są wrażliwe na wielkość liter, co czyni jeszcze jeden szczegół implementacyjny niepodlegającym negocjacji: żadne wyszukiwanie filtra szyfrowania nie może być niewrażliwe na wielkość liter. HotPDF rozwiązuje nazwy pod-słownika /CF, wpis /Length filtra i sprawdzenie /Type strumienia przez wyszukiwania wrażliwe na wielkość liter. Plik definiujący /stdcf, gdy /StmF wskazuje /StdCF, jest wadliwy, a potraktowanie tych dwóch nazw jako tego samego klucza zamieniłoby wykrywalny błąd autora w ciche zastosowanie złego klucza do każdego strumienia dokumentu

Jak sprawić, by /EFF obowiązywał na strumieniach plików osadzonych

Gdy /EFF różni się od /StmF, strumień osadzonego pliku potrzebuje jawnego początkowego wpisu /Crypt w swoim /Filter oraz pasującego słownika /DecodeParms zawierającego /Name na tej samej pozycji tablicy. HotPDF oblicza to dla każdego strumienia przy zapisie: wykrywa /Type /EmbeddedFile, dziedziczy skonfigurowany filtr pliku osadzonego i emituje jawny znacznik /Crypt tylko wtedy, gdy odziedziczona nazwa różni się od efektywnego domyślnego filtra strumienia. Gdy /EFF i /StmF są zgodne, znacznik nie jest zapisywany, ponieważ czytnik rozwiązałby ten sam filtr. Pozycja w tablicy jest więc równie ważna jak nazwa. Podczas ponownego odczytu strumienia HotPDF skanuje /Filter w poszukiwaniu wpisu /Crypt, zapisuje jego indeks, a następnie szuka tego samego indeksu w tablicy /DecodeParms, aby znaleźć /Name. /Crypt na indeksie 0 połączony z parametrami na indeksie 1 rozwiązuje się do /Identity, a nie do twojego filtra. Dlatego writer wypełnia tablicę parametrów wartością null, gdy strumień miał wcześniej /Filter, ale nie miał /DecodeParms: pozycje muszą pozostać wyrównane

Pod spodem kryje się ostrzejsza pułapka. Jeśli istniejący /Filter albo /DecodeParms jest obiektem pośrednim — co często zdarza się w plikach z generatorów współdzielących jedną tablicę filtrów między wieloma strumieniami — wstawienie /Crypt na miejscu zmieniłoby współdzielony graf filtrów i uszkodziło każdy inny strumień, który na niego wskazuje. HotPDF rozwiązuje obiekt pośredni i najpierw klonuje go do bezpośredniego obiektu prywatnego dla strumienia, czyszcząc numery obiektu i generacji, aby pierwotny korzeń pośredni nigdy nie został osadzony w nowej tablicy. Dla strumienia, który już używał ASCIIHexDecode, serializowany wynik to /Filter [ /Crypt /ASCIIHexDecode ] z /DecodeParms [ << /Type /CryptFilterDecodeParms /Name /StdCF >> ... ]. Ta sama dyscyplina pozycyjna obowiązuje każdy inny łańcuch filtrów, w tym te, po których poruszasz się podczas wyodrębniania obrazów z załadowanego PDF-a przez ich filtry dekodujące

// Editor przechowuje już załadowany dokument, a ContentStream to
// THPDFStreamObject, którego /Filter jest pośrednią nazwą /ASCIIHexDecode
Editor.OwnerPassword := 'owner-secret';
Editor.UserPassword := 'open-secret';
Editor.CryptKeyLength := aes128;
Editor.ConfigureCryptFilterDefaults('StdCF', 'Identity');
Editor.SetStreamCryptFilter(ContentStream, 'StdCF');
Editor.ActivateProtection := True;
Editor.SaveLoadedDocument('out.pdf');

// Pusta nazwa usuwa nadpisanie i przy następnym zapisie usuwa nieaktualny
// wpis /Crypt razem z jego parametrami dekodowania
Editor.SetStreamCryptFilter(ContentStream, '');
Editor.SaveLoadedDocument('cleared.pdf');

Czy strumienie obiektów dziedziczą politykę dokumentu /Encrypt?

Nie, a założenie, że tak jest, to niezawodny sposób na wygenerowanie śmieci. Strumień obiektów musi stosować rzeczywistą politykę /StmF albo własny jawny znacznik /Crypt: sama obecność słownika /Encrypt nie sprawia, że każdy kontener /ObjStm staje się szyfrogramem. Dokument z /StmF /Identity ma jawne strumienie obiektów, nawet gdy jego łańcuchy są w pełni zaszyfrowane, a dekoder, który mimo to je odszyfruje, przekaże do fazy inflate dane, które nigdy nie były wynikiem deflate

Konsekwencja dla obiektów członkowskich jest warta przeczytania dwa razy. Zgodnie z ISO 32000-1 §7.5.7 łańcuchy wewnątrz zaszyfrowanego strumienia obiektów są już jawne po odszyfrowaniu samego kontenera, więc ponowne ich odszyfrowanie byłoby podwójnym deszyfrowaniem. HotPDF chroni się przed tym, sprawdzając, czy kontener każdego obiektu typu 2 był zaszyfrowany, i pomijając obiekt, gdy był, a pominięcia zlicza w XRefProbeDecryptObjStmSkips jako bezpośredni dowód zadziałania zabezpieczenia. Gdy kontener był jawny, łańcuchy członków nigdy nie były niczym objęte, więc HotPDF materializuje te elementy i stosuje /StrF do każdego z osobna — z kluczem, tak jak faktycznie robi implementacja, opartym na numerze i generacji obiektu członkowskiego, a nie numerze zawierającego go obiektu /ObjStm. Odwróć tę zasadę w pliku z mieszaną polityką, a każdy łańcuch w każdym skompresowanym obiekcie zdekoduje się do szumu. Reguły na poziomie kontenera są szerzej omówione w notatkach o strumieniach obiektów PDF i aktualizacjach przyrostowych

Gdzie HotPDF odmawia zgadywania

Semantyka filtrów szyfrowania nie istnieje poniżej /V 4, więc HotPDF odrzuca każde nadpisanie per strumień w takim pliku jawnym błędem zamiast zapisywać znacznik /Crypt, którego nie honorowałby żaden zgodny czytnik. To samo obowiązuje po stronie odczytu: słownik szyfrowania z /V poniżej 4 czyści wszystkie trzy załadowane nazwy filtrów, ponieważ nie ma tam nic do raportowania. Trzy dalsze granice są wymuszane celowo:

  • Filtr per strumień inny niż Identity w dokumencie zaszyfrowanym kluczem publicznym jest odrzucany, ponieważ polityka specyficzna dla strumienia w handlerze klucza publicznego wymaga osobnej koperty odbiorcy dla strumienia, której HotPDF jeszcze nie emituje
  • Osadzone pliki zaszyfrowane kluczem publicznym, których /EFF różni się od efektywnego /StmF, są odrzucane z tego samego powodu zamiast zapisu w postaci, której nikt nie może odszyfrować
  • Szybka ścieżka bezpośrednia AES-256 dla pliku działa wyłącznie wtedy, gdy łańcuchy, strumienie i pliki osadzone rozwiązują się do tej samej metody filtra szyfrowania i żaden obiekt w pliku nie zawiera jawnego /Crypt; mieszana polityka albo jawne metadane wymuszają fallback do pełnej ścieżki grafu obiektów

Żadne z tych ograniczeń nie jest decyzją wydajnościową. Wyznaczają miejsca, w których błędne zgadywanie tworzy PDF otwierający się w jednym viewerze, zawodzący w innym i nie dający deweloperowi żadnego sygnału aż do zgłoszenia od klienta. Odmowa w ConfigureCryptFilterDefaults albo przy zapisie kosztuje jeden wyjątek; ciche zastosowanie złego klucza do osadzonego pliku kosztuje cały cykl wsparcia. Jeśli tworzysz oprogramowanie Delphi albo C++Builder produkujące lub konsumujące zaszyfrowane PDF-y — selektywnie jawne treści stron z zaszyfrowanymi załącznikami, zaszyfrowane opakowania ładunków PDF 2.0 albo interoperacyjność z plikami o nieznanych ci politykach filtrów — opisane tutaj API filtrów szyfrowania jest częścią aktualnego komponentu PDF HotPDF dla Delphi, obok ścieżek szyfrowania, strumieni obiektów i aktualizacji przyrostowych, na których się opiera