PDF с подписью ML-DSA-65 может пройти проверку инструментария, который его породил, и всё равно вернуться как неподдерживаемый алгоритм от верификатора, требующего, чтобы файл объявил, какое расширение определяет его алгоритм. Публичный ключ — 1 952 байта, ровно размер FIPS 204 для этого набора параметров. Байты подписи целы, диапазоны байтов покрывают весь файл. Такой верификатор ищет одну запись в Catalog документа, называющую это расширение, потому что ISO 32000-2 вообще не упоминает ML-DSA
У подписывающей стороны другая стена. Аппаратный токен, облачный HSM или национальная eID-карта вычислят подпись по запросу, но ни один не отдаст закрытый ключ вашему приложению, так что любой подписывающий код, рассчитанный загрузить PFX-файл и подписать в своём процессе, просто не имеет ничего для загрузки. Обе проблемы — о том, где живёт решение: объявление алгоритма живёт в файле, а ключ живёт там, куда вашему Delphi-коду не дотянуться
Почему валидная подпись ML-DSA возвращается как неподдерживаемая?
HotPDF верифицирует ML-DSA-44, ML-DSA-65 и ML-DSA-87 CMS-подписи только когда загруженный документ объявляет developer-расширение HotPDF в своём Catalog. Без этого объявления верификатор возвращает svUnsupportedAlgorithm для решёточной подписи, как бы безупречна ни была криптография. Рассуждение — воспроизводимость: файл, использующий алгоритм, которого базовый стандарт не называет, и не говорящий, на какое расширение он опирается, даёт вердикт, который другая реализация не имеет оснований повторить
На производящей стороне THotPDF.EnsureMLDSAExtensions пишет /Extensions << /HotPDF << /BaseVersion /2.0 /ExtensionLevel 1 >> >> в Catalog и поднимает документ до PDF 2.0, если он целился в более старую версию. На читающей стороне LoadedDocumentDeclaresMLDSAExtension возвращает True только когда тот словарь присутствует с /BaseVersion равным 2.0 и /ExtensionLevel не меньше 1. VerifyLoadedSignatureWithOptions сочетает оба: берёт флаг AllowMLDSA вызывающего и AND-ит его с тестом объявления до запуска любой верификации, так что установка флага на необъявленном файле ничего не меняет
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'contract-pq.pdf';
Pdf.StrictVersionLock := False; // по умолчанию; позволяет вызову поднять файл до PDF 2.0
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 11);
Pdf.CurrentPage.TextOut(50, 720, 0, 'Supply agreement 2026-114');
Pdf.EnsureMLDSAExtensions; // объявить, пока никакая подпись не накрыла Catalog
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
Две детали в этом фрагменте несут вес. Вызов должен случиться до сохранения и подписания документа, потому что Catalog сидит внутри подписанного диапазона байтов; вписать его потом — либо неподписанное изменение подписанного файла, либо новая инкрементальная ревизия, которую любой валидатор занесёт как модификацию. Вторая деталь — StrictVersionLock. При включённом замке и документе, приколоченном ниже PDF 2.0, EnsureMLDSAExtensions возвращается, ничего не записав: PDF 2.0-расширение нельзя объявить в файле 1.7. Если ваш генератор приколачивает версии ради архивных требований, проверяйте Version = pdf20, прежде чем полагаться на объявление
Один вызов верификации для EdDSA и ML-DSA
VerifyLoadedSignatureWithOptions принимает индекс подписи, поток источника, запись THPDFCMSVerifyOptions и out-параметр THPDFSignatureInfo. Стартуйте запись от THPDFCMSVerifyOptions.Default, который оставляет AllowMLDSA выключенным, ограничивает CMS-контейнер 256 подписавшими и лимитирует вложенность countersignature до 8. Для новых алгоритмов важны три поля. SignatureProvider позволяет вашему собственному верификатору ответить первым. OpenSSLLibraryPath называет OpenSSL 3 libcrypto, который выполняет верификацию Ed25519 и Ed448 в pure-режиме. AllowMLDSA включает решёточные алгоритмы, с оглядкой на описанный выше тест Catalog. Запись носит и поля пиннинга политики подписи (RequireSignaturePolicy, ExpectedSignaturePolicyOID, ExpectedSignaturePolicyHash), которые действуют на все семейства алгоритмов
Встроенный путь ветвится по алгоритму. Ed25519 и Ed448 идут через OpenSSL 3; когда путь не задан, платформенный провайдер пробует libcrypto-3-x64.dll, libcrypto-3.dll и libcrypto.dll через обычный порядок поиска DLL на Win64. ML-DSA идёт через алгоритм-провайдер Windows CNG ML-DSA, и на сборке Windows, которая его не предлагает, результат — unsupported. Задайте OpenSSLLibraryPath на копию, которую вы кладёте рядом с исполняемым файлом. Оставите пустым — вердикт будет зависеть от того, какой libcrypto случился сбросить на путь поиска чужой установщик, а это трудно объяснить на аудите
Ваш собственный SignatureProvider спрашивается первым. Если он вернул spsUnsupported, тот же запрос получает встроенный платформенный провайдер, поэтому провайдер, знающий только ML-DSA, может встать перед стандартной обработкой EdDSA, не переизобретая её
var
Pdf: THotPDF;
PQ: THPDFCallbackSignatureProvider;
Opts: THPDFCMSVerifyOptions;
Info: THPDFSignatureInfo;
Status: THPDFSignatureVerifyStatus;
Src: TFileStream;
begin
PQ := THPDFCallbackSignatureProvider.Create(
function(const Req: THPDFSignatureProviderRequest): THPDFSignatureProviderStatus
begin
if not (Req.Algorithm in [hsaMLDSA44, hsaMLDSA65, hsaMLDSA87]) then
Exit(spsUnsupported); // передать EdDSA платформенному провайдеру
// MyFips204Verify — ваша собственная обёртка над ML-DSA-библиотекой;
// Req.Input содержит DER-кодированные подписываемые атрибуты (pure-режим)
Result := MyFips204Verify(Req.AlgorithmOID, Req.PublicKey,
Req.Input, Req.Signature);
end);
Pdf := THotPDF.Create(nil);
Src := TFileStream.Create('contract-pq.pdf', fmOpenRead or fmShareDenyNone);
try
Pdf.LoadFromFile('contract-pq.pdf');
Opts := THPDFCMSVerifyOptions.Default;
Opts.SignatureProvider := PQ;
Opts.OpenSSLLibraryPath := ExtractFilePath(ParamStr(0)) + 'libcrypto-3-x64.dll';
Opts.AllowMLDSA := True; // всё ещё AND с тестом объявления в Catalog
Status := Pdf.VerifyLoadedSignatureWithOptions(0, Src, Opts, Info);
if Status = svValid then
Memo1.Lines.Add(string(Info.SignatureAlgorithm) + ' / ' +
string(Info.SignatureAlgorithmOID));
finally
Src.Free;
Pdf.Free;
PQ.Free; // запись опций им не владеет
end;
end;
Логируйте Info.SignatureAlgorithmOID, а не тот алгоритм, которого ждала ваша конфигурация. Для ML-DSA-65 это 2.16.840.1.101.3.4.3.18, и аудиторский след, записавший OID, найденный в файле, ответит на вопросы позже, на которые значение из конфигурации не способно. Если кастомный провайдер вообще не нужен, более короткая перегрузка VerifyLoadedSignature(SigIndex, Source, Info) соберёт опции по умолчанию и выставит AllowMLDSA по объявлению в Catalog за вас
Правила кодирования, на которых спотыкаются чужие ML-DSA и EdDSA CMS
Оба семейства алгоритмов верифицируются в pure-режиме поверх полных DER-кодированных подписываемых атрибутов, и SignerInfo без подписываемых атрибутов рапортуется как unsupported. Для EdDSA параметры AlgorithmIdentifier должны отсутствовать. Частая ошибка в рукопашном CMS — сперва хешировать подписываемые атрибуты SHA-512 или SHAKE256 и подписывать дайджест: это тихо превращает PureEdDSA в prehash-вариант, который CMS нигде не объявляет. Для ML-DSA верификатор ещё проверяет пару «набор параметров — дайджест сообщения»: ML-DSA-44 принимает SHA-256, SHA-384, SHA-512 или SHAKE256, ML-DSA-65 требует SHA-384 или сильнее, ML-DSA-87 — SHA-512 или SHAKE256. Несогласованная пара всплывает в Info.DigestConsistencyIssue — первое поле, которое стоит читать, когда файл из чужого инструментария не проходит
У EdDSA своя история регистрации. ISO/TS 32002 — расширение, регистрирующее Ed25519 и Ed448 для подписей PDF 2.0, и EnsureISO32002Extensions добавляет соответствующую запись ISO_ с ExtensionLevel 32002 в Catalog. Верификация принимает EdDSA и без той записи, но файл, который вы производите, должен её нести — по той же причине воспроизводимости, что и объявление ML-DSA
Что просят сделать провайдера подписи
Провайдер подписи получает один THPDFSignatureProviderRequest и возвращает THPDFSignatureProviderStatus плюс, при успехе, байты подписи. Запрос несёт алгоритм и его OID, OID алгоритма дайджеста, длину соли PSS, является ли Input сообщением (sikMessage) или предвычисленным дайджестом (sikDigest), байты входа, сертификат подписанта, идентификатор ключа и идентификатор операции, уникальный для вызова. Это тот словарь, на котором драйвер токена или сервис подписи уже говорит, и ничто из него не требует, чтобы ключ пересёк границу вашего процесса
Каждый провайдер также анонсирует набор возможностей вроде spcSign, spcCancel, spcHardwareKey и spcRemoteKey. В библиотеке поставляется несколько реализаций:
THPDFCallbackSignatureProviderоборачивает анонимные методы для верификации, подписания и отмены — кратчайший путь от существующей внутренней подпрограммы подписи к PDF-подписиTHPDFRemoteSignatureProviderоборачивает транспортную функцию с лимитом повторов, реестром отмен и границами на размер входа и подписи, так что плохо ведущий себя удалённый сервис не продавит в CMS-энкодер чрезмерный ответTHPDFPKCS11SignatureProviderзаимствует сессию и хендл закрытого ключа, которые ваш код уже открыл и залогинил. HotPDF никогда не видит PIN и не управляет сессией токена; он сериализует вызовыC_Sign, дописывает DigestInfo вперед для RSA PKCS#1 v1.5, передаёт реальную длину соли в RSA-PSS через параметры механизма и превращает сырой ECDSA-результатr||sв DER-форму, которой ждёт CMSTHPDFWindowsKeyStorageSignatureProviderподписывает через хранилище ключей Windows CNG, что покрывает смарт-карты и ключи с опорой на TPM, и может требовать аппаратно-опирающийся ключ
PKCS#11-провайдер принимает вход-дайджест, поэтому ведает RSA PKCS#1 v1.5, RSA-PSS и ECDSA. Ed25519 и Ed448 подписывают полное сообщение подписываемых атрибутов, и они идут через callback- или remote-провайдер, где ваш код передаёт Req.Input токену или сервису как сообщение
var
Provider: THPDFRemoteSignatureProvider;
Opts: THPDFCMSSignOptions;
CertDER: TBytes;
InStream, OutStream: TFileStream;
begin
CertDER := TFile.ReadAllBytes('signer.cer'); // System.IOUtils; DER-сертификат удалённого ключа
Provider := THPDFRemoteSignatureProvider.Create(
function(const Req: THPDFSignatureProviderRequest; Attempt: Integer;
out Signature: TBytes): THPDFSignatureProviderStatus
begin
// PostToSigningService — ваш собственный HTTP-клиент: таймауты и 5xx отображайте в
// spsProviderError, отклонённое одобрение — в spsCancelled, незнакомый ключ — в spsUnsupported
Result := PostToSigningService(Req.KeyIdentifier, Req.AlgorithmOID,
Req.Input, Signature);
end,
3, // RetryLimit: до четырёх попыток в сумме
1048576, // MaxInputBytes
65536); // MaxSignatureBytes
Opts := HPDFCMSDefaultOptions(palBaseline_B_B);
Opts.SignatureScheme := cmsRSAPSS;
Opts.DigestAlgorithm := cmsdaSHA256;
Opts.PSSSaltLength := 32;
// contract-unsigned.pdf сохранён с плейсхолдером от AddSignedSignatureField
InStream := TFileStream.Create('contract-unsigned.pdf', fmOpenRead or fmShareDenyWrite);
OutStream := TFileStream.Create('contract-signed.pdf', fmCreate);
try
HPDFCMSSignPDFStreamWithProvider(InStream, OutStream, CertDER,
Provider, 'contracts-2026', Opts);
finally
OutStream.Free;
InStream.Free;
Provider.Free;
end;
end;
Шесть статусов и что каждый должен запускать
У THPDFSignatureProviderStatus шесть значений, потому что правильная реакция для каждого своя. spsInvalid — криптографический провал, ему место в журнале безопасности. spsUnsupported — провайдер не реализует алгоритм, это проблема развёртывания. spsMalformed — запрос или ответ имели неверную форму. spsProviderError — транспортный или аппаратный сбой, который может пройти на повторе. spsCancelled — человек отклонил запрос токена, и повтор лишь раздражает
Remote-провайдер строит эту политику внутрь. Он повторяет только на spsProviderError, проверяет реестр отмен перед каждой попыткой, отвергает пустой или чрезмерный вход как spsMalformed до вызова транспорта и понижает ответ «валидно» с пустой или чрезмерной подписью до spsMalformed. На стороне верификации ответ провайдера отображается на статус верификации: spsInvalid становится svSignatureInvalid, spsMalformed — svMalformed, spsProviderError и spsCancelled оба становятся svCryptoProviderError, а spsUnsupported — svUnsupportedAlgorithm, когда платформенный провайдер тоже отказал в запросе
Пишете провайдера — не соблазняйтесь отображать каждую неожиданную ситуацию в spsInvalid. Это превращает отсутствующую DLL или истёкший токен сервиса в рапорт, что подпись клиента подделана, и кому-то придётся объяснять это клиенту. Возвращайте spsInvalid только когда математика действительно провалилась. Для подписания возвращайте spsValid только вместе с непустой подписью; оба входа поднимают EHPDFCMSError, когда провайдер вернул успех без байтов
Куда ложится подпись провайдера в файле
Две функции соединяют провайдера с реальными байтами PDF. HPDFCMSBuildSignedDataWithProvider строит detached CMS из дайджеста документа, посчитанного где-то ещё; длина дайджеста должна совпадать с Options.DigestAlgorithm, поэтому конфигурация SHA-384 ждёт 48 байтов и отвергает 32-байтовый дайджест SHA-256 исключением. HPDFCMSSignPDFStreamWithProvider подписывает плейсхолдер, который разметил AddSignedSignatureField, хеширует оба окна /ByteRange, спрашивает у провайдера подпись над подписываемыми атрибутами и вписывает hex-результат в /Contents. Если CMS перерастает плейсхолдер, вызов поднимает исключение и восстанавливает неподписанный плейсхолдер в выходном потоке вместо того, чтобы что-то обрезать
Раскладка та же, что пишет путь подписания на PFX: два диапазона, пропускающих hex-окно подписи. Верификатор требует от каждого файла этой формы, проверяя, что зазор между диапазонами содержит ровно строку /Contents этой подписи и ничего больше, а иначе рапортует svInvalidByteRange. Документ, подписанный через PKCS#11-токен, поэтому верифицируется тем же кодом проверки подписей, что и подписанный из PFX-файла. Профильные правила, стоящие над выбором алгоритма, разобраны в базовых подписях PAdES в Delphi, а ловушки ECDSA-кодирования, которыми занимается PKCS#11-конверсия, — в верификации ECDSA CMS и форматах подписи P1363
Внедрять постквантовые подписи, не оставляя читателей за бортом
PDF-читатель без ML-DSA рапортует такую подпись как непроверяемую, и человек, глядящий на документ, видит предупреждение вместо зелёной галочки. Это делает миграцию задачей планирования. Держите RSA или ECDSA как подпись, которую сегодняшние валидаторы будут судить. Там, где политика требует квантово-устойчивых доказательств, объявите расширение через EnsureMLDSAExtensions и добавьте вторую подпись от ML-DSA-способного подписанта рядом с классической. Та подпись приходит от вашего собственного подписанта FIPS 204, поскольку CMS-строитель HotPDF не создаёт ML-DSA-подписи; часть библиотеки — верифицировать её. Первичную подпись двигайте, лишь когда системы, потребляющие ваши документы, смогут её проверить
В таком плане HotPDF покрывает оба конца, которые вы контролируете из Delphi. Его CMS-строители подписывают RSA PKCS#1 v1.5, RSA-PSS, ECDSA, Ed25519 и Ed448 через любого из провайдеров выше, а его верификатор проверяет ML-DSA, EdDSA и классические алгоритмы одним вызовом, записывая в результат реально найденный OID. HotPDF — нативный VCL PDF-компонент для Delphi и C++Builder, не требующий внешнего PDF-рантайма, поэтому код подписания и верификации компилируется в ваш исполняемый файл. Редакции, набор функций подписи и пробная загрузка — на странице HotPDF Delphi PDF component