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

Подписи PAdES с SHA-3 по ISO/TS 32001 в Delphi

Вы переводите Delphi-подпись на SHA3-512, и OpenSSL отвергает результат: CMS заявляет SHA3-512, но её атрибут messageDigest держит 32 байта там, где алгоритм производит 64. Да, код на Delphi может подписывать SHA-3: SignPadesForDigest в PDFium Component принимает SHA3-256, SHA3-384 или SHA3-512 и записывает каждое зависящее от хеша поле CMS под этот единственный выбор, как того ждёт ISO/TS 32001

Что ISO/TS 32001 добавляет к подписи PDF 2.0

ISO/TS 32001 — короткое расширение к ISO 32000-2. Оно расширяет набор хеш-функций, которые может объявить подпись PDF. Базовый список сидит в таблице 260 ISO 32000-2 §12.8.3.1, и для субфильтров на базе CMS он останавливается на SHA-256, SHA-384, SHA-512 и RIPEMD-160, с SHA-1, объявленным устаревшим. Техническая спецификация добавляет семейство SHA-3 из FIPS 202. SHA-3 — губчатая конструкция, не разделяющая с SHA-2 никакой внутренней структуры, и в этом весь смысл для политики подписи, желающей иметь второе хеш-семейство на полке на случай, если SHA-2 когда-нибудь ослабнет. В CMS каждый вариант — идентификатор объекта на дуге NIST. Идентификаторы комбинированных алгоритмов подписи сидят сразу рядом:

TCmsDigestAlgorithmOID хешаВыходRSA PKCS#1 v1.5ECDSA
cdaSha2562.16.840.1.101.3.4.2.132 байта1.2.840.113549.1.1.111.2.840.10045.4.3.2
cdaSha3_2562.16.840.1.101.3.4.2.832 байта2.16.840.1.101.3.4.3.142.16.840.1.101.3.4.3.10
cdaSha3_3842.16.840.1.101.3.4.2.948 байт2.16.840.1.101.3.4.3.152.16.840.1.101.3.4.3.11
cdaSha3_5122.16.840.1.101.3.4.2.1064 байта2.16.840.1.101.3.4.3.162.16.840.1.101.3.4.3.12

В PDFium Component весь выбор — одно перечисление, TCmsDigestAlgorithm из модуля FPdfCms. Доверьте выбор ключу. Кривая NIST ждёт хеш собственной ширины, поэтому P-256 сочетается с SHA3-256, P-384 с SHA3-384 и P-521 с SHA3-512, а валидатор, применяющий политику кривых и хешей ISO/TS 32002, пометит любую другую пару недействительной. У RSA такого правила нет. Аргумент прочности короток в любом случае: ключ RSA-2048 даёт порядка 112 бит стойкости, RSA-3072 — около 128, так что SHA3-256 уже обгоняет ключ, а SHA3-512 на ключе RSA-2048 добавляет к CMS 32 байта и ничего к стойкости. Мы рекомендуем SHA3-256 для RSA, если письменная политика не требует большего варианта

Дополнения SHA-3 по ISO/TS 32001 для PDF-подписей из Delphi: ключи EC сочетаются с хешем той же ширины, а RSA по умолчанию получает SHA3-256 в PDFium Component
семейство SHA-3 входит в таблицу 260 вторым хеш-семейством, кривые EC сочетаются с совпадающей шириной, а SHA3-256 уже обгоняет ключ RSA-2048

Пять полей CMS, меняющихся вместе с хешем

