Artykuł techniczny

Szybkie szyfrowanie PDF AES-256 dla ogromnych dokumentów

Szyfrowanie pliku PDF o rozmiarze 2 GB brzmi jak problem związany ze strumieniowaniem: otwórz plik, przepuść dwa gigabajty przez AES-256, zapisz wynik. Ten model myślowy jest błędny w sposób, który decyduje o całym budżecie wydajnościowym. Norma ISO 32000-1 §7.6 określa ziarnistość szyfrowania PDF na poziomie pojedynczego obiektu — każdy strumień i każdy ciąg znaków jest szyfrowany osobno, każdy z własnym wektorem inicjującym (IV) i własnym dopełnieniem (padding). Zeskanowane archiwum o rozmiarze 2 GB z 500 000 obiektów to 500 000 małych operacji CBC, a nie jeden długi przebieg, i przy takiej skali stały koszt związany z każdą operacją ma większe znaczenie niż arytmetyka AES w jej wnętrzu

Ten artykuł dotyczy tego stałego kosztu: na co poświęcany jest czas, gdy kod Delphi stosuje AES-256 do bardzo dużych dokumentów, i jak go odzyskać. Kwestie konfiguracyjne — hasła, flagi uprawnień, wywołanie zgodności wersji 5 z 6 — omówiono w artykule towarzyszącym na temat konfiguracji szyfrowania AES-256 w HotPDF; żadna z tych informacji nie jest tu powtarzana

Pół miliona operacji CBC, nie jeden przebieg

Szkielet pliku pozostaje w postaci jawnej. Tabele odsyłaczy (cross-reference), numery obiektów, klucze słowników, drzewo stron: nic z tego nie jest zaszyfrowane, co pozwala czytnikowi na lokalizację obiektów przed zweryfikowaniem hasła. To, co standard szyfruje, to zawartość — dane strumieni, takie jak opisy stron, obrazy, czcionki i załączniki, a także ciągi znaków, takie jak wartości metadanych i tekst adnotacji. Pod filtrem kryptograficznym AES-256 każdy z nich jest przetwarzany niezależnie: nowy losowy 16-bajtowy wektor IV, tryb CBC dla bajtów, dopełnienie bloku do granicy 16 bajtów oraz wektor IV zapisywany jawnym tekstem przed szyfrogramem

Wynikają z tego dwie konsekwencje. Po pierwsze, tekst zaszyfrowany jest zawsze dłuższy niż jawny: wektor IV dodaje 16 bajtów, a dopełnienie od 1 do 16 dodatkowych, więc 100-bajtowy ciąg zajmuje na dysku 128 bajtów, a pusty strumień nadal generuje 32 bajty. Kod, który dostosowuje rozmiar bufora wyjściowego do długości wejściowej lub zapisuje z powrotem tylko tyle bajtów, ile odczytał, tworzy pliki, których nie można odszyfrować przy ostatnim bloku każdego obiektu. Po drugie, koszt zależy od liczby obiektów, a nie tylko od liczby bajtów. Zeskanowane archiwum koncentruje swoje bajty w kilku dużych strumieniach obrazów, ale zawiera setki tysięcy krótkich strumieni i małych ciągów znaków, gdzie kosztem jest narzut na każdą operację, a nie sam algorytm AES

Jedynym ułatwieniem w projekcie AES-256 jest obsługa kluczy. Obsługi zabezpieczeń do rewizji 4 wyprowadzały oddzielny klucz dla każdego obiektu przez haszowanie klucza pliku razem z numerem obiektu i generacji, co wymuszało każdorazowo nowy harmonogram kluczy. Schematy /V 5 zrezygnowały z derywacji na poziomie obiektu: jeden losowy 256-bitowy klucz pliku szyfruje każdy obiekt w dokumencie. Ten fakt pozwala na zastosowanie każdej z poniższych optymalizacji — kosztowny stan kryptograficzny może być zbudowany raz na plik, a nie raz na obiekt

Słownik /Encrypt w R6: jedno powolne otwarcie, tanie obiekty

Dokument w rewizji 6 deklaruje swój schemat w słowniku /Encrypt w części trailer, a najważniejsze wpisy mieszczą się w kilku linijkach:

/Filter /Standard
/V 5  /R 6  /Length 256
/CF << /StdCF << /CFM /AESV3  /Length 32  /AuthEvent /DocOpen >> >>
/StmF /StdCF    /StrF /StdCF
/O ...48 bytes...   /U ...48 bytes...
/OE ...32 bytes...  /UE ...32 bytes...
/Perms ...16 bytes...  /P -3904  /EncryptMetadata true

