Технічна стаття

Підписання PDF-файлів у форматі PAdES B-B в Delphi за допомогою PDFium

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

Ситуація знайома. Вам передають договір у форматі PDF, юристи вимагають підписати його цифровим підписом перед відправкою, і ви намагаєтеся використати ту саму збірку PDFium, яку вже використовуєте для рендерингу та перевірки документів, але виявляєте, що PDFium взагалі не вміє записувати підписи. Його API для підписів призначене виключно для читання. Компонент PDFium закриває цю прогалину, реалізуючи весь конвеєр підписання на Pascal — від розрахунку хешу до побайтового запису. У цій статті детально описано весь цей процес.

Чому PDFium не може записувати цифровий підпис?

PDFium представляє підписи як об'єкти лише для читання і не містить засобів для їх створення. Сімейство FPDFSignatureObj_* дозволяє перелічити існуючі підписи, прочитати значення /Contents та перевірити /ByteRange, але немає аналогів для створення словника підпису, резервування місця під /Contents чи запису діапазону байтів. Інкрементне збереження (метод FPDF_SaveAsCopy з прапорцем FPDF_INCREMENTAL) хоч і присутнє, але не має механізмів для підписання. Будь-який компонент, який реалізує підписання поверх PDFium, має самостійно генерувати кожен байт підпису. Саме тому компонент PDFium містить три модулі на чистому Pascal. FPC 3.2.2 постачається з підтримкою md5 та sha1, але взагалі не підтримує SHA-2, а API System.Hash SHA-256 у Delphi не є сумісним із FPC. Тому модуль FPdfSha256 містить автономну реалізацію стандарту FIPS 180-4, що використовує єдиний тип TSHA256Digest для всіх шляхів коду CMS без умовної компіляції. Модуль FPdfAsn1 реалізує кодування та читання DER, необхідні для CMS, а FPdfCms будує CAdES SignedData поверх обох рішень.

Як підписати PDF цифровим підписом в Delphi?

Завантажте документ, а потім викличте метод SignPades із відбитком (thumbprint) сертифіката. Компонент PDFium шукає цей відбиток у сховищі сертифікатів поточного користувача «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 кодує набір атрибутів один раз, обчислює хеш для форми 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 commitment-type-indication, не задавайте при цьому властивість Reason. Стандарт ETSI EN 319 142-1 §6.3 забороняє одночасне використання обох параметрів, оскільки вони виражають один намір різними способами.

Як пов'язані ByteRange та простір /Contents?

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

CNG-бекенд Windows та його обмеження

Компонент PDFium виконує підписання лише на 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 працюватиме лише після його імпорту до сховища поточного користувача, після чого його відбиток можна передавати в метод 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 разом із вбудованими засобами читання підписів PDFium.