Замена хеш-функции — самая малая часть работы. Отсоединённая CAdES-подпись называет свой хеш в пяти местах, и строгий верификатор сверяет их между собой. Если хоть одно всё ещё говорит SHA-256, подпись проваливается, даже когда арифметика RSA или ECDSA верна:

  1. digestAlgorithm в SignerInfo, отражённый в наборе digestAlgorithms SignedData, несёт OID хеша, например 2.16.840.1.101.3.4.2.9 для SHA3-384
  2. Подписанный атрибут messageDigest (RFC 5652) держит хеш двух диапазонов /ByteRange, поэтому его OCTET STRING длиной 32, 48 или 64 байта
  3. signatureAlgorithm называет комбинированную схему из таблицы выше. Исключение — RSASSA-PSS: его OID остаётся id-RSASSA-PSS (1.2.840.113549.1.1.10), а хеш переезжает в параметры
  4. Параметры RSASSA-PSS повторяют хеш дважды, как hashAlgorithm и как хеш MGF1, и выставляют saltLength в длину хеша. Опустите их — верификатор предположит SHA-1, MGF1 с SHA-1 и соль в 20 байт, а это другой алгоритм; здесь важны и правила тегирования RSASSA-PSS-params из RFC 4055
  5. Подписанный атрибут CMSAlgorithmProtection (RFC 6211) копирует идентификаторы хеша и алгоритма подписи в подписанные атрибуты, где никто не подменит их незамеченным. Он обязан совпадать с полями 1 и 3 байт в байт

Одно поле не меняется вовсе. ESSCertIDv2 внутри атрибута signing-certificate-v2 (RFC 5035) хеширует сертификат подписанта собственным hashAlgorithm, который по умолчанию SHA-256 и не зависит от хеша SignedData, поэтому PDFium Component держит его на SHA-256 в кодировке по умолчанию. Ошибка, всплывающая чаще прочих в рукописном коде CMS, — длина messageDigest. Код, написанный под SHA-256, склонен держать хеш в фиксированной 32-байтной записи и копировать её в атрибут. Прогоните через неё результат SHA3-512 — атрибут получит первые 32 байта, пока digestAlgorithm говорит .10: то самое отвержение OpenSSL из вступления. Та же ошибка живёт и ярусом ниже, где EVP_PKEY_sign OpenSSL берёт длину хеша как tbslen и сверяет её с набранным на контексте message digest, поэтому подписант, всегда передающий 32, получает ошибку длины, ни разу не упомянувшую SHA-3. PDFium Component считает хеш документа по тому алгоритму, какой объявил найденный подписант, и выводит атрибуты, PSS-параметры и атрибут защиты алгоритмов из того же объявления, так что ни одно поле не может расползтись от остальных

Пять полей CMS, меняющихся вместе с хешем SHA-3 в PAdES-подписи из Delphi, которые строгие верификаторы сверяют между собой, когда их записывает PDFium Component
digestAlgorithm, messageDigest, signatureAlgorithm, PSS-параметры и CMSAlgorithmProtection обязаны называть один хеш, а ESSCertIDv2 держит собственный SHA-256

Подпись локального PDF через SignPadesForDigest

На компоненте TPdf точки входа SHA-3 — SignPadesForDigest(const FileName: string; const Options: TPadesSignOptions; DigestAlgorithm: TCmsDigestAlgorithm): Boolean и её потоковая сестра SignPadesToStreamForDigest. Обе принимают ту же запись TPadesSignOptions, что и SignPades, и cdaSha256 даёт ту же подпись, что существующая перегрузка, так что один кодовый путь обслуживает оба семейства. На Windows отпечаток SHA-1 выбирает сертификат в хранилище Current User MY. Бэкенд OpenSSL на других платформах подписывает ключом и сертификатом, переданными ConfigureSslSigningCredentials, и отклоняет отпечаток. Проверьте и версию документа. ISO/TS 32001 расширяет PDF 2.0, а подпись входит инкрементным обновлением, сохраняющим исходный заголовок, поэтому доверяющая сторона, валидирующая по правилам PDF 2.0, требует файл 2.0 ещё до подписания

uses
  System.SysUtils, System.Classes, System.IOUtils,
  PDFium, FPdfCms, FPdfCrypto, FPdfPades
  {$IFDEF MSWINDOWS}, FPdfCryptoWin{$ELSE}, FPdfCryptoSsl{$ENDIF};

