Technical Article

Подписание PDF-файлов с уровнем PAdES B-B в Delphi с помощью PDFium

Компонент PDFium Component подписывает PDF-файл цифровой подписью уровня PAdES B-B с помощью метода SignPades: он загружает документ, вычисляет хэш подписанного диапазона байтов, строит структуру CMS CAdES и добавляет подпись в виде инкрементного сохранения. Криптографический бэкэнд поддерживается только под Windows, поэтому проверяйте возможность подписания с помощью свойства PadesCryptoAvailable

Сценарий знаком многим. На столе лежит договор в формате PDF, юридический отдел требует подписать его цифровой подписью перед отправкой, и вы открываете ту же сборку PDFium, которую используете для отображения и анализа документов, но обнаруживаете, что API подписей в PDFium доступен только для чтения. Движок не имеет средств создания подписей. Компонент PDFium Component решает эту проблему, реализуя весь процесс подписания на коде Pascal (от вычисления хэша до побайтовой записи в файл). В этой статье мы подробно разберем весь этот процесс

Почему PDFium не поддерживает создание подписей?

В PDFium подписи представлены объектами только для чтения, средств создания новых подписей нет. Функции семейства FPDFSignatureObj_* позволяют находить существующие подписи, считывать параметр /Contents и анализировать диапазон /ByteRange, но нет методов создания словаря подписи, резервирования области под значение или записи диапазона байтов; хотя инкрементное сохранение присутствует (FPDF_SaveAsCopy с флагом FPDF_INCREMENTAL), оно не содержит механизмов подписания. Любой компонент, добавляющий подпись в PDF поверх PDFium, должен генерировать каждый байт подписи самостоятельно. Именно поэтому PDFium Component реализует эту логику в трех модулях на чистом Pascal. Поскольку FPC 3.2.2 поддерживает md5 и sha1, но не имеет SHA-2, а API SHA-256 в Delphi (System.Hash) несовместим с FPC, модуль FPdfSha256 предлагает независимую реализацию стандарта FIPS 180-4, сохраняя код CMS на типе TSHA256Digest без ветвления компиляторов. Модуль FPdfAsn1 реализует кодирование и чтение DER-структур, необходимых для CMS, а FPdfCms собирает на их основе контейнер CAdES SignedData

Как подписать файл PDF цифровой подписью в Delphi?

Загрузите документ и вызовите метод SignPades с указанием отпечатка (thumbprint) сертификата. Компонент PDFium Component найдет этот сертификат в системном хранилище Current User "MY", извлечет его закрытый ключ и запишет подписанную копию по указанному пути

uses
  PDFium, FPdfCrypto;

procedure SignContract(const AThumbprint: string);
var
  Pdf: TPdf;
begin
  if not PadesCryptoAvailable then
    raise Exception.Create('PAdES signing requires the Windows CNG backend');

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';   // the document to sign
    Pdf.Active := True;
    // Second argument: SHA-1 thumbprint of a certificate in the Current
    // User "MY" store. First argument: destination for the signed copy.
    if not Pdf.SignPades('contract-signed.pdf', AThumbprint) then
      raise Exception.Create('Signing failed');
  finally
    Pdf.Free;
  end;
end;

Свойство PadesCryptoAvailable — первая обязательная проверка. В среде Windows оно возвращает True и активирует бэкэнд crypt32/ncrypt; на других платформах оно вернет False, а вызов метода подписания приведет к ошибке EPadesCrypto. Проверка этого свойства гарантирует, что сборки под Linux или macOS не упадут во время выполнения на неподдерживаемом коде. Отпечаток сертификата представляет собой SHA-1 хэш сертификата (то же значение, которое показывает диспетчер сертификатов Windows на вкладке «Состав»), позволяя указать подписанта без хранения ключевой информации в исходном коде программы

Что содержит контейнер CMS: подписанные атрибуты и RFC 5652

Базовая подпись PAdES не является простым результатом шифрования хэша файла алгоритмом RSA; это структура CAdES CMS SignedData, содержащая обязательный набор подписанных атрибутов. Метод FPdfCms.BuildSignedData формирует именно этот набор: тип содержимого (content-type), хэш сообщения (message-digest) и сертификат подписанта (signing-certificate-v2) — атрибут ESS, связывающий подпись с хэшем сертификата. Здесь есть одна важная деталь, о которую спотыкаются почти все ручные реализации CMS. Стандарт RFC 5652 §5.4 требует, чтобы хэш подписанных атрибутов вычислялся для кодировки DER SET OF (тег 0x31), тогда как те же атрибуты записываются в структуру SignerInfo под тегом IMPLICIT [0] (0xA0). Компонент PDFium Component кодирует множество атрибутов один раз, вычисляет хэш для формы 0x31, а затем просто заменяет первый байт тега на 0xA0 при записи, позволяя использовать один буфер для обеих задач без повторного обхода дерева данных

var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;

    Opts := TPadesSignOptions.Default;
    Opts.CertificateThumbprint := 'a1b2c3d4e5f6...';  // signer in the MY store
    Opts.Reason := 'I approve this agreement';
    Opts.Location := 'Berlin, DE';
    Opts.ContentsSize := 16384;                        // hex width of /Contents

    if not Pdf.SignPades('contract-signed.pdf', Opts) then
      raise Exception.Create('Signing failed');
  finally
    Pdf.Free;
  end;
end;

Перегрузка с параметрами позволяет указывать метаданные словаря подписи по спецификации ISO 32000-1 §12.8.1: Reason, Location, ContactInfo и Name (все они являются необязательными и записываются в словарь подписи). Однако есть важное ограничение. Если вы задаете параметр CommitmentTypeOid для добавления подписанного атрибута типа обязательства CAdES, не указывайте свойство Reason; стандарт ETSI EN 319 142-1 §6.3 запрещает одновременное использование обоих параметров, так как они выражают одно намерение разными средствами

Взаимодействие ByteRange и области /Contents

Подпись должна покрывать весь файл, за исключением байтов самой подписи. PAdES решает эту циклическую зависимость выделением области фиксированного размера, которой управляет метод SignPadesBytes. Он резервирует в параметре /Contents hex-строку длиной ContentsSize байт (по умолчанию 16384, что гарантированно больше типичной структуры CMS SignedData), сериализует инкрементное обновление для определения точного смещения области, а затем вычисляет диапазон /ByteRange в виде двух диапазонов по краям этой области (все байты до открывающего маркера hex-строки и все байты после закрывающего маркера). Хэширование SHA-256 выполняется только по этим двум диапазонам. Готовая структура CMS кодируется в шестнадцатеричном виде в зарезервированную область с заполнением нулями до фиксированной ширины, после чего дописывается таблица обновлений перекрестных ссылок. Так как ширина области зафиксирована заранее, запись подписи не сдвигает последующие байты, сохраняя валидность диапазона; исходные байты документа сохраняются без изменений, что позволяет не нарушать целостность ранее наложенных подписей в соответствии с требованиями ISO 32000-1 §12.8.1

Windows CNG бэкэнд и его ограничения

Компонент PDFium Component подписывает файлы только в среде Windows, и это ограничение осознанно. Модуль FPdfCryptoWin динамически загружает библиотеки crypt32.dll и ncrypt.dll, не добавляя внешних зависимостей на этапе компиляции, а цепочка вызовов является стандартной для CNG: открывается хранилище MY, выполняется поиск сертификата по хэшу, извлекается дескриптор закрытого ключа через CryptAcquireCertificatePrivateKey и вызывается метод NCryptSignHash. Поддерживаются алгоритмы RSA с PKCS#1 v1.5, RSA-PSS и ECDSA. Алгоритму ECDSA требуется дополнительное преобразование, так как NCryptSignHash возвращает пару r и s по стандарту IEEE P1363, в то время как CMS ожидает структуру DER ECDSA-Sig-Value SEQUENCE, поэтому бэкэнд перекодирует ее по спецификации RFC 5480

var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
  Output: TFileStream;
begin
  if not PadesCryptoAvailable then
    Exit;   // no signing backend on this platform

  Opts := TPadesSignOptions.Default;
  Opts.CertificateThumbprint := ReadThumbprintFromConfig;

  Pdf := TPdf.Create(nil);
  Output := TFileStream.Create('contract-signed.pdf', fmCreate);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;
    Pdf.SignPadesToStream(Output, Opts);
  finally
    Output.Free;
    Pdf.Free;
  end;
end;

В результате закрытый ключ должен обязательно находиться в системном хранилище сертификатов Windows. Сертификат из PFX-файла можно использовать только после его импорта в хранилище Current User, после чего его отпечаток передается в метод SignPades. В текущей версии нет поддержки PKCS#11 или HSM, а также программных ключей из файлов, поэтому при значении PadesCryptoAvailable = False функция подписания на компьютере работать не будет

Границы возможностей уровня PAdES B-B

Уровень PAdES B-B является базовым начальным уровнем: он доказывает автора подписи и неизменность байтов документа с момента подписания, но не более того. Подпись уровня B-B не содержит доверенного штампа времени (поэтому не может доказать точное время подписания) и не несет данных проверки статуса сертификатов (поэтому проверяющая сторона должна самостоятельно запрашивать статусы цепочки сертификатов в будущем). Эти ограничения снимаются на более высоких уровнях. Если вам требуется фиксация времени для аудита, добавление штампа времени RFC 3161 и DSS переводит подпись на уровни B-T и выше; если вам нужно прочитать подпись и определить ее уровень, используйте методы из статьи об анализе подписей PDF и уровней PAdES; а перед подписанием документов полезно запустить проверки, описанные в материале об аудите угроз безопасности PDF

Описанные здесь методы SignPades поставляются в составе компонента PDFium Component для Delphi и C++Builder наряду с функциями чтения подписей, доступными из коробки