HotPDF wczytuje podpisane, zaszyfrowane AES-256 PDF-y w Delphi dużo szybciej niż kiedyś, bo trzy osobne koszty zostały usunięte ze ścieżki wczytywania: parsowanie tokenów strumienia źródłowego bez bufora, drugi przebieg sprawdzania hasła Algorithm 2.A z ISO 32000-2 podczas deszyfrowania i powolna wewnętrzna pętla AES-128-CBC wewnątrz utwardzonego hasza R6. Na celach Delphi sama poprawka R6 zbiła wczytanie dokumentu z około 66 ms do 16 ms
Żadne z nich nie wychodziło na małych plikach testowych, których używa większość zestawów testowych. Niepodpisana, nieszyfrowana próbka jednego czy dwóch kilobajtów otwiera się w kilka setnych sekundy niezależnie od tego, co robi parser. Kłopoty zaczynają się od dokumentów, które klienci faktycznie wysyłają: umowa z wbudowanym podpisem CMS, około 35 KB, chroniona hasłem rewizji 6. Ta kombinacja włącza za jednym razem każdą drogą ścieżkę kodu, a każda z nich miała własny powód, żeby być wolniejszą, niż musiała być. Ten artykuł przechodzi przez nie w kolejności odkrycia, razem z błędami kluczy cache, które wyszły, gdy wyniki weryfikacji podpisów zaczęły być memoizowane
Dlaczego 34 KB podpisany PDF wczytuje się 170 ms?
34 KB podpisany PDF wczytywał się 170 ms, bo parser czytał goły THandleStream bezpośrednio, a każdy bajt tokena zamieniał się we własne wywołanie systemowe ReadFile. Tokenizer PDF przesuwa się o jeden bajt naraz, dopóki szuka ograniczników, więc niebuforowany strumień uchwytu zamienia tani skan w pamięci w tysiące przejść przez jądro
Detale wyjaśniają, dlaczego błąd przeżył. THotPDF.LoadFromFile od dawna owija swoje źródło plikowe wewnętrznym buforem odczytu, a sonda AutoDecrypt została już naprawiona z tego samego powodu, po tym jak spędziła 1,4 s na ponownym odpytywaniu rozmiaru pliku 56 MB. Podpisane i zaszyfrowane dokumenty idą jednak inną drogą. Żeby mieć pewność, że leniwe odczyty obiektów i inspekcja podpisów zawsze widzą tę samą niezmienną rewizję, nawet po tym, jak wołający zwolni własny strumień, THotPDF.LoadFromStream bierze migawkę źródła i parsuje ją drugi raz. Ten drugi pars, razem z wczytaniem reuse używanym dla uwierzytelnionych rewizji, czytał uchwyt migawki bez bufora z przodu. Oba idą teraz przez ten sam wewnętrzny buforowany czytnik, którego używa ścieżka plikowa
Ten bufor jest wewnętrzny, więc nigdy sam go nie tworzysz. Kontrolujesz strumień, który dajesz LoadFromStream, a HotPDF czyta ten strumień jak leci podczas pierwszego przebiegu strukturalnego. Jeśli dane są już w pamięci, przekaż TMemoryStream. Jeśli są na dysku, wywołaj LoadFromFile, albo na Delphi 10.1 Berlin i późniejszych daj loaderowi TBufferedFileStream zamiast gołego TFileStream
uses
System.SysUtils, System.Classes, HPDFDoc;
function LoadEncryptedFromBytes(const FileName: string;
const Password: AnsiString): THotPDF;
var
Mem: TMemoryStream;
begin
Result := THotPDF.Create(nil);
Mem := TMemoryStream.Create;
try
Mem.LoadFromFile(FileName); // jeden zbiorczy odczyt zamiast odczytów per token
Mem.Position := 0;
// LoadFromStream zwraca liczbę stron, 0 gdy dokument się nie wczytał
if Result.LoadFromStream(Mem, Password) < 1 then
raise Exception.Create('Could not load ' + FileName);
except
Result.Free;
Mem.Free;
raise;
end;
Mem.Free;
end;
Zwolnienie strumienia pamięci tuż po wczytaniu jest bezpieczne dla podpisanego i zaszyfrowanego wejścia, bo komponent trzyma już własną migawkę bajtów. Trzymaj jednak oryginalny plik albo bufor, jeśli planujesz potem weryfikować podpisy, bo weryfikacja haszuje surowe segmenty /ByteRange pliku, a nie odszyfrowany model obiektów
Mierzenie wczytywania i weryfikacji jako osobnych faz
Czas wczytywania i czas weryfikacji podpisu powinny być mierzone osobno, bo zawodzą na różne sposoby i są naprawiane w różnych miejscach. Faza wczytywania obejmuje parsowanie, sprawdzenie hasła i deszyfrowanie. Faza weryfikacji ponownie haszuje podpisane zakresy bajtów i sprawdza podpis CMS. Jeśli złożysz je w jedną liczbę, regresja w jednej chowa się w drugiej
Do tego wystarczy RTL-owy TStopwatch z System.Diagnostics. Uporządkowanie niżej wczytuje przeciążeniem z hasłem THotPDF.LoadFromFile, a potem weryfikuje pierwszy podpis przez przeciążenie TStream z VerifyLoadedSignature. Przeciążenie strumieniowe to właściwy wybór dla zaszyfrowanego pliku, bo ścieżka wczytywania z hasłem nie trzyma nazwy pliku źródłowego do ponownego otwarcia przez przeciążenia bezparametrowe
uses
System.SysUtils, System.Classes, System.Diagnostics,
HPDFDoc, HPDFSignVerify;
procedure TimeSignedEncryptedLoad(const FileName: string;
const Password: AnsiString);
var
Pdf: THotPDF;
Src: TFileStream;
Info: THPDFSignatureInfo;
Status: THPDFSignatureVerifyStatus;
Watch: TStopwatch;
Pages: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Watch := TStopwatch.StartNew;
Pages := Pdf.LoadFromFile(FileName, Password);
Writeln(Format('Load + decrypt: %d ms, %d pages, encrypted=%s',
[Watch.ElapsedMilliseconds, Pages, BoolToStr(Pdf.IsEncrypted, True)]));
if (Pages < 1) or (Pdf.GetLoadedSignatureFieldCount = 0) then
Exit;
Src := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
try
Watch := TStopwatch.StartNew;
Status := Pdf.VerifyLoadedSignature(0, Src, Info);
Writeln(Format('Verify signature 0: %d ms, status %d, signer %s',
[Watch.ElapsedMilliseconds, Ord(Status), string(Info.SignerName)]));
finally
Src.Free;
end;
finally
Pdf.Free;
end;
end;
Uruchom to kilka razy i zignoruj pierwszy bieg. Pierwsze wywołanie płaci jednorazowe koszty ustawienia, włącznie z budowaniem tabel AES i puszczeniem ich autotestu, a cache dyskowy skrzywia też pierwszy odczyt. Szukasz liczby stanu ustalonego dla prawdziwego pliku klienta, zmierzonej względem niepodpisanej, nieszyfrowanej kopii tego samego dokumentu
Dlaczego hasło AES-256 było wyprowadzane dwa razy?
HotPDF wyprowadzał klucz pliku AES-256 dwa razy, bo dwa etapy tego samego wczytywania każdy puszczał pełne sprawdzenie hasła na własną rękę. Przed parsem strukturalnym RecoverReloadKeysFromStreamRanges skanuje wstępnie surowe bajty, żeby zaszyfrowane pojemniki strumieni obiektów dały się odszyfrować, gdy dekodowane są filtry. Ten wstępny skan wykonuje już sprawdzienia Algorithm 2.A z ISO 32000-2 §7.6.4.3.3, walidując hasło względem soli użytkownika i właściciela i odzyskując klucz szyfrowania pliku. DecryptLoadedDocument potem ignorował ten wynik i robił całe wyprowadzanie od nowa
Dla rewizji 4 i starszych duplikat kosztował niewiele więcej niż pętlę MD5. Dla rewizji 6 Algorithm 2.A woła utwardzony hasz Algorithm 2.B, więc duplikat podwajał najdroższą część otwierania pliku. Reużycie odzyskanego klucza jest proste. Bezpieczne reużycie to trudniejsza część, bo zapamiętany klucz, który przeżywa własne sprawdzenie hasła, zamienia się w obejście uwierzytelnienia
Poprawka ogranicza reużycie flagą zakresu, która jest prawdą tylko między wstępnym skanem a wywołaniem deszyfrowania wewnątrz przeciążeń z hasłem LoadFromFile i LoadFromStream, i jest czyszczona w bloku finally. Flagę ustawia się tylko, gdy wstępny skan faktycznie zwalidował hasło. Bezpośrednie wywołanie DecryptLoadedDocument po zwykłym wczytaniu zawsze puszcza pełne wyprowadzanie, bo to hasło nigdy nie było wstępnie skanowane. Bez tego ograniczenia nieświeży klucz reload z wcześniejszego udanego otwarcia mógłby cicho odszyfrować dokument dla późniejszego wołającego, który podał złe hasło, a to dacznie gorszy błąd niż powolne wczytywanie
Na czym utrudniony hasz R6 wydaje swój czas?
Utwardzony hasz R6 wydaje prawie cały swój czas na szyfrowaniu AES-128-CBC. ISO 32000-2 §7.6.4.3.4 Algorithm 2.B powtarza rundę co najmniej 64 razy. Każda runda buduje bufor 64 kopii hasła, bieżącego hasza i, po stronie właściciela, napisu /U, szyfruje ten bufor AES-128-CBC z pierwszymi 16 bajtami hasza jako kluczem i kolejnymi 16 jako IV i wybiera SHA-256, SHA-384 albo SHA-512 dla następnego hasza z pierwszych 16 bajtów szyfrogramu czytanych jako liczba modulo 3. Hasze są krótkie. Bufory szyfrowane to kilka kilobajtów na rundę
Implementacja tej pętli w HotPDF, PDF20HashDanceR6, szyfrowała kiedyś bufor blok po bloku przez bajtowo zorientowany referencyjny AES. Teraz najpierw próbuje natywnej procedury CBC bez dopełnienia, która na celach Delphi dociera do linkowanego backendu AES w C. Z tą zmianą pełne wczytanie dokumentu spadło z około 66 ms do 16 ms. Gdy natywna procedura jest niedostępna albo zwraca niezgadzającą się długość, kod cofa się do historycznej pętli blokowej, a ta pętla może z kolei użyć tabelowo napędzanego bloku AES-128, którego tabele T są generowane z istniejącego S-boksa. Szyfr jest ten sam w każdym przypadku, więc wyprowadzony klucz jest bajt w bajt identyczny niezależnie od tego, która ścieżka go policzyła
Ten artykuł jest o szybkości. Trafienie w regułę zakończenia Algorithm 2.B to osobny problem poprawności, a pomylka o jeden tam odrzuca poprawne hasła tylko od czasu do czasu. Stronę konfiguracyjną R5 kontra R6 opisuje przewodnik po konfiguracji szyfrowania PDF AES-256 z HotPDF
Cztery sposoby, na jakie szybki blok AES może okłamać
Tabelowo napędzany blok AES może produkować zły szyfrogram i wciąż przejść własny autotest. Każda z czterech pułapek niżej wyszła przy budowie szybkiej ścieżki, a dwie z nich przeszły autotest taki, jaki był pierwotnie zaprojektowany
- Re-entrancja autotestu. Procedura gotowości ustawia swoją flagę inicjalizacji przed puszczeniem autotestu, więc re-enteryczne wywołanie zrobione w trakcie testu bierze ścieżkę referencyjną. Pierwsza wersja testu porównywała więc implementację referencyjną z nią samą. Poprawka to niechroniony surowy szybki blok, który autotest woła bezpośrednio, żeby ścieżka tabelowa była kodem faktycznie testowanym
- Zamienione układy tabel. Zamiana kolejności bajtów dwóch z czterech tabel rundy (Te1 i Te3) produkuje zły szyfrogram, którego re-enteryczny autotest nie miał jak zauważyć. Złapał go samodzielny program sondy, który wypisywał szyfrogram FIPS-197 aneks C.1
69c4e0d86a7b0430d8cdb78070b4c55adla klucza000102…0fi tekstu jawnego00112233…ff. Autotest wymaga teraz i tego wektora, i równości bajtów z blokiem referencyjnym, zanim zaufa szybkiej ścieżce - Sprawdzenia zakresu i przepełnienia FPC. Pod Free Pascal z
-Cri-Cow stylu Delphi jawne konwersjeByte()i arytmetyka przesunięć o szerokości bajtu podnoszą wyjątki zakresu albo przepełnienia. Indeksy tabel i podwajanie GF(2^8) są teraz liczone w natywnej szerokości liczby całkowitej i maskowaneand $FF - Błędna kompilacja przez inliner FPC. Małe pomocniki wczytania i zapisu big-endian przyjmujące parametry
PBytebyły kompilowane do naruszeń dostępu, gdy były oznaczoneinlinepod FPC. Usunięcieinlineje naprawiło, a koszt wywołania jest znikomy przy lookupach tabel
Za finalnym podpięciem stoi to samo doświadczenie. Skierowanie każdego bloku AES-128 w bibliotece przez szybkie tabele wywołało deterministyczne naruszenie dostępu FPC podczas jednego wczytania kandydata wydania, więc ta zmiana została wycofana. Wysłany projekt jest konserwatywny: najpierw natywne CBC, jawna gałąź szybkich tabel wewnątrz pętli R6 i blok referencyjny wszędzie indziej. Ogólna nauka: nowy kod kryptograficzny, który musi się budować pod kilkoma kompilatorami, potrzebuje niezależnej sondy znanej odpowiedzi puszczanej na każdym kompilatorze docelowym. Porażka testu na poziomie DLL nie powie ci, czy masz złą tabelę, czy pad. Więcej o stronie Free Pascal w notatach o backendach Deflate i AES HotPDF pod Free Pascal
Jak cache'ować weryfikację podpisu bez cache'owania złej odpowiedzi?
Cache weryfikacji podpisu jest bezpieczny tylko, gdy jego klucz pokrywa każde wejście, które może zmienić werdykt, włącznie z wejściami mieszkającymi poza podpisanymi zakresami bajtów. Warstwa dokumentu headless w HotPDF memoizuje wyniki weryfikacji CMS, haszując bajty CMS, bajty słownika podpisu w luce /Contents, treści pokrytych zakresów, odcisk polityki, budżety weryfikacji, całkowity rozmiar źródła i jednominutowe wiadro czasu. Trzy z tych wejść zostały dodane dopiero po tym, jak błąd pokazał ich brak
Zaufane pliki certyfikatów są kluczowane treścią, nie ścieżką. Warstwa operacji zapisuje ten sam materiał PEM pod świeżą tymczasową nazwą pliku dla każdego polecenia, więc klucz oparty na ścieżce nigdy się nie zgadzał i cache nigdy nie trafiał. Całkowity rozmiar źródła musiał być częścią klucza, bo zgłaszane UnsignedTrailingBytes od niego zależy: rewizja bazowa i późniejsza aktualizacja przyrostowa mogą dzielić dokładnie tę samą pokrytą treść, mając różne długości ogonów, i cache kluczowany wyłącznie zakresami podałby werdykt rewizji bazowej zaktualizowanemu plikowi. Tło tego, co te bajty ogona znaczą, jest w przewodniku HotPDF po weryfikacji cyfrowych podpisów PDF w Delphi
Trzeci błąd był pułapką języka Pascal, nie błędem projektu. Procedury haszujące przyjmują nietypowany bufor const, a przekazanie zmiennej tablicy dynamicznej do parametru nietypowanego przekazuje adres referencji tablicy, nie dane tablicy
function Checksum(const Data; Count: Integer): Cardinal;
var
P: PByte;
I: Integer;
begin
Result := 0;
P := @Data;
for I := 0 to Count - 1 do
begin
Result := Result + P^;
Inc(P);
end;
end;
procedure DigestCMS(const CMS: TBytes);
begin
// Źle: czyta referencję tablicy i wszystko, co za nią na stosie.
// Odczyt 16 KB stamtąd wybiega poza stronę stosu i podnosi naruszenie
// dostępu, a Delphi i FPC zawodzą w różnych punktach.
Writeln(Checksum(CMS, Length(CMS)));
// Dobrze: przekaż pierwszy element, z strażą na pustą tablicę
if Length(CMS) > 0 then
Writeln(Checksum(CMS[0], Length(CMS)));
end;
Blokada cache to SyncObjs.TCriticalSection, a nie API Windows InitializeCriticalSection, bo ten sam moduł buduje się też pod FPC na Linuksie, gdzie to API nie istnieje
Co zostaje powolne celowo
Samo wyprowadzanie klucza R6 nigdy nie jest cache'owane między wczytaniami i to jest celowe. Zewnętrzne bajty i jawnie podane hasło zawsze przechodzą prawdziwą ścieżkę uwierzytelnienia, więc każde wczytanie z hasłem dokumentu R6 płaci za co najmniej jeden hasz Algorithm 2.B, a i hasz strony użytkownika, i strony właściciela, gdy próbowane jest hasło właściciela. Zakresowe reużycie opisane wyżej usuwa duplikat wewnątrz pojedynczego wczytania i nic poza tym
- Liczba 66 ms do 16 ms dotyczy celów Delphi z linkowanym natywnym backendem AES. Budowle FPC biorą blok tabelowy, a generacja kodu FPC na Windows biegała 2-4 razy wolniej niż Delphi przy tych samych asercjach
- Cache weryfikacji wiąże się z jednominutowym wiadrem czasu, więc powtórzone sprawdzenia tych samych bajtów w krótkim oknie są tanie, a zapamiętany werdykt nigdy nie przeżywa minuty, w której został policzony
- Cache autorytetu scalonej rewizji był zaimplementowany, zdebugowany, a potem usunięty. Nie zamknął luki FPC i mierzalnie spowolnił callbacki bezpieczeństwa Delphi, co jest dobrą przypominajką, żeby mierzyć, zanim zostawi się cache
- Weryfikacja podpisów potrzebuje pierwotnych zaszyfrowanych bajtów pliku, więc trzymaj źródło dostępnym. Szybsze deszyfrowanie nic nie robi, żeby to wymaganie usunąć
FAQ
Czy reużycie wstępnie zeskanowanego klucza osłabia sprawdzanie hasła?
Nie. HotPDF reużywa odzyskany klucz pliku tylko wewnątrz tego samego wywołania przeciążenia z hasłem, które zwalidowało hasło pełnymi sprawdnieniami Algorithm 2.A, a flaga zakresu jest czyszczona w bloku finally. Samodzielne wywołanie DecryptLoadedDocument zawsze wykonuje kompletne wyprowadzanie, więc klucz zostawiony z wcześniejszego otwarcia nie da się pożyczyć ze złym hasłem
Dlaczego nie zapamiętać wyniku hasza R6 między wczytaniami?
Cache'owanie hasza między wczytaniami znaczyłoby przyjmowanie hasła bez sprawdzenia go względem otwieranego pliku. HotPDF traktuje każde zewnętrzne źródło bajtów i każde jawne hasło jako niezaufane wejście, które musi uwierzytelnić się samo, więc mierzalne przyspieszenie przyszło z przyspieszenia Algorithm 2.B, a nie z jego pomijania
Czy przekazywać TFileStream do LoadFromStream?
Wol LoadFromFile dla plików na dysku, bo już buforuje wewnętrznie. Gdy potrzebujesz przeciążenia strumieniowego, przekaż TMemoryStream albo TBufferedFileStream, bo HotPDF czyta dostarczony strumień bezpośrednio podczas pierwszego przebiegu strukturalnego, a goły strumień uchwytu kosztuje wywołanie systemowe za każdy mały odczyt
Wczytywanie chronione hasłem, szyfrowanie AES-256 R6, weryfikacja podpisów CMS i reszta API wczytanego dokumentu są razem w jednej natywnej bibliotece VCL dla Delphi i C++Builder. Szczegóły funkcji i wspierane wersje IDE są na stronie produktu HotPDF Component