function Sha3SigningAvailable: Boolean;
begin
{$IFDEF MSWINDOWS}
  Result := WinSha3DigestAvailable;   // CNG-провайдеры SHA-3, Windows 11 24H2+
{$ELSE}
  Result := SslSha3SigningAvailable;  // False, пока учётные данные не настроены
{$ENDIF}
end;

procedure SignWithSha3(const SourceFile, SignedFile, Thumbprint: string;
  const KeyPassword: AnsiString; const Log: TProc<string>);
var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
  Signed: TMemoryStream;
begin
{$IFNDEF MSWINDOWS}
  // OpenSSL подписывает явно настроенным материалом; цепочки в этом наброске нет
  ConfigureSslSigningCredentials(
    TFile.ReadAllBytes('/etc/signing/signer-key.pem'),
    TFile.ReadAllBytes('/etc/signing/signer.der'), nil, KeyPassword);
{$ENDIF}
  if not Sha3SigningAvailable then
    raise Exception.Create('No SHA-3 capable signing backend on this machine');

  Opts := TPadesSignOptions.Default;          // B-B, RSASSA-PSS
{$IFDEF MSWINDOWS}
  Opts.CertificateThumbprint := Thumbprint;   // сертификат в хранилище Current User MY
{$ENDIF}
  Opts.Reason := 'Approved for release';

  Pdf := TPdf.Create(nil);
  Signed := TMemoryStream.Create;
  try
    Pdf.FileName := SourceFile;
    Pdf.Active := True;
    if Pdf.PdfVersion <> pv20 then
      Log(SourceFile + ' is not a PDF 2.0 file');
    try
      if Pdf.SignPadesToStreamForDigest(Signed, Opts, cdaSha3_256) then
        Signed.SaveToFile(SignedFile);
    except
      on E: EPadesCryptoUnavailable do
        Log('SHA-3 signing unavailable: ' + E.Message);
    end;
  finally
    Signed.Free;
    Pdf.Free;
  end;
end;

Когда бэкенд не может выдать SHA-3, вызов поднимает EPadesCryptoUnavailable вместо отката к SHA-256. Это ровно то поведение, которое нужно. SHA3-256 попросили не просто так — обычно из-за документа политики от доверяющей стороны, а SHA-256-подпись, произведённая на её месте, прошла бы все проверки у вас, чтобы через недели получить отказ у них, когда в вашей команде уже никто не помнит, какая машина подписывала. Ловите исключение там, где ещё можно объяснить оператору, на каком хосте не хватает алгоритма

Где доступен SHA-3: Windows CNG и OpenSSL

Доступность принадлежит машине. На Windows бэкенд по умолчанию — CNG, а CNG везёт провайдеров алгоритма SHA-3 начиная с Windows 11 24H2; на более ранних сборках BCryptOpenAlgorithmProvider для "SHA3-256" падает с STATUS_NOT_FOUND. WinSha3DigestAvailable открывает провайдеров SHA3-256, SHA3-384 и SHA3-512 один раз и кеширует ответ, потому что пока процесс жив, он измениться не может. Везде остальном подписывает бэкенд OpenSSL, и на что он способен, зависит от найденного libcrypto:

  • OpenSSL 3.x всегда экспортирует EVP_sha3_256, EVP_sha3_384 и EVP_sha3_512, поэтому подпись SHA-3 работает на любой платформе, где бэкенд загружается
  • Загрузчик принимает и библиотеку серии 1.1 (libcrypto-1_1.dll, libcrypto.so.1.1). SHA-3 появился в OpenSSL 1.1.1 и отсутствует в 1.1.0, так что на старых образах Linux решает SslSha3SigningAvailable
  • SslSha3SigningAvailable вдобавок требует настроенных учётных данных для подписи. Спрошенный до ConfigureSslSigningCredentials, он ответит False, даже когда SslLibraryVersion рапортует об отличной библиотеке 3.x. Это выглядит как отсутствующий бэкенд. Это лишь проблема порядка вызовов
  • 32-битный исполняемый файл не может загрузить libcrypto-3-x64.dll. Когда этот бэкенд поднимали, сборка Win32, нацеленная на x64 DLL, рапортовала SslAvailable = False, а SslMissingSymbols выдавал лишь путь к библиотеке — симптом, читающийся как сломанная привязка символов; причиной была разрядность, и DLL, совпадающая с исполняемым файлом, всё исправила

