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

Постквантовое и EdDSA-подписание PDF с HotPDF в Delphi

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-ит его с тестом объявления до запуска любой верификации, так что установка флага на необъявленном файле ничего не меняет

HotPDF пропускает верификацию ML-DSA в PDF только через явное объявление расширения в Catalog, которое EnsureMLDSAExtensions пишет до сохранения документа, и отказывает решёточным алгоритмам на необъявленных файлах, даже когда установлен AllowMLDSA
HotPDF допускает ML-DSA только пока Catalog всё ещё объявляет расширение, что делает постквантовый вердикт воспроизводимым, а вызов EnsureMLDSAExtensions до EndDoc кладёт объявление внутрь подписанного диапазона байтов, а не после него
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-форму, которой ждёт CMS
  • THPDFWindowsKeyStorageSignatureProvider подписывает через хранилище ключей Windows CNG, что покрывает смарт-карты и ключи с опорой на TPM, и может требовать аппаратно-опирающийся ключ

PKCS#11-провайдер принимает вход-дайджест, поэтому ведает RSA PKCS#1 v1.5, RSA-PSS и ECDSA. Ed25519 и Ed448 подписывают полное сообщение подписываемых атрибутов, и они идут через callback- или remote-провайдер, где ваш код передаёт Req.Input токену или сервису как сообщение

Сменные провайдеры подписи HotPDF обмениваются одной простой записью запроса и возвращают статус из шести значений — показано для реализаций callback, remote и PKCS#11, причём закрытый ключ никогда не входит в Delphi-процесс
Callback-, remote- и PKCS#11-провайдеры говорят одной записью запроса и отвечают тем же статусом из шести значений, что позволяет аппаратным токенам, удалённым сервисам HSM и национальным eID-картам оставлять ключи у себя, а не экспортировать их
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

Две точки входа подписания провайдером HotPDF — HPDFCMSBuildSignedDataWithProvider для дайджеста, посчитанного снаружи, и HPDFCMSSignPDFStreamWithProvider для размеченного плейсхолдера — сходятся в одну раскладку CMS, сохраняющую диапазоны /ByteRange, которые валидаторы проверяют первыми
Любая из точек входа пишет одну и ту же подписанную форму из двух покрытых диапазонов вокруг hex-окна, поэтому документ с подписью от провайдера имеет ту же раскладку байтов, что подписанный из PFX-файла

Внедрять постквантовые подписи, не оставляя читателей за бортом

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