Техническая статья

HotPDF: digital signatures and PAdES-ready signing в Delphi

Подпись PDF — это в основном учёт байтов, и именно в учёте байтов всё обычно и ломается. Криптография работает на коде, который аудировали два десятилетия, и эта часть почти никогда не подводит. В продакшне подводит то, что куда прозаичнее: заполнитель, зарезервированный слишком маленьким для настоящей подписи, хеш, взятый не с того участка файла, или «сохранение» после подписания, которое тихо переписало байты, уже зафиксированные подписью. Разложите байты правильно — и зелёная галочка позаботится о себе сама

HotPDF закрывает подписание для Delphi и C++Builder на трёх уровнях, и выбор между ними сводится к ответу на один вопрос: где живёт закрытый ключ? PFX-файл на диске требует одного вызова функции. Ключ, запертый в HSM или удалённой службе подписания, требует последовательности «резерв — хеш — вставка», потому что ни одна библиотека не способна залезть в токен и вытащить оттуда ключ. Подпись, которая должна удовлетворять европейскому регулированию, поверх этого требует ещё и базовых структур PAdES. Разделы ниже следуют именно этой последовательности усложнения

Диаграмма решений: выбор между подписанием HotPDF PFX одним вызовом, путём резервировать-хешировать-вставить, когда ключ лежит в HSM или удалённом сервисе, и базовыми структурами PAdES для регулируемого европейского подписания
Выбирайте уровень подписания, спросив, где живёт закрытый ключ; читаемый файл PFX схлопывает подписание в один вызов, ключи в токенах заставляют объезжать на байтовом уровне, а европейское регулирование добавляет слой PAdES

Как /ByteRange фиксирует подписанные байты

Подпись обязана жить внутри файла, который она подписывает, а подписать саму себя она не может. PDF обходит этот парадокс, оставляя дыру. Перед подписанием писатель резервирует запись /Contents фиксированного размера, заполненную нулями, и записывает массив /ByteRange для двух отрезков по обе стороны от неё: всё, что до дыры, и всё, что после. Подписант хеширует эти два отрезка и записывает получившийся блоб CMS в дыру в виде шестнадцатеричной строки. Ловушка — в слове фиксированного. Вы фиксируете размер этой дыры ещё до того, как знаете, насколько большой окажется готовая подпись, так что резерв должен быть уверенной оценкой с запасом. Восемь килобайт с комфортом вмещают отсоединённую подпись CMS с короткой цепочкой сертификатов

HotPDF разделяет эти два случая на два разных вызова, и спутать их — типичная ранняя ошибка. AddSignatureField размещает пустое видимое поле, чтобы человек позже подписал его в просмотрщике. AddSignedSignatureField создаёт поле и резервирует дыру /Contents — именно это вам нужно всякий раз, когда подпись завершает код, а не человек. Отдайте внешнему подписанту пустое поле — и заполнять ему будет нечего

Путь в один вызов: подписание из PFX

Когда сертификат и его закрытый ключ лежат в файле PFX/PKCS#12, который ваш процесс способен прочитать, весь конвейер сводится к одной функции класса:

if THotPDF.SignPDFWithPFX('invoice-unsigned.pdf', 'invoice-signed.pdf',
    'company-cert.pfx', 'pfx-password') then
  Writeln('Signed: invoice-signed.pdf')
else
  raise Exception.Create('PFX signing failed');

Когда это не срабатывает, проблема редко в PDF. Она в PFX. HotPDF читает контейнеры, защищённые PBES2, то есть с выводом ключа через PBKDF2 поверх AES-256-CBC. PFX, экспортированный старым мастером сертификатов Windows или OpenSSL до версии 3.0, обычно вместо этого обёрнут устаревшим RC2 или 3DES, и он просто не разберётся. Исправление — один раз переэкспортировать контейнер с современной защитой; сегодняшний OpenSSL делает это по умолчанию, и это не требует изменения кода. Так что если подписание мгновенно падает на сертификате, который «везде работает», прежде чем подозревать собственный код, посмотрите, как был создан этот PFX

Путь «резерв — хеш — вставка» для HSM и токенов