Парк Windows, в котором ещё бегают машины до 24H2, может держать CNG там, где есть SHA-3, а остальное вести через OpenSSL с провайдером подписанта. У IPadesSignerProvider два метода. ResolveSslSignerForDigest в FPdfCryptoSsl возвращает подписанта, чьи объявленные хеш, алгоритм подписи, длина хеша и PSS-соль следуют запрошенному алгоритму, а при настроенном провайдере ResolvePadesSignerForDigest проверяет, что возвращённый подписант объявляет хеш, который просил вызов, и поднимает EPadesCrypto, если не объявляет. Провайдер, собранный под SHA3-256, не отштампует запрос SHA3-384. Только провайдер действует на весь процесс, поэтому серверу, подписывающему параллельные запросы, стоит настроить его один раз на старте, а не на каждый вызов, как в этом наброске; ConfigureSslLibraryPath направляет загрузчик на libcrypto, когда DLL не лежит на пути поиска

Схема доступности подписи SHA-3 в Delphi: CNG-провайдеры Windows от 24H2, проверки версии и разрядности OpenSSL и отказной путь EPadesCryptoUnavailable в PDFium Component
доступность принадлежит машине, поэтому проверка живёт на старте: CNG там, где есть SHA-3, OpenSSL в остальных случаях, и никакого тихого отката к SHA-256
uses
  System.SysUtils, System.Classes, System.IOUtils,
  PDFium, FPdfCms, FPdfCrypto, FPdfCryptoWin, FPdfCryptoSsl, FPdfPades;

type
  TOpenSslSha3Provider = class(TInterfacedObject, IPadesSignerProvider)
  private
    FDigest: TCmsDigestAlgorithm;
  public
    constructor Create(ADigest: TCmsDigestAlgorithm);
    function BackendName: string;
    function Resolve(const Options: TPadesSignerOptions): TPadesResolvedSigner;
  end;

constructor TOpenSslSha3Provider.Create(ADigest: TCmsDigestAlgorithm);
begin
  inherited Create;
  FDigest := ADigest;
end;

function TOpenSslSha3Provider.BackendName: string;
begin
  Result := 'OpenSSL';
end;

function TOpenSslSha3Provider.Resolve(
  const Options: TPadesSignerOptions): TPadesResolvedSigner;
begin
  Result := ResolveSslSignerForDigest(Options, FDigest);
end;

procedure SignSha3OnAnyWindows(Pdf: TPdf; Dest: TStream;
  const Opts: TPadesSignOptions; const KeyPassword: AnsiString);
var
  Provider: IPadesSignerProvider;
  Chain: TPadesChainCertArray;
  SslOpts: TPadesSignOptions;
begin
  if WinSha3DigestAvailable then
  begin
    Pdf.SignPadesToStreamForDigest(Dest, Opts, cdaSha3_256);   // путь CNG
    Exit;
  end;

  Chain := nil;
  ConfigureSslSigningCredentials(
    TFile.ReadAllBytes('C:\Signing\signer-key.pem'),
    TFile.ReadAllBytes('C:\Signing\signer.der'), Chain, KeyPassword);
  if not SslSha3SigningAvailable then
    raise Exception.Create('No SHA-3 in loaded OpenSSL ' + SslLibraryVersion);

  SslOpts := Opts;
  SslOpts.CertificateThumbprint := '';   // OpenSSL подписывает настроенным сертификатом
  Provider := TOpenSslSha3Provider.Create(cdaSha3_256);
  ConfigurePadesSignerProvider(Provider);
  try
    Pdf.SignPadesToStreamForDigest(Dest, SslOpts, cdaSha3_256);
  finally
    ConfigurePadesSignerProvider(nil);
  end;