/V 5 wybiera 256-bitową architekturę klucza, a /R 6 utwardzone uzgadnianie (handshake) według ISO 32000-2. /CF definiuje nazwany filtr kryptograficzny — /AESV3 oznacza AES-256 w trybie CBC z dołączonym na początku wektorem IV — a /StmF i /StrF przypisują ten filtr odpowiednio do strumieni i ciągów znaków. /O, /U, /OE i /UE przechowują materiał do weryfikacji hasła i opakowywania klucza, a /Perms przenosi kopię bitów uprawnień zaszyfrowaną algorytmem AES, dzięki czemu wrogi edytor nie może po cichu zmienić flagi /P

Struktura kosztów ukrywa się w /OE i /UE. Rozpakowanie klucza pliku z nich uruchamia Algorytm 2.B, iteracyjną funkcję derywacji klucza (KDF) łączącą rundy SHA-256, SHA-384 i SHA-512 — przynajmniej 64 rundy z regułą zatrzymania zależną od danych — stworzoną celowo powolnie, aby zgadywanie haseł pozostało kosztowne. Tę cenę płaci się raz, gdy program zapisujący tworzy plik, i raz, gdy czytnik go otwiera, co zajmuje pojedyncze milisekundy. W pliku z pół milionem obiektów KDF to tylko szum, a jeśli zapis jest wolny, to podejrzanym nie jest Algorytm 2.B; jest nim pętla przetwarzająca poszczególne obiekty

Ponownie użyj uchwytu klucza, ponownie użyj bufora roboczego

Naiwna implementacja to schludna funkcja pomocnicza: EncryptAes256Cbc, która otwiera dostawcę Windows CNG, wybiera tryb CBC, generuje obiekt klucza, szyfruje jeden bufor i zamyka wszystko. Poprawna, łatwa do testowania jednostkowego i katastrofalna wewnątrz pętli o 500 000 iteracji. Dokumentacja Microsoftu oznacza BCryptOpenAlgorithmProvider jako operację kosztowną i zaleca buforowanie uchwytu, a BCryptGenerateSymmetricKey uruchamia pełny harmonogram kluczy AES i alokuje stan dostawcy — czyste marnotrawstwo, gdy klucz nigdy nie zmienia się w całym dokumencie

Delphi RTL nie dostarcza modułu importu bcrypt, więc zadeklaruj punkty wejścia bezpośrednio. Poniższa klasa buduje cały stan kryptograficzny tylko raz, a następnie szyfruje dowolną liczbę obiektów bez alokacji w stanie ustalonym:

uses
  Winapi.Windows, System.SysUtils, System.Classes;

const
  BCRYPT_AES_ALGORITHM  = 'AES';
  BCRYPT_CHAINING_MODE  = 'ChainingMode';
  BCRYPT_CHAIN_MODE_CBC = 'ChainingModeCBC';
  BCRYPT_OBJECT_LENGTH  = 'ObjectLength';
  BCRYPT_BLOCK_PADDING            = $00000001;
  BCRYPT_USE_SYSTEM_PREFERRED_RNG = $00000002;

type
  NTSTATUS = Integer;
  BCRYPT_HANDLE = Pointer;

