Вы переводите 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. Идентификаторы комбинированных алгоритмов подписи сидят сразу рядом:
TCmsDigestAlgorithm | OID хеша | Выход | RSA PKCS#1 v1.5 | ECDSA |
|---|---|---|---|---|
cdaSha256 | 2.16.840.1.101.3.4.2.1 | 32 байта | 1.2.840.113549.1.1.11 | 1.2.840.10045.4.3.2 |
cdaSha3_256 | 2.16.840.1.101.3.4.2.8 | 32 байта | 2.16.840.1.101.3.4.3.14 | 2.16.840.1.101.3.4.3.10 |
cdaSha3_384 | 2.16.840.1.101.3.4.2.9 | 48 байт | 2.16.840.1.101.3.4.3.15 | 2.16.840.1.101.3.4.3.11 |
cdaSha3_512 | 2.16.840.1.101.3.4.2.10 | 64 байта | 2.16.840.1.101.3.4.3.16 | 2.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, если письменная политика не требует большего варианта
Пять полей CMS, меняющихся вместе с хешем
Замена хеш-функции — самая малая часть работы. Отсоединённая CAdES-подпись называет свой хеш в пяти местах, и строгий верификатор сверяет их между собой. Если хоть одно всё ещё говорит SHA-256, подпись проваливается, даже когда арифметика RSA или ECDSA верна:
digestAlgorithmв SignerInfo, отражённый в набореdigestAlgorithmsSignedData, несёт OID хеша, например 2.16.840.1.101.3.4.2.9 для SHA3-384- Подписанный атрибут
messageDigest(RFC 5652) держит хеш двух диапазонов/ByteRange, поэтому его OCTET STRING длиной 32, 48 или 64 байта signatureAlgorithmназывает комбинированную схему из таблицы выше. Исключение — RSASSA-PSS: его OID остаётся id-RSASSA-PSS (1.2.840.113549.1.1.10), а хеш переезжает в параметры- Параметры RSASSA-PSS повторяют хеш дважды, как
hashAlgorithmи как хеш MGF1, и выставляютsaltLengthв длину хеша. Опустите их — верификатор предположит SHA-1, MGF1 с SHA-1 и соль в 20 байт, а это другой алгоритм; здесь важны и правила тегирования RSASSA-PSS-params из RFC 4055 - Подписанный атрибут 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-параметры и атрибут защиты алгоритмов из того же объявления, так что ни одно поле не может расползтись от остальных
Подпись локального 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 не лежит на пути поиска
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