end;

Как удалённый или HSM-подписант узнаёт хеш?

Удалённое подписание делит работу надвое. PreparePadesRemoteSignature записывает PDF с пустым слотом /Contents плюс TPadesRemoteSigningRequest, а владелец ключа позже возвращает отсоединённый CMS, часто из другого процесса или с другой машины, через сессию подготовки и завершения, используемую для ключей HSM и облачных. На стороне подготовки вы задаёте TPadesRemoteSignOptions.DigestAlgorithm, скажем cdaSha3_384, и запрос фиксирует выбор в TPadesRemoteSigningRequest.DigestAlgorithm рядом с DocumentDigest — значением, которое обязан нести CMS messageDigest. С этого момента запрос — последняя инстанция. CompletePadesRemoteSignature пересчитывает хеш /ByteRange алгоритмом, сохранённым в запросе, и больше к опциям не обращается; иначе смена конфигурации между подготовкой и завершением судила бы старый запрос по новому алгоритму. Сервис подписи, построенный на компоненте, должен брать алгоритм оттуда же:

uses
  System.SysUtils, System.Classes,
  FPdfCms, FPdfCrypto, FPdfCryptoSsl, FPdfPades,
  FPdfPadesRemote;   // подключает реализацию Enterprise

function SignRemoteRequest(const RequestFile: string;
  Padding: TPadesPaddingScheme): TBytes;
var
  Stream: TFileStream;
  Request: TPadesRemoteSigningRequest;
  SignerOpts: TPadesSignerOptions;
  Resolved: TPadesResolvedSigner;
begin
  Stream := TFileStream.Create(RequestFile, fmOpenRead or fmShareDenyWrite);
  try
    Request := LoadPadesRemoteSigningRequest(Stream);
  finally
    Stream.Free;
  end;

  SignerOpts := TPadesSignerOptions.Default;   // пустой отпечаток для OpenSSL
  SignerOpts.PaddingScheme := Padding;
  // Хеш решает запрос, а не конфигурация этого сервиса
  Resolved := ResolveSslSignerForDigest(SignerOpts, Request.DigestAlgorithm);
  Result := BuildSignedDataEx(Request.DocumentDigest, Resolved.Signer,
    Resolved.SignerCertDer, Resolved.ChainCertDer, TCmsBuildOptions.Default);
end;

Внутри запроса длина хеша — единственная улика об алгоритме. Она заодно служит отпечатком. Прежде чем читать какой-либо поток, CompletePadesRemoteSignature сравнивает Length(DocumentDigest) с CmsDigestByteLength(DigestAlgorithm), поэтому обрезанный хеш или переименованный алгоритм падают на этом месте, а поток назначения не затрагивается вовсе. Одна длина не отличит SHA3-256 от SHA-256 — оба 32 байта, поэтому значение хеша вдобавок сверяется с messageDigest внутри возвращённого CMS. Подготовленный PDF, напротив, байт в байт одинаков для SHA-256 и SHA3-384: заполнитель держит только числа /ByteRange и никакого хеша. Одинаковые файлы легко прочесть неверно как «настройку игнорируют». Удалённое подписание поставляется в редакции Enterprise через модуль FPdfPadesRemote

Вариант хеша редко бывает трудным решением: для ключа RSA это обычно cdaSha3_256, а в продакшене ломается хост, чей CNG или libcrypto не может его выдать, — потому проверка доступности и живёт на старте приложения в любом развёртывании PDFium Component для Delphi