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

PDFium: удалённая подпись PAdES через HSM и облако

PDFiumPas разделяет подписание PAdES на два вызова, чтобы приватный ключ никогда не находился в вашем процессе. PreparePadesRemoteSignature записывает инкрементальное обновление с пустым заполнителем фиксированной ширины /Contents и возвращает запись запроса, несущую дайджест документа SHA-256, точный ByteRange и отпечаток подготовленного файла. CompletePadesRemoteSignature принимает отсоединённый CMS, который возвращает ваша служба подписи, и вставляет его в этот зарезервированный слот

Между этими двумя вызовами могут пройти минуты или часы, процесс может перезапуститься, а работа может переместиться на другую машину. Именно этот разрыв — вся причина, по которой API устроен именно так

Почему удалённый ключ не может использовать обычный вызов подписи?

Потому что SignPadesBytes предполагает, что операция подписания происходит внутри самого вызова. Он строит инкрементальное обновление, вычисляет дайджест по ByteRange, подписывает его и записывает результат — всё это до возврата из вызова. Это абсолютно верно, когда ключ хранится в хранилище сертификатов Windows или в загруженном вами файле PKCS#12

Это невозможно, когда ключ находится в сетевом HSM, квалифицированном устройстве создания подписи, управляемом доверенным поставщиком услуг, или в облачном API подписи, требующем подтверждения от пользователя на телефоне. В этих случаях последовательность действий — не вызов функции, а диалог: вы отправляете дайджест, что-то другое аутентифицирует человека, а CMS возвращается позже. Синхронный API не может выразить «позже», не блокируя поток на операции, которой может понадобиться второй фактор

Двухфазный протокол

Первая фаза готовит документ. PDFiumPas дописывает поле подписи и словарь значения, резервирует ContentsSize байт закодированного в hex пространства в /Contents, вычисляет ByteRange вокруг этого резервирования и формирует TPadesRemoteSigningRequest, содержащий FormatVersion, PreparedFingerprint, DocumentDigest, четырёхэлементный ByteRange, ContentsHexOffset и ContentsSize

Единственное значение, которое нужно вашей службе подписи, — это DocumentDigest: SHA-256, который возвращённая структура CAdES SignedData обязана нести как свой дайджест сообщения. Всё остальное в записи существует для того, чтобы вторая фаза могла доказать, что файл, который она завершает, — это именно тот файл, по которому был вычислен дайджест

uses
  FPdfPades;

var
  Options: TPadesRemoteSignOptions;
  Request: TPadesRemoteSigningRequest;
  Source, Prepared, Session: TFileStream;
begin
  Options := TPadesRemoteSignOptions.Default;
  Options.Reason := 'Approved by finance';
  Options.Location := 'Lisbon';
  Options.Name := 'A. Moreira';
  Options.SigningTimeUtc := NowUtc;
  Options.ContentsSize := 16384;   // зарезервированные hex-байты для CMS

  Source := TFileStream.Create('contract.pdf', fmOpenRead or fmShareDenyWrite);
  Prepared := TFileStream.Create('contract.prepared.pdf', fmCreate);
  try
    PreparePadesRemoteSignature(Source, Prepared, Options, Request);
  finally
    Prepared.Free;
    Source.Free;
  end;

  // Сохранить сессию, чтобы её мог завершить более поздний запуск — или другая машина
  Session := TFileStream.Create('contract.signreq', fmCreate);
  try
    SavePadesRemoteSigningRequest(Session, Request);
  finally
    Session.Free;
  end;

  SendDigestToSigningService(Request.DocumentDigest);
end;

Что отклоняет Complete и зачем нужна каждая проверка?

Именно на этапе завершения обычно и ломается дизайн удалённого подписания, поэтому проверка намеренно бескомпромиссна. CompletePadesRemoteSignature отклоняет: подготовленный PDF, чей отпечаток больше не совпадает с запросом; ByteRange, не совпадающий с зафиксированными координатами заполнителя; изменённые разделители /Contents; заполнитель, который уже не пуст; CMS крупнее резервирования; CMS, не являющийся ровно одним значением DER; неподдерживаемую форму SignedData; отсутствующий атрибут signing-certificate-v2; и CMS, чей дайджест сообщения не равен подготовленному дайджесту документа

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

