Шифрование 2 ГБ PDF-файла звучит как проблема потоковой передачи: открыть файл, пропустить два гигабайта через AES-256, записать результат. Эта ментальная модель неверна, причем так, что это решает весь бюджет производительности. ISO 32000-1 §7.6 устанавливает детализацию (granularity) шифрования PDF на уровне отдельного объекта — каждый поток и каждая строка шифруются отдельно, каждый со своим собственным вектором инициализации и своим собственным заполнением (padding). Сканированный архив объемом 2 ГБ со 500 000 объектами — это 500 000 небольших операций CBC, а не один длинный проход, и в таком масштабе фиксированные затраты на каждую операцию имеют большее значение, чем арифметика AES внутри нее
Эта статья о таких фиксированных затратах: куда уходит время, когда код Delphi применяет AES-256 к очень большим документам, и как его вернуть. Что касается настройки — паролей, флагов разрешений, вопроса совместимости версии (revision) 5 или 6 — см. сопроводительную статью по настройке шифрования AES-256 в HotPDF; ничего из этого здесь не повторяется
Полмиллиона операций CBC, а не один проход
Каркас файла остается в открытом тексте. Таблицы перекрестных ссылок, номера объектов, ключи словарей, дерево страниц: ничто из этого не зашифровано, благодаря чему ридер может находить объекты до того, как проверит пароль. То, что стандарт шифрует — это содержимое: данные потоков, такие как описания страниц, изображения, шрифты и вложения, плюс строки, такие как значения метаданных и текст аннотаций. Под фильтром шифрования AES-256 каждый из них обрабатывается сам по себе: свежий случайный 16-байтовый IV (вектор инициализации), CBC над байтами, заполнение блока до границы в 16 байт, и IV, записанный открытым текстом перед зашифрованным текстом (ciphertext)
Из этого следуют два следствия. Во-первых, зашифрованный текст всегда длиннее открытого: IV добавляет 16 байт, а заполнение добавляет еще от 1 до 16, поэтому 100-байтовая строка занимает на диске 128 байт, а пустой поток по-прежнему дает 32. Код, который подгоняет размер выходного буфера под длину ввода или записывает обратно только то количество байт, которое прочитал, создает файлы, которые не удается расшифровать на последнем блоке каждого объекта. Во-вторых, стоимость зависит от количества объектов, а не только от количества байтов. Сканированный архив концентрирует свои байты в нескольких больших потоках изображений, но несет сотни тысяч коротких потоков и небольших строк, где накладные расходы на каждую операцию, а не AES, являются счетом к оплате
Единственная милость в дизайне AES-256 — это обработка ключей. Обработчики безопасности (security handlers) вплоть до версии 4 выводили отдельный ключ для каждого объекта путем хеширования ключа файла вместе с номерами объекта и поколения (generation), что каждый раз вынуждало создавать новое расписание ключей (key schedule). Схемы /V 5 отказались от пообъектного вывода: один случайный 256-битный ключ файла шифрует каждый объект в документе. Этот факт дает право на каждую оптимизацию ниже — дорогостоящее криптографическое состояние может быть построено один раз на файл, а не один раз на объект
Словарь /Encrypt R6: одно медленное открытие, дешевые объекты
Документ версии (revision) 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 — усиленное (hardened) рукопожатие (handshake) ISO 32000-2. /CF определяет именованный фильтр шифрования — /AESV3 означает AES-256 в режиме CBC с добавленным в начало IV — а /StmF и /StrF назначают этот фильтр потокам и строкам соответственно. /O, /U, /OE и /UE содержат материал для проверки пароля и обертывания ключей, а /Perms несет зашифрованную AES копию битов разрешений, чтобы враждебный редактор не мог молча переключить /P
Структура затрат скрывается в /OE и /UE. Развертывание (unwrapping) ключа файла из них запускает Алгоритм 2.B, итерированную функцию формирования ключа (KDF), объединяющую в цепочку раунды SHA-256, SHA-384 и SHA-512 — по крайней мере 64 из них, с зависящим от данных правилом остановки — построенную намеренно медленно, чтобы подбор паролей оставался дорогим. Эта цена уплачивается один раз, когда записывающая программа (writer) создает файл, и один раз, когда ридер открывает его, считанные миллисекунды каждый раз. В файле с полумиллионом объектов KDF — это шум, и если сохранение происходит медленно, Алгоритм 2.B не является подозреваемым; им является цикл для каждого объекта
Повторное использование дескриптора ключа, повторное использование рабочего буфера (scratch buffer)
Наивная реализация — это аккуратная служебная функция (utility function): помощник EncryptAes256Cbc, который открывает провайдер Windows CNG, выбирает CBC, генерирует объект ключа, шифрует один буфер и все разрушает (tears down). Правильно, пригодно для модульного тестирования (unit-testable) и катастрофично внутри цикла на 500 000 итераций. Документация Microsoft отмечает BCryptOpenAlgorithmProvider как ресурсоемкий (expensive) и рекомендует кешировать дескриптор, а BCryptGenerateSymmetricKey запускает полное расписание ключей AES и выделяет состояние провайдера — чистые потери, когда ключ никогда не меняется во всем документе
Delphi RTL не поставляется с модулем импорта (import unit) bcrypt, поэтому объявите точки входа напрямую. Приведенный ниже класс создает все криптографическое состояние один раз, а затем шифрует любое количество объектов без выделения памяти в установившемся режиме (steady-state allocation):
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 с нулевым (nil) выходным буфером — возвращает длину зашифрованного текста с заполнением, которая никогда не равна длине ввода; заполнение детерминировано, поэтому вы можете вычислить ((Len div 16) + 1) * 16 самостоятельно и вдвое сократить количество вызовов, но запрос — это задокументированный контракт. Во-вторых, BCryptEncrypt продвигает буфер IV на месте по мере формирования цепочки (chains), поэтому рабочая копия поступает в каждый вызов, а нетронутый IV приземляется в выводе. В-третьих, FScratch только растет, вплоть до самого большого объекта в файле, после чего цикл ничего не выделяет
Ценность повторного использования дескриптора в измерениях
Файлом, который заставил выполнить это упражнение, был сканированный кредитный архив объемом 1.8 ГБ: 412 000 зашифрованных объектов, несущих 1 710 МБ полезной нагрузки (payload) после вычитания структуры открытого текста. Та же машина, тот же файл, хранилище NVMe, один поток:
- Настройка при каждом вызове (провайдер открыт и ключ сгенерирован внутри помощника): фаза шифрования 71.3 с — 1 710 МБ ÷ 71.3 с ≈ 24 МБ/с
- Состояние поднято (hoisted) (класс выше): 9.6 с — 1 710 МБ ÷ 9.6 с ≈ 178 МБ/с
Разница составляет 61.7 с на 412 000 вызовов, или примерно 150 мкс на каждый вызов, затраченных на открытие провайдера, установку режима цепочки и перестроение расписания ключей для ключа, который никогда не менялся. Ничего из этого не было криптографией. С AES-NI шифрование CBC больших буферов работает со скоростью около 1.4 ГБ/с на одном ядре, поэтому на саму арифметику AES приходится около 1.2 с из 9.6; большая часть остального — это два перехода пользовательского режима BCryptEncrypt на каждый объект плюс генерация IV на каждый объект. Пакетная обработка IV — один вызов BCryptGenRandom, заполняющий 4 096 из них — сократила время выполнения до 8.9 с. Дальше вы находитесь на нижнем пределе (floor) API для каждого объекта, и оставшийся рычаг — это параллелизм: объекты /V 5 независимы под общим ключом файла, поэтому четыре рабочих потока с одним объектом ключа каждый сократили фазу до 3.1 с, прежде чем программа записи вывода стала точкой сериализации
Полная перезапись против инкрементного сохранения
Детализация (granularity) также решает, сколько стоит сохранение. Добавление шифрования к существующему документу с открытым текстом перезаписывает каждый объект по определению: каждый поток и строка меняют как содержимое, так и длину, каждое смещение перекрестной ссылки перемещается, и инкрементного пути не существует. Планируйте это в бюджете как полную последовательную перезапись и пишите во временный файл, который переименовывается поверх целевого, потому что сбой (crash) в середине шифрования в противном случае оставляет наполовину зашифрованный файл, который не откроет ни один пароль
Обратное направление — дешевое. Как только файл зашифрован, инкрементное обновление (incremental update) добавляет новые объекты, зашифрованные тем же ключом файла, и оставляет каждый исходный байт нетронутым. Штамповка аннотации об утверждении в 2-гигабайтный зашифрованный архив стоит килобайты добавленного вывода, а не 2-гигабайтной перезаписи. Следствие (corollary) для конвейера: шифруйте один раз, в качестве последнего шага задания, и позвольте последующим прикосновениям ехать (ride) на инкрементных сохранениях. Смена (rotation) пароля, которая также сменяет ключ файла — это снова полная перезапись, планируйте ее соответствующе
Измерение пропускной способности (throughput) без самообмана
Заявления о пропускной способности шифрования, как правило, ошибочны в числителе, знаменателе или и в том, и в другом. Числителем должны быть байты полезной нагрузки (payload): сумма длин потоков и строк, фактически пропущенных через AES, после сжатия, которую записывающая программа (writer) может суммировать по ходу дела. Размер файла завышает это значение — архив выше занимает на диске 1.8 ГБ, но только 1 710 МБ из них когда-либо касаются шифра. Знаменателем должна быть только фаза шифрования, заключенная в скобки с помощью TStopwatch из System.Diagnostics, с парсингом, дефлейтом (deflate) и дисковым вводом-выводом за скобками. Включите их, и тот же самый код шифрования будет измеряться в несколько раз медленнее на файле, который просто сжимается хуже. Приведенные выше цифры сопоставимы именно потому, что обе стороны деления относятся только к шифрованию
Ничто из этого не обязательно должно быть кодом, которым вы владеете. HotPDF оборачивает ту же самую инженерию за свойствами компонента — ActivateProtection, CryptKeyLength, UseAES256R6 — на правильной высоте для интерактивных VCL-приложений, с подводными камнями (pitfalls) порядка присваивания, рассмотренными в статье HotPDF AES-256. Для необслуживаемых конвейеров (unattended pipelines) PDFlibPas применяет AES-256 версии (revision) 6 к существующим файлам за один вызов EncryptFile со стойкостью (Strength) 4 и проверяет затем, что оказалось на диске — рабочий процесс (workflow), разобранный в статье об аудите шифрования PDFlibPas
Описанные здесь пути шифрования поставляются в HotPDF Component для Delphi и C++Builder, а также в библиотеке PDFlibPas; на страницах обоих продуктов есть полное справочное руководство по шифрованию