Шифрування 2 ГБ PDF-файлу звучить як проблема потокового передавання: відкрийте файл, пропустіть два гігабайти через AES-256, запишіть результат. Ця ментальна модель неправильна в тому сенсі, що вона визначає весь бюджет продуктивності. ISO 32000-1 §7.6 встановлює ступінь деталізації шифрування PDF на рівні окремого об'єкта — кожен потік і кожен рядок шифруються окремо, кожен із власним вектором ініціалізації та власним доповненням. Відсканований архів об'ємом 2 ГБ з 500 000 об'єктами — це 500 000 дрібних операцій CBC, а не один довгий прохід, і в такому масштабі фіксовані витрати на кожну операцію мають більше значення, ніж арифметика AES всередині неї
Ця стаття про ті фіксовані витрати: куди йде час, коли код Delphi застосовує AES-256 до дуже великих документів, і як його повернути. Що стосується налаштування — паролів, прапорців дозволів, вибору сумісності версії 5 проти 6 — дивіться супровідну статтю про налаштування шифрування AES-256 у HotPDF; нічого з цього тут не повторюється
Півмільйона операцій CBC, а не один прохід
Скелет файлу залишається у вигляді звичайного тексту. Таблиці перехресних посилань, номери об'єктів, ключі словників, дерево сторінок: нічого з цього не шифрується, саме тому програма для читання може знаходити об'єкти ще до перевірки пароля. Те, що шифрує стандарт — це вміст — дані потоків, такі як описи сторінок, зображення, шрифти та вкладення, а також рядки, такі як значення метаданих та текст анотацій. Під криптографічним фільтром AES-256 кожен з них обробляється окремо: новий випадковий 16-байтовий вектор ініціалізації (IV), CBC над байтами, доповнення блоку до 16-байтової межі, а IV записується у відкритому вигляді перед зашифрованим текстом
З цього випливають два наслідки. По-перше, зашифрований текст завжди довший за звичайний: IV додає 16 байтів, а доповнення — від 1 до 16, тому рядок у 100 байтів займає 128 байтів на диску, а порожній потік усе одно дає 32. Код, який задає розмір вихідного буфера відповідно до довжини входу або записує стільки ж байтів, скільки прочитав, створює файли, які не вдається розшифрувати на останньому блоці кожного об'єкта. По-друге, витрати залежать від кількості об'єктів, а не лише від кількості байтів. Відсканований архів зосереджує свої байти в кількох великих потоках зображень, але містить сотні тисяч коротких потоків і дрібних рядків, де головним є накладні витрати на кожну операцію, а не сам AES
Єдине полегшення в дизайні AES-256 — це обробка ключів. Обробники безпеки до версії 4 отримували окремий ключ для кожного об'єкта шляхом хешування ключа файлу разом із номером об'єкта та генерації, що кожного разу змушувало створювати новий розклад ключів. Схеми /V 5 відмовилися від отримання ключів для кожного об'єкта: один випадковий 256-бітний ключ файлу шифрує кожен об'єкт у документі. Цей факт дозволяє застосовувати кожну оптимізацію нижче — дорогий криптографічний стан можна створити один раз на файл, а не один раз на об'єкт
Словник /Encrypt R6: одне повільне відкриття, дешеві об'єкти
Документ версії 6 оголошує свою схему в словнику /Encrypt трейлера, і важливі записи вміщуються в кілька рядків:
/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 вибирає 256-бітну архітектуру ключів, а /R 6 — посилене рукостискання ISO 32000-2. /CF визначає іменований криптографічний фільтр — /AESV3 означає AES-256 у режимі CBC з доданим IV — а /StmF та /StrF призначають цей фільтр потокам і рядкам відповідно. /O, /U, /OE та /UE містять матеріали для перевірки паролів та загортання ключів, а /Perms несе зашифровану AES копію бітів дозволів, щоб ворожий редактор не зміг непомітно перемкнути /P
Структура витрат ховається в /OE та /UE. Розгортання ключа файлу з них запускає Алгоритм 2.B, ітеративну функцію виведення ключа, що об'єднує раунди SHA-256, SHA-384 та SHA-512 — щонайменше 64 з них, із залежним від даних правилом зупинки — побудовану навмисно повільною, щоб вгадування пароля залишалося дорогим. Ця ціна платиться один раз, коли записувач створює файл, і один раз, коли програма для читання відкриває його, по кілька мілісекунд щоразу. У файлі з півмільйоном об'єктів KDF — це просто шум, і якщо збереження повільне, Алгоритм 2.B не є підозрюваним; підозрюваним є цикл обробки кожного об'єкта
Повторне використання дескриптора ключа, повторне використання робочого буфера
Наївна реалізація — це охайна утилітна функція: помічник EncryptAes256Cbc, який відкриває провайдера Windows CNG, вибирає CBC, генерує об'єкт ключа, шифрує один буфер і все знищує. Правильно, можна протестувати за допомогою модульних тестів і... катастрофічно всередині циклу на 500 000 ітерацій. Документація Microsoft позначає BCryptOpenAlgorithmProvider як дорогу операцію і рекомендує кешувати дескриптор, а BCryptGenerateSymmetricKey запускає повний розклад ключів AES і виділяє стан провайдера — марна трата, коли ключ ніколи не змінюється в межах документа
У RTL Delphi немає модуля імпорту bcrypt, тому оголосіть точки входу безпосередньо. Наведений нижче клас один раз створює весь криптографічний стан, а потім шифрує будь-яку кількість об'єктів без постійного виділення пам'яті:
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;
Три деталі є опорними. Запит розміру — перший виклик BCryptEncrypt з нульовим вихідним буфером — повертає довжину доповненого зашифрованого тексту, яка ніколи не дорівнює довжині вхідних даних; доповнення є детермінованим, тому ви можете самостійно обчислити ((Len div 16) + 1) * 16 і вдвічі зменшити кількість викликів, але запит є задокументованим контрактом. По-друге, BCryptEncrypt просуває буфер IV на місці в міру зчеплення, тому робоча копія потрапляє в кожен виклик, а початковий IV опиняється на виході. По-третє, FScratch лише збільшується, аж до найбільшого об'єкта у файлі, після чого цикл нічого не виділяє
Скільки коштує повторне використання дескриптора, у вимірах
Файл, який змусив провести цю вправу, був відсканованим кредитним архівом об'ємом 1,8 ГБ: 412 000 зашифрованих об'єктів, що несуть 1710 МБ корисного навантаження після вирахування структури відкритого тексту. Та сама машина, той самий файл, сховище NVMe, один потік:
- Налаштування для кожного виклику (провайдер відкритий, а ключ згенерований у помічнику): фаза шифрування 71,3 с — 1710 МБ ÷ 71,3 с ≈ 24 МБ/с
- Стан винесено (клас вище): 9,6 с — 1710 МБ ÷ 9,6 с ≈ 178 МБ/с
Різниця становить 61,7 с на 412 000 викликів, або приблизно 150 мкс на виклик, витрачених на відкриття провайдера, встановлення режиму зчеплення та перебудову розкладу ключів для ключа, який ніколи не змінювався. Ніщо з цього не було криптографією. З AES-NI шифрування CBC великих буферів виконується зі швидкістю близько 1,4 ГБ/с на одному ядрі, тому сама арифметика AES становить близько 1,2 с з 9,6; більша частина решти — це два переходи BCryptEncrypt у режимі користувача на кожен об'єкт плюс генерація IV для кожного об'єкта. Пакетування IV — один виклик BCryptGenRandom, що заповнює 4096 з них — скоротило виконання до 8,9 с. Після цього ви досягаєте нижньої межі API для кожного об'єкта, і єдиний важіль, що залишився, — це паралелізм: об'єкти /V 5 незалежні під спільним ключем файлу, тому чотири робочі потоки з одним об'єктом ключа кожен скоротили фазу до 3,1 с, перш ніж записувач виводу став точкою серіалізації
Повний перезапис порівняно з інкрементним збереженням
Гранулярність також визначає вартість збереження. Додавання шифрування до наявного документа з відкритим текстом перезаписує кожен об'єкт за визначенням: кожен потік і рядок змінюють як вміст, так і довжину, кожне зміщення перехресного посилання переміщується, і немає інкрементного шляху. Бюджетуйте це як повний послідовний перезапис і пишіть у тимчасовий файл, який потім перейменовується на цільовий, оскільки збій посеред шифрування інакше залишить напівзашифрований файл, який не відкриє жоден пароль
Зворотний напрямок — дешевий. Після того як файл зашифровано, інкрементне оновлення додає нові об'єкти, зашифровані тим самим ключем файлу, і залишає кожен вихідний байт недоторканим. Нанесення анотації про схвалення на зашифрований архів об'ємом 2 ГБ коштує кілобайти доданого виводу, а не перезапис 2 ГБ. Наслідок для конвеєра: шифруйте один раз як останній крок завдання, і нехай наступні дотики йдуть через інкрементні збереження. Ротація пароля, яка також змінює ключ файлу, знову є повним перезаписом — плануйте це відповідно
Вимірювання пропускної здатності без самообману
Заяви про пропускну здатність шифрування, як правило, помиляються в чисельнику, знаменнику або в обох. Чисельник має бути байтами корисного навантаження: сума довжин потоків і рядків, фактично пропущених через AES після стиснення, які записувач може підсумовувати на ходу. Розмір файлу перебільшує його — вищезазначений архів займає 1,8 ГБ на диску, але лише 1710 МБ із них коли-небудь торкаються шифру. Знаменником має бути лише фаза шифрування, взята в дужки за допомогою TStopwatch із System.Diagnostics, з розбором, стисненням deflate та дисковим вводом/виводом за дужками. Включіть їх, і ідентичний код шифрування покаже результати в кілька разів повільніші на файлі, який просто гірше стискається. Наведені вище цифри можна порівнювати саме тому, що обидві сторони ділення стосуються лише шифрування
Ніщо з цього не обов'язково має бути вашим власним кодом. HotPDF загортає ту саму інженерію у властивості компонента — ActivateProtection, CryptKeyLength, UseAES256R6 — на правильному рівні для інтерактивних програм VCL, з пастками порядку призначення, розглянутими в статті HotPDF про AES-256. Для автоматизованих конвеєрів PDFlibPas застосовує AES-256 версії 6 до існуючих файлів одним викликом EncryptFile із Strength 4 і згодом перевіряє те, що опинилося на диску, — робочий процес, який розглядається в статті про аудит шифрування PDFlibPas
Шляхи шифрування, описані тут, постачаються в HotPDF Component для Delphi та C++Builder, а також у бібліотеці PDFlibPas; обидві сторінки продуктів містять повну довідку щодо шифрування