function BCryptOpenAlgorithmProvider(out hAlg: BCRYPT_HANDLE; AlgId,
  Impl: PWideChar; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptCloseAlgorithmProvider(hAlg: BCRYPT_HANDLE;
  Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptSetProperty(hObj: BCRYPT_HANDLE; Prop: PWideChar; Input: PByte;
  cbInput, Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptGetProperty(hObj: BCRYPT_HANDLE; Prop: PWideChar; Output: PByte;
  cbOutput: ULONG; out cbResult: ULONG; Flags: ULONG): NTSTATUS; stdcall;
  external 'bcrypt.dll';
function BCryptGenerateSymmetricKey(hAlg: BCRYPT_HANDLE;
  out hKey: BCRYPT_HANDLE; KeyObj: PByte; cbKeyObj: ULONG; Secret: PByte;
  cbSecret: ULONG; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptDestroyKey(hKey: BCRYPT_HANDLE): NTSTATUS; stdcall;
  external 'bcrypt.dll';
function BCryptEncrypt(hKey: BCRYPT_HANDLE; Input: PByte; cbInput: ULONG;
  Padding: Pointer; IV: PByte; cbIV: ULONG; Output: PByte; cbOutput: ULONG;
  out cbResult: ULONG; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptGenRandom(hAlg: BCRYPT_HANDLE; Buffer: PByte;
  cbBuffer, Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';

procedure CngCheck(Status: NTSTATUS; const Api: string);
begin
  if Status <> 0 then
    raise Exception.CreateFmt('%s failed, NTSTATUS 0x%.8x',
      [Api, Cardinal(Status)]);
end;

type
  TPdfObjectEncryptor = class
  private
    FAlg: BCRYPT_HANDLE;
    FKey: BCRYPT_HANDLE;
    FKeyObject: TBytes;  // CNG key-object workspace, allocated once
    FScratch: TBytes;    // ciphertext scratch, grows and then stays
  public
    constructor Create(const FileKey: TBytes);
    destructor Destroy; override;
    procedure EncryptObject(const Plain: TBytes; Dest: TStream);
  end;

constructor TPdfObjectEncryptor.Create(const FileKey: TBytes);
var
  Mode: string;
  ObjLen, Got: ULONG;
begin
  inherited Create;
  if Length(FileKey) <> 32 then
    raise Exception.Create('AES-256 file key must be 32 bytes');
  CngCheck(BCryptOpenAlgorithmProvider(FAlg, BCRYPT_AES_ALGORITHM, nil, 0),
    'BCryptOpenAlgorithmProvider');
  Mode := BCRYPT_CHAIN_MODE_CBC;
  CngCheck(BCryptSetProperty(FAlg, BCRYPT_CHAINING_MODE,
    PByte(PWideChar(Mode)), (Length(Mode) + 1) * SizeOf(WideChar), 0),
    'BCryptSetProperty');
  CngCheck(BCryptGetProperty(FAlg, BCRYPT_OBJECT_LENGTH, PByte(@ObjLen),
    SizeOf(ObjLen), Got, 0), 'BCryptGetProperty');
  SetLength(FKeyObject, ObjLen);
  // The AES key schedule is built once here and reused for every object
  CngCheck(BCryptGenerateSymmetricKey(FAlg, FKey, PByte(FKeyObject), ObjLen,
    PByte(FileKey), 32, 0), 'BCryptGenerateSymmetricKey');
end;

destructor TPdfObjectEncryptor.Destroy;
begin
  if FKey <> nil then
    BCryptDestroyKey(FKey);
  if FAlg <> nil then
    BCryptCloseAlgorithmProvider(FAlg, 0);
  inherited;
end;

procedure TPdfObjectEncryptor.EncryptObject(const Plain: TBytes; Dest: TStream);
var
  IV, IVWork: array[0..15] of Byte;
  Need, Written: ULONG;
  Src: PByte;
begin
  // Fresh random IV per object; it travels in the clear ahead of the data
  CngCheck(BCryptGenRandom(nil, @IV[0], 16, BCRYPT_USE_SYSTEM_PREFERRED_RNG),
    'BCryptGenRandom');
  Src := PByte(Plain);  // nil for an empty input is valid: padding-only block

  // Size query: CBC padding always adds 1..16 bytes, so Need > Length(Plain)
  IVWork := IV;  // BCryptEncrypt advances the IV buffer while it chains
  CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
    nil, 0, Need, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt(size)');

  if ULONG(Length(FScratch)) < Need then
    SetLength(FScratch, Need);  // grows a handful of times, then stays put

  IVWork := IV;
  CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
    PByte(FScratch), Need, Written, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt');

  // AESV3 layout: the 16-byte IV, then the padded ciphertext
  Dest.WriteBuffer(IV[0], 16);
  Dest.WriteBuffer(FScratch[0], Written);
end;

Trzy szczegóły mają kluczowe znaczenie. Zapytanie o rozmiar — pierwsze wywołanie BCryptEncrypt z zerowym (nil) buforem wyjściowym — zwraca długość zaszyfrowanego tekstu z dopełnieniem, która nigdy nie jest równa długości wejściowej; dopełnienie jest deterministyczne, więc można samodzielnie obliczyć ((Len div 16) + 1) * 16 i zmniejszyć o połowę liczbę wywołań, ale zapytanie to udokumentowany kontrakt. Po drugie, BCryptEncrypt przesuwa bufor IV w miejscu podczas tworzenia łańcucha (chaining), więc do każdego wywołania trafia kopia robocza, a nietknięty IV trafia do danych wyjściowych. Po trzecie, FScratch tylko rośnie, aż do największego obiektu w pliku, po czym pętla nie alokuje już niczego

Ile warte jest ponowne użycie uchwytów, pomiary

Plik, który wymusił to ćwiczenie, to zeskanowane archiwum kredytowe o rozmiarze 1,8 GB: 412 000 zaszyfrowanych obiektów przenoszących 1710 MB ładunku (payload) po odjęciu jawnej struktury. Ta sama maszyna, ten sam plik, pamięć masowa NVMe, jeden wątek:

  • Konfiguracja dla każdego wywołania (otwarcie dostawcy i generacja klucza wewnątrz funkcji pomocniczej): faza szyfrowania 71,3 s — 1710 MB ÷ 71,3 s ≈ 24 MB/s
  • Wyodrębnienie stanu (powyższa klasa): 9,6 s — 1710 MB ÷ 9,6 s ≈ 178 MB/s

Różnica wynosi 61,7 s dla 412 000 wywołań, co daje około 150 µs na wywołanie spędzone na otwieraniu dostawcy, ustawianiu trybu wiązania i ponownym budowaniu harmonogramu kluczy dla klucza, który nigdy się nie zmienił. Nic z tego nie było kryptografią. Dzięki AES-NI, szyfrowanie CBC dużych buforów działa z prędkością około 1,4 GB/s na jednym rdzeniu, więc sama arytmetyka AES odpowiada za około 1,2 s z 9,6 s; większość reszty to dwa przejścia w trybie użytkownika BCryptEncrypt na obiekt oraz generowanie wektora IV dla każdego obiektu. Przetwarzanie wsadowe (batching) IV — jedno wywołanie BCryptGenRandom wypełniające 4096 z nich — skróciło ten etap do 8,9 s. Poza tym osiągamy dolną granicę narzutu API na obiekt, a pozostałą dźwignią jest zrównoleglenie: obiekty /V 5 są niezależne od siebie przy użyciu wspólnego klucza pliku, więc cztery wątki robocze, z których każdy miał własny obiekt klucza, zredukowały tę fazę do 3,1 s, zanim zapisywarka wyjściowa stała się punktem serializacji

Pełne przepisanie kontra zapis przyrostowy

Ziarnistość decyduje również o tym, ile kosztuje zapis. Dodanie szyfrowania do istniejącego dokumentu jawnego z definicji przepisuje każdy obiekt: każdy strumień i ciąg znaków zmienia zarówno zawartość, jak i długość, każdy offset odsyłacza się przesuwa i nie istnieje żadna ścieżka przyrostowa. Zaplanuj to w budżecie jako pełne, sekwencyjne przepisanie i zapisuj do pliku tymczasowego, którego nazwa zostanie później zmieniona na docelową, ponieważ w przeciwnym razie awaria w połowie szyfrowania pozostawi na wpół zaszyfrowany plik, którego nie otworzy żadne hasło

Odwrotny kierunek jest tym tanim. Po zaszyfrowaniu pliku aktualizacja przyrostowa dopisuje nowe obiekty zaszyfrowane tym samym kluczem pliku i pozostawia każdy oryginalny bajt nienaruszony. Wstawienie adnotacji o zatwierdzeniu do 2-gigabajtowego zaszyfrowanego archiwum kosztuje kilobajty dołączonych danych wyjściowych, a nie przepisanie 2 GB. Wniosek dla potoku przetwarzania: szyfruj raz, jako ostatni krok zadania, a kolejne modyfikacje opieraj na zapisach przyrostowych. Rotacja haseł, która rotuje również klucz pliku, to ponownie pełne przepisanie — zaplanuj to jako takie

Mierzenie przepustowości bez oszukiwania samego siebie

Deklaracje dotyczące przepustowości szyfrowania bywają błędne w liczniku, w mianowniku lub w obu z nich. Licznikiem powinny być bajty ładunku: suma długości strumieni i ciągów znaków faktycznie przepuszczonych przez AES, po kompresji, które program zapisujący może sumować w locie. Rozmiar pliku to zawyża — powyższe archiwum zajmuje 1,8 GB na dysku, ale tylko 1710 MB z tego trafia do szyfru. Mianownikiem powinna być sama faza szyfrowania, ujęta w TStopwatch z System.Diagnostics, gdzie parsowanie, kompresja deflate i wejście/wyjście dyskowe znajdują się poza tym nawiasem. Po ich włączeniu, ten sam kod szyfrujący zostanie zmierzony jako kilka razy wolniejszy na pliku, który po prostu kompresuje się gorzej. Powyższe liczby są porównywalne dokładnie dlatego, że obie strony dzielenia obejmują wyłącznie proces szyfrowania

Nic z tego nie musi być Twoim własnym kodem. HotPDF skrywa tę samą inżynierię za właściwościami komponentu — ActivateProtection, CryptKeyLength, UseAES256R6 — na odpowiednim poziomie abstrakcji dla interaktywnych aplikacji VCL, uwzględniając pułapki związane z kolejnością przypisywania opisane w artykule o AES-256 w HotPDF. W przypadku potoków nienadzorowanych, PDFlibPas stosuje AES-256 rewizji 6 do istniejących plików w jednym wywołaniu EncryptFile o sile (Strength) 4 i następnie weryfikuje to, co zostało zapisane na dysku; proces ten został omówiony w artykule o audycie szyfrowania w PDFlibPas

Opisane tu ścieżki szyfrowania są dostępne w komponecie HotPDF Component dla Delphi i C++Builder oraz w bibliotece PDFlibPas; na stronach obu produktów można znaleźć kompletną dokumentację dotyczącą szyfrowania