Требование signing-certificate-v2 — это вопрос соответствия PAdES, а не целостности. ETSI EN 319 142 требует, чтобы сертификат подписи был связан с подписанными атрибутами, а CMS без этого атрибута не является подписью PAdES, даже если он криптографически проверяется. Отклонение на этапе завершения означает, что вы узнаёте об этом здесь, а не из отчёта валидатора клиента — тема, раскрытая подробнее в статье о том, почему валидаторы отклоняют подписи PAdES

var
  Request: TPadesRemoteSigningRequest;
  Session, Prepared, Dest: TFileStream;
  CmsDer: TBytes;
begin
  Session := TFileStream.Create('contract.signreq', fmOpenRead);
  try
    Request := LoadPadesRemoteSigningRequest(Session);
  finally
    Session.Free;
  end;

  CmsDer := FetchDetachedCmsFromService;   // возвращается HSM или TSP

  Prepared := TFileStream.Create('contract.prepared.pdf', fmOpenRead);
  Dest := TFileStream.Create('contract.signed.pdf', fmCreate);
  try
    try
      CompletePadesRemoteSignature(Prepared, Dest, Request, CmsDer);
    except
      on E: EPadesCrypto do
        // Каждый отказ несёт конкретную причину; логируйте её дословно
        FailSession(E.Message);
    end;
  finally
    Dest.Free;
    Prepared.Free;
  end;
end;

Пересечение границ процессов и машин

SavePadesRemoteSigningRequest и LoadPadesRemoteSigningRequest сериализуют сессию через стабильный версионированный бинарный формат, и именно это делает конструкцию практичной, а не только корректной. Веб-приложение может подготовить документ в одном запросе, сохранить подготовленный PDF и блоб сессии, вернуть дайджест браузеру для подписи смарт-картой и завершить файл в совершенно другом обработчике запроса

Именно поле FormatVersion обеспечивает безопасность этого при обновлениях. Сессия, записанная более старой сборкой и загруженная более новой, явно распознаётся или отклоняется, а не прочитывается неверно как запись другой формы. Если ваша очередь может хранить сессии днями, относитесь к версии формата как к операционному факту, достойному логирования, а не как к детали реализации

Расчёт размера заполнителя

ContentsSize — единственный параметр, над которым действительно нужно подумать, потому что он фиксируется до того, как CMS вообще существует. Он считает резервирование в hex-кодировке, поэтому 6-килобайтовому DER CMS требуется минимум 12 КБ пространства, а реализация ограничивает резервирование сверху 64 MiB

Зарезервируете слишком мало — и завершение упадёт с ошибкой превышения размера CMS уже после того, как ваша служба подписи проделала свою работу, а на тарифицируемой службе квалифицированной подписи это означает потраченную впустую операцию. Зарезервируете слишком много — и каждый подписанный документ будет вечно нести эту заполняющую избыточность. Разумный подход — измерить: подпишите один документ вашей реальной цепочкой сертификатов, посмотрите длину DER, удвойте её для hex, затем добавьте щедрый запас под токен временной метки, если планируете перейти на подпись уровня T. Цепочки с несколькими промежуточными сертификатами и длинным ответом OCSP растут быстрее, чем ожидают

Что следует после подписи

Завершённая удалённая подпись — это PAdES B-B. Долгосрочная проверка требует временной метки и материала валидации — отдельного инкрементального обновления, добавляющего DSS и его словари VRI для каждой подписи, описанного в статье о долгосрочных подписях с временными метками RFC 3161 и DSS. Этот шаг локален: он добавляет сертификаты, ответы OCSP и CRL, ни для одного из которых не нужен приватный ключ

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

PDFiumPas — это компонент для Delphi и Lazarus на основе движка PDFium со стеком PAdES, реализованным нативно на Pascal, поэтому подписание, простановка временных меток и проверка работают без внешних инструментов командной строки. Полная документация по API и пробная сборка доступны на странице PDFium Delphi component