Путь в один вызов предполагает, что ваш процесс способен прочитать ключ как файл. Всё чаще это не так. Ключ находится в HSM, на USB-токене или за API службы подписания, и добраться до него напрямую библиотека никак не может. HotPDF решает это, разбивая подписание на шаги на уровне байтов: записать документ-заполнитель, запросить у библиотеки диапазоны для хеширования, передать входные данные хеша тому, что держит ключ, а затем вклеить полученный CMS обратно в дыру

HotPDF: четырёхшаговый конвейер резервировать-хешировать-вставить над placeholder.pdf — зарезервированная дыра /Contents между двумя диапазонами ByteRange и HSM, обменивающая дайджест на CMS hex
HotPDF резервирует дыру и сообщает оба диапазона ByteRange, ваш держатель ключей подписывает их вовне, а вернувшийся CMS вшивается обратно байт в байт, не трогая ни одного замороженного байта
var
  Doc: THotPDF;
  Fs: TFileStream;
  PdfBytes, HashInput, SigHex: AnsiString;
  R1Start, R1Len, R2Start, R2Len, CStart, CLen: Integer;
begin
  // 1. Записать документ с зарезервированной дырой /Contents
  Doc := THotPDF.Create(nil);
  try
    Doc.FileName := 'placeholder.pdf';
    Doc.BeginDoc;
    Doc.CurrentPage.AddSignedSignatureField('Sig1',
      Rect(50, 100, 350, 150), 8192, 'adbe.pkcs7.detached',
      'Contract approval', 'Boston, MA', 'legal@example.com');
    Doc.EndDoc;
  finally
    Doc.Free;
  end;

  // 2. Загрузить сохранённые байты; возвращаемые смещения нумеруются с 0
  Fs := TFileStream.Create('placeholder.pdf', fmOpenRead);
  try
    SetLength(PdfBytes, Fs.Size);
    Fs.ReadBuffer(PdfBytes[1], Fs.Size);
  finally
    Fs.Free;
  end;
  THotPDF.PreparePDFForSigning(PdfBytes, R1Start, R1Len, R2Start, R2Len,
    CStart, CLen);

  // 3. Захешировать оба отрезка и подписать во внешней системе (HSM, токен, служба)
  HashInput := Copy(PdfBytes, R1Start + 1, R1Len) +
               Copy(PdfBytes, R2Start + 1, R2Len);
  SigHex := SignWithHsm(HashInput);  // ваша интеграция: возвращает CMS в виде hex

  // 4. Вклеить подпись в зарезервированную дыру
  THotPDF.InsertSignatureHex(PdfBytes, SigHex);
  Fs := TFileStream.Create('signed.pdf', fmCreate);
  try
    Fs.WriteBuffer(PdfBytes[1], Length(PdfBytes));
  finally
    Fs.Free;
  end;
end;

Две детали в этой последовательности вызывают большинство перемежающихся сбоев. Первая: PreparePDFForSigning работает с байтами уже готового файла. Заполнитель нужно записать и полностью сохранить до того, как смещения обретут какой-либо смысл; вычислите их для потока, который ещё собирается, — и они не совпадут с байтами, которые вы в итоге захешируете. Вторая деталь — снова размер резерва. Запрошенные вами 8192 байта должны вместить итоговый CMS, а подпись, несущая промежуточные сертификаты, или та, которую служба украшает подписанными атрибутами, может выйти за эти рамки. InsertSignatureHex не станет увеличивать дыру, чтобы освободить место. Симптом — конвейер, который спокойно подписывает с одним сертификатом и падает со следующим; лекарство — перегенерировать заполнитель с резервом, измеренным по настоящей подписи, полученной от реального подписанта, а не угаданным

Базовые уровни PAdES и метки времени, которые продлевают жизнь подписи

Если вы подписываете по европейским правилам, в игре стандарт ETSI EN 319 142-1, который надстраивает четыре базовых уровня PAdES. B-B — это обычная подпись. B-T добавляет доверенную метку времени, доказывающую, когда подпись была сделана. B-LT встраивает материал для проверки — сертификаты и данные об отзыве — прямо в документ, чтобы его можно было проверить и годы спустя. B-LTA надстраивает сверху периодические метки времени документа, так что доказательства переживают алгоритмы, на которых были построены. HotPDF формирует структуры на стороне документа для каждого уровня:

HotPDF: уложенные друг на друга базовые уровни PAdES от B-B через B-T и B-LT к B-LTA, с хронологией продления — периодические метки времени документа держат подпись проверяемой десятилетия спустя
Каждый уровень надстраивает новую защиту над предыдущей; B-LTA продолжает заново накладывать метки времени документа, чтобы доказательства пережили алгоритмы, на которых их изначально построили
// Базовое поле подписи PAdES (ETSI EN 319 142-1)
Pdf.CurrentPage.AddPAdESSignatureField(
  'ApprovalSig', Rect(50, 100, 350, 150), 'B-B',
  'Contract approval', 'Boston, MA', 'legal@example.com');

// Метка времени документа: больший резерв под токен TSA и цепочку сертификатов
Pdf.CurrentPage.AddDocumentTimestampSignature('ArchiveTS', 16384);

Резерв в 16384 байта под метку времени выбран осознанно. Орган штампов времени возвращает токен, который тащит за собой собственную цепочку сертификатов, так что ему регулярно нужно больше места, чем те 8 КБ, которых достаточно обычной подписи. Эти же метки времени документа — механизм, лежащий в основе B-LTA: повторная простановка меток времени на архивной подписи каждые несколько лет, с всё ещё актуальными алгоритмами, — вот что делает документ, подписанный вами в 2026 году, проверяемым и в 2040-м

Пару слов о строках причины, места и контакта, которые принимают оба вызова полей: это метаданные для удобства, и не более того. HotPDF хранит их как обычные записи словаря и рисует их в видимом оформлении подписи, но ни один валидатор не сверяет их ни с чем. Заполняйте их последовательно из данных вашего рабочего процесса, поскольку аудиторы их действительно читают, но никогда не путайте их с доказательством. Настоящее криптографическое утверждение целиком живёт в CMS и его цепочке сертификатов, а верификатор полностью игнорирует видимый текст

После подписания файл может только расти

В момент появления подписи байты внутри её диапазонов замораживаются. Единственный законный способ изменить файл после этого — инкрементальное обновление по ISO 32000-1 §7.5.6, которое дописывает новые и изменённые объекты после исходных байтов и связывает их со свежим разделом перекрёстных ссылок. Сделанное таким образом, изменение оставляет подпись действительной для её редакции, и просмотрщик честно сообщает состояние: подписанная редакция цела, документ был расширен позже. Вместо этого пересериализуйте весь файл целиком — и вы перепишете подписанные отрезки, что уничтожит подпись, даже если визуально ничего не изменилось. Тот же механизм редакций — способ, которым один документ несёт несколько подписей: каждая новая подпись попадает в собственное инкрементальное обновление, а её диапазоны покрывают всё, что было до неё, включая более ранние подписи. Механика «только дописывание», и когда её безопасно уплотнять, разобрана в статье об object stream'ах и инкрементальных обновлениях

Стоит держать в уме два ограничения, пока вы проектируете. Режим вывода PDF/A в HotPDF напрямую отклоняет поля подписи, так что архивное соответствие и встроенная подпись обязаны поставляться раздельными файлами. И подписание ничего не говорит о секретности: оно доказывает, кто создал документ и что с тех пор он не менялся, но прочитать его по-прежнему может кто угодно. Сокрытие содержимого — отдельная задача, которую решают шифрование AES-256 и политика разрешений

Что бы вы ни построили, тестируйте это чем-то, кроме того же кода, который записал файл. Откройте результат на панели подписей Acrobat и убедитесь в трёх вещах: подпись действительна, личность выстраивается в цепочку до ожидаемого корня, а панель не сообщает об изменениях после подписания. Затем поменяйте один-единственный байт внутри подписанного диапазона в одноразовой копии и убедитесь, что панель теперь называет документ изменённым. Конвейер подписания, отказ которого забраковать подделанный файл вы никогда не наблюдали воочию, — это конвейер, чья проверка на самом деле не тестировалась

Все три уровня подписания поставляются вместе с HotPDF Delphi Component для Delphi и C++Builder; страница продукта содержит ссылку на полный справочник по API подписания