HotPDF загружает подписанные, зашифрованные AES-256 PDF в Delphi заметно быстрее, чем раньше, потому что с пути загрузки убрали три отдельные стоимости: небуферизованный поточный разбор токенов исходного потока, второй прогон проверки пароля по Алгоритму 2.A из ISO 32000-2 при расшифровке и медленный внутренний цикл AES-128-CBC внутри hardened-хеша R6. На Delphi-целях один лишь фикс R6 срезал загрузку документа с примерно 66 мс до 16 мс
Ни одна из них не проявлялась на маленьких тестовых файлах, которыми пользуются большинство наборов. Неподписанный незашифрованный сэмпл в пару килобайт открывается за сотые доли секунды, что бы ни делал парсер. Неприятности начинаются с документов, которые реально шлют заказчики: договор со встроенной CMS-подписью, около 35 КБ, защищённый паролем ревизии 6. Эта комбинация включает все дорогие ветки кода разом, и у каждой была своя причина быть медленнее необходимого. Эта статья проходит по ним в порядке обнаружения, вместе с ошибками cache key, всплывшими, когда результаты проверки подписей начали мемоизировать
Почему подписанный PDF на 34 КБ грузится 170 мс?
Подписанный PDF на 34 КБ грузился 170 мс, потому что парсер читал голый THandleStream напрямую, и каждый байт токена оборачивался собственным системным вызовом ReadFile. PDF-токенизер продвигается по одному байту, пока ищет разделители, поэтому небуферизованный handle-поток превращает дешёвое сканирование в памяти в тысячи переходов в ядро
Детали объясняют, почему баг выжил. THotPDF.LoadFromFile давно заворачивает свой файловый источник во внутренний буфер чтения, а пробник AutoDecrypt уже был починен по той же причине после того, как 1,4 с перезапрашивал размер файла в 56 МБ. Подписанные и зашифрованные документы идут другим маршрутом. Чтобы ленивые чтения объектов и инспекция подписи всегда видели одну и ту же неизменяемую редакцию, даже после того как вызывающий освободил свой поток, THotPDF.LoadFromStream берёт снимок источника и разбирает его второй раз. Тот второй разбор вместе с reuse-загрузкой, применяемой для аутентифицированных редакций, читал снимок-хендл без буфера впереди. Оба теперь идут через тот же внутренний буферизованный читатель, что и файловый путь
Тот буфер внутренний, так что вы его сами никогда не создаёте. Что вы контролируете, так это поток, который передаёте в LoadFromStream, и HotPDF читает его как есть во время первого структурного прохода. Если данные уже в памяти, передавайте TMemoryStream. Если на диске — вызывайте LoadFromFile, а на Delphi 10.1 Berlin и новее дайте загрузчику TBufferedFileStream вместо голого 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); // одно объемное чтение вместо чтений по токену
Mem.Position := 0;
// LoadFromStream возвращает число страниц, 0 когда документ не загрузился
if Result.LoadFromStream(Mem, Password) < 1 then
raise Exception.Create('Could not load ' + FileName);
except
Result.Free;
Mem.Free;
raise;
end;
Mem.Free;
end;
Освобождать поток памяти сразу после загрузки безопасно для подписанного и зашифрованного входа, потому что компонент уже держит собственный снимок байтов. Исходный файл или буфер всё же приберегите, если планируете проверять подписи после, — верификация хеширует сырые сегменты /ByteRange файла, а не расшифрованную объектную модель
Измерение загрузки и проверки как отдельных фаз
Время загрузки и время проверки подписи стоит мерить раздельно, потому что они ломаются по-разному и чинятся в разных местах. Фаза загрузки покрывает разбор, проверку пароля и расшифровку. Фаза проверки заново хеширует подписанные диапазоны байтов и проверяет CMS-подпись. Слепите их в одно число — и регресс любой из них спрячется внутри другой
RTL-класса TStopwatch из System.Diagnostics достаточно. Стенд ниже загружает через перегрузку с паролем THotPDF.LoadFromFile, а затем проверяет первую подпись через перегрузку TStream метода VerifyLoadedSignature. Потоковая перегрузка — правильный выбор для зашифрованного файла, потому что путь загрузки с паролем не хранит имени исходного файла, которое безаргументные перегрузки могли бы переоткрыть
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;
Прогоните несколько раз и игнорируйте первый. Первый вызов платит разовые затраты настройки, включая построение таблиц AES и их самотест, а дисковый кеш тоже кособочит первое чтение. Ищете вы установившееся число для реального файла заказчика, отмеренное против неподписанной незашифрованной копии того же документа
Почему ключ AES-256 выводился дважды?
HotPDF выводил ключ файла AES-256 дважды, потому что две стадии одной загрузки каждая прогоняла полную проверку пароля на свой манер. Перед структурным разбором RecoverReloadKeysFromStreamRanges предосканирует сырые байты, чтобы контейнеры зашифрованных object stream могли расшифровываться, пока декодируются фильтры. Тот предоскан уже выполняет проверки Алгоритма 2.A из ISO 32000-2 §7.6.4.3.3, валидируя пароль против user- и owner-солей и восстанавливая ключ шифрования файла. DecryptLoadedDocument затем игнорировал этот результат и делал всё выведение заново
Для ревизии 4 и старше дубль стоил немногим больше цикла MD5. Для ревизии 6 Алгоритм 2.A вызывает hardened-хеш Алгоритма 2.B, поэтому дубль удваивал самую дорогую часть открытия файла. Переиспользовать восстановленный ключ просто. Безопасно переиспользовать — труднее, потому что закешированный ключ, переживший собственную проверку пароля, превращается в обход аутентификации
Фикс ограничивает переиспользование флагом области видимости, истинным только между предосканом и вызовом расшифровки внутри парольных перегрузок LoadFromFile и LoadFromStream, и снимаемым в блоке finally. Флаг выставляется только когда предоскан реально валидировал пароль. Прямой вызов DecryptLoadedDocument после обычной загрузки всегда выполняет полное выведение, потому что тот пароль никогда не предосканивался. Без этого ограничения устаревший reload-ключ от более раннего успешного открытия мог бы тихо расшифровать документ для позднейшего вызывающего с неверным паролем, а это баг куда хуже медленной загрузки
Куда уходит время hardened-хеша R6?
Hardened-хеш R6 тратит почти всё время на шифрование AES-128-CBC. ISO 32000-2 §7.6.4.3.4, Алгоритм 2.B, повторяет раунд минимум 64 раза. Каждый раунд строит буфер из 64 копий пароля, текущего хеша и, на стороне владельца, строки /U, шифрует этот буфер AES-128-CBC, беря первые 16 байтов хеша как ключ и следующие 16 как IV, и выбирает SHA-256, SHA-384 или SHA-512 для следующего хеша по первым 16 байтам шифртекста, прочитанным как число по модулю 3. Хеши короткие. Шифруемые буферы — по несколько килобайт за раунд
Реализация того цикла в HotPDF, PDF20HashDanceR6, раньше шифровала буфер по блоку за раз через байтоориентированный референсный AES. Теперь она сперва пробует нативную CBC-подпрограмму без набивки, которая на Delphi-целях добирается до прилинкованного C-бэкенда AES. С этим изменением полная загрузка документа упала с примерно 66 мс до 16 мс. Если нативная подпрограмма недоступна или возвращает несовпадающую длину, код откатывается к историческому блочному циклу, а тот цикл в свою очередь может использовать табличный блок AES-128, чьи T-таблицы генерируются из существующего S-box. Шифр во всех случаях один, поэтому выведенный ключ побайтово идентичен независимо от того, какой путь его вычислил
Эта статья о скорости. Правильное правило завершения Алгоритма 2.B — отдельная проблема корректности, и ошибка на единицу там отвергает правильные пароли лишь иногда. Конфигурационная сторона R5 против R6 разобрана в руководстве по настройке шифрования PDF AES-256 с HotPDF
Четыре способа, как быстрый AES-блок может вам наврать
Табличный AES-блок может выдать неверный шифртекст и всё же пройти собственный самотест. Каждая из четырёх ловушек ниже всплыла при постройке быстрого пути, и две прошли самотест в его первой редакции
- Реентерабельность самотеста. Подпрограмма готовности выставляет флаг инициализированности до прогона самотеста, поэтому реентерабельный вызов, случившийся во время теста, идёт референсным путём. Первая версия теста потому сравнивала референсную реализацию с ней же самой. Фикс — неохраняемый сырой быстрый блок, который самотест вызывает напрямую, так что тестируется реально табличный путь
- Перепутанные раскладки таблиц. Обмен порядком байтов двух из четырёх раундовых таблиц (Te1 и Te3) даёт неверный шифртекст, который реентерабельный самотест заметить не мог. Поймала его отдельная пробная программа, печатавшая шифртекст
69c4e0d86a7b0430d8cdb78070b4c55aиз Appendix C.1 FIPS-197 для ключа000102…0fи открытого текста00112233…ff. Самотест теперь требует и тот вектор, и побайтовое равенство с референсным блоком, прежде чем доверять быстрому пути - Проверки диапазона и переполнения FPC. Под Free Pascal с
-Crи-Coдельфийские явные конверсииByte()и арифметика сдвигов байтовой ширины поднимают исключения диапазона или переполнения. Индексы таблиц и удвоение GF(2^8) теперь вычисляются в нативной целой ширине и маскируютсяand $FF - Мискомпиляция инлайнера FPC. Малые хелперы big-endian загрузки и сохранения, принимающие параметры
PByte, компилировались в access violation, будучи помеченыinlineпод FPC. Снятиеinlineпочинило их, а стоимость вызова ничтожна на фоне табличных выборок
За финальной разводкой стоит тот же опыт. Маршрут каждого AES-128 блока библиотеки через быстрые таблицы вызвал детерминированный FPC access violation во время одной загрузки кандидата в публикацию, поэтому изменение откатили. Поставляемый дизайн консервативен: сперва нативный CBC, явная ветка быстрых таблиц внутри цикла R6 и референсный блок везде остальном. Общий урок: новому криптокоду, которому надо собираться под несколькими компиляторами, нужен независимый known-answer пробник на каждом целевом компиляторе. Провал DLL-уровня теста не скажет вам, неправильная у вас таблица или падение. Подробнее о стороне Free Pascal — в заметках о бэкендах Deflate и AES HotPDF под Free Pascal
Как кешировать проверку подписи, не закешировав неверный ответ?
Кеш проверки подписи безопасен, только если его ключ покрывает каждый вход, способный изменить вердикт, включая входы, живущие вне подписанных диапазонов байтов. Headless-слой документов HotPDF мемоизирует результаты проверки CMS, хешируя байты CMS, байты словаря подписи в зазоре /Contents, содержимое покрытых диапазонов, отпечаток политики, бюджеты проверки, общий размер источника и минутное ведро времени. Три из этих входов добавили только после того, как баг показал их отсутствие
Доверенные файлы сертификатов ключуются по содержимому, а не по пути. Слой операций пишет один и тот же PEM-материал в свежее временное имя файла на каждую команду, поэтому ключ по пути никогда не совпадал и кеш никогда не бил. Общий размер источника пришлось включить в ключ, потому что сообщаемый UnsignedTrailingBytes от него зависит: базовая редакция и позднейшее инкрементальное обновление могут делить в точности одинаковое покрытое содержимое при разных длинах хвоста, и кеш, ключующийся только по диапазонам, вручил бы вердикт базовой редакции обновлённому файлу. Подоплека того, что значат те хвостовые байты, — в руководстве HotPDF по проверке цифровых подписей PDF в Delphi
Третий баг был языковой ловушкой Pascal, а не ошибкой проектирования. Хеш-подпрограммы принимают нетипизированный буфер const, и передача переменной динамического массива в нетипизированный параметр передаёт адрес ссылки на массив, а не данные массива
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
// Неправильно: читается ссылка на массив и всё, что за ней на стеке.
// Чтение 16 КБ оттуда убегает со страницы стека и поднимает access
// violation, причём Delphi и FPC падают в разных точках.
Writeln(Checksum(CMS, Length(CMS)));
// Правильно: передаётся первый элемент, с защитой для пустого массива
if Length(CMS) > 0 then
Writeln(Checksum(CMS[0], Length(CMS)));
end;
Блокировка кеша — SyncObjs.TCriticalSection, а не API Windows InitializeCriticalSection, потому что тот же модуль собирается и под FPC на Linux, где этого API нет
Что остаётся медленным нарочно
Само выведение ключа R6 никогда не кешируется между загрузками, и это намеренно. Внешние байты и явно переданный пароль всегда идут настоящим путём аутентификации, поэтому каждая загрузка с паролем документа R6 платит минимум за один хеш Алгоритма 2.B, и за оба — пользовательского и владельческого — когда пробуется пароль владельца. Описанное выше scoped-переиспользование убирает дубль внутри одной загрузки и ничего сверх
- Цифры с 66 мс до 16 мс относятся к Delphi-целям с прилинкованным нативным бэкендом AES. Сборки FPC берут табличный блок, и кодогенерация FPC на Windows работала в 2–4 раза медленнее Delphi под теми же утверждениями
- Кеш проверки привязан к минутному ведру времени, поэтому повторные проверки тех же байтов в коротком окне дёшевы, а закешированный вердикт никогда не переживает минуту, в которую вычислен
- Кеш полномочий слитой редакции был реализован, отлажен и затем удалён. Он не закрыл разрыв FPC и заметно замедлил колбэки безопасности Delphi — хорошее напоминание мерить, прежде чем держать кеш
- Проверке подписи нужны исходные зашифрованные байты файла, так что держите источник доступным. Ускоренная расшифровка этого требования не снимает
FAQ
Ослабляет ли переиспользование предосканированного ключа проверку пароля?
Нет. HotPDF переиспользует восстановленный ключ файла только внутри того же вызова парольной перегрузки, что валидировал пароль полными проверками Алгоритма 2.A, а флаг области видимости снимается в блоке finally. Одиночный вызов DecryptLoadedDocument всегда выполняет полное выведение, поэтому ключ, оставшийся от более раннего открытия, нельзя одолжить с неверным паролем
Почему бы не кешировать результат хеша R6 между загрузками?
Кеширование хеша между загрузками означало бы принимать пароль, не сверяя его с открываемым файлом. HotPDF трактует каждый внешний источник байтов и каждый явный пароль как недоверенный вход, обязанный аутентифицироваться самостоятельно, поэтому измеримое ускорение пришло от ускорения Алгоритма 2.B, а не от его пропуска
Стоит ли передавать TFileStream в LoadFromStream?
Для файлов на диске предпочитайте LoadFromFile — он уже буферизует внутри. Когда нужна потоковая перегрузка, передавайте TMemoryStream или TBufferedFileStream, поскольку HotPDF читает поданный поток напрямую во время первого структурного прохода, а голый handle-поток стоит системного вызова на каждое мелкое чтение
Загрузка с паролем, шифрование AES-256 R6, проверка CMS-подписей и остальной API загруженного документа поставляются вместе в одной нативной VCL-библиотеке для Delphi и C++Builder. Детали возможностей и поддерживаемые версии IDE — на странице продукта HotPDF Component