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

Подписване на 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), но не съдържа възможност за подписване. Поради това всеки компонент, който подписва PDF с помощта на PDFium, трябва сам да генерира всеки байт от подписа. Ето защо компонента PDFium изгражда този механизъм от три чисти Pascal модула. FPC 3.2.2 се доставя с md5 и sha1, но без SHA-2, а API-то на Delphi System.Hash за SHA-256 не е съвместимо с FPC на ниво сорс код. Поради това FPdfSha256 е самостоятелна имплементация по стандарта FIPS 180-4, която поддържа всички CMS пътища в един тип TSHA256Digest без разклонения на компилатора. 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';   // документът за подписване
    Pdf.Active := True;
    // Втори аргумент: SHA-1 отпечатък на сертификат в хранилището
    // „MY“ на текущия потребител. Първи аргумент: дестинация за подписаното копие.
    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 показва в раздела Details, и идентифицира конкретен подписващ, без да се налага да поставяте ключове в сорс кода си

Какво влиза в 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...';  // подписващ в хранилището MY
    Opts.Reason := 'I approve this agreement';
    Opts.Location := 'Berlin, DE';
    Opts.ContentsSize := 16384;                        // шестнадесетична дължина на /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 се кодира в шестнадесетичен формат в резервираната позиция, допълва се с нули до фиксираната ширина и се добавя обновяване на таблицата с кръстосани връзки (cross-reference). Тъй като ширината е фиксирана предварително, запълването на позицията не измества нито един байт след нея, което запазва диапазона от байтове валиден. Оригиналните байтове на документа се запазват непроменени, така че предишен подпис в същия файл остава валиден, точно както изисква инкременталното подписване по ISO 32000-1 §12.8.1

Бекендът Windows CNG и неговите ограничения

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

var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
  Output: TFileStream;
begin
  if not PadesCryptoAvailable then
    Exit;   // на тази платформа липсва бекенд за подписване

  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 е базовата линия, най-ниското от четирите нива на PAdES: то доказва кой е подписал и че байтовете не са променяни след това, и нищо повече. Подписът B-B не съдържа доверен времеви отпечатък, така че не може да докаже кога точно е извършено подписването, и не вгражда данни за оттегляне, поради което след години проверяващият трябва сам да изтегли веригата от сертификати и техния статус. Тези пропуски се запълват именно от по-високите нива. Когато се нуждаете от време на подписване, което одитор би приел, добавянето на времеви отпечатък по RFC 3161 и DSS за дългосрочно валидиране издига подписа до ниво B-T и по-високо. Когато искате да прочетете готов подпис и да проверите нивото му, инспектирането на PDF подпис и неговото PAdES ниво е правилното средство, а преди да подпишете каквото и да било, статията за одит на рисковете за сигурността на PDF ви информира под какво поставяте името си

Методите SignPades, показани тук, се доставят с компонента PDFium Component за Delphi и C++Builder, заедно с вградената в PDFium инспекция на подписи само за четене