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

ML-DSA в Delphi: постквантовая FIPS 204 в PDFlibPas

PDFlibPas реализует ML-DSA — алгоритм цифровой подписи на решётках, стандартизованный в FIPS 204, — целиком на Object Pascal. Все три набора параметров поставляются как обычные функции: MLDSA44Sign, MLDSA65Sign, MLDSA87Sign плюс соответствующие точки входа KeyGen и Verify. Ни OpenSSL, ни платформенной DLL, ни C-клея. Единственный модуль PDFlibMLDSA не зависит ни от чего, кроме библиотечной губки SHAKE, и его вывод побайтово совпадает с официальными known-answer тест-векторами FIPS 204

Последняя фраза — единственная часть, потребовавшая настоящей работы. Писать решёточную арифметику на Pascal механически; добиться её согласия с NIST — нет. Далее — инженерный отчёт о порте: как три набора параметров в итоге стали делить один движок, и конкретные дефекты, отделявшие компилируется и работает от совпадает с KAT. Если вы оцениваете постквантовые варианты для документного конвейера на Delphi или C++Builder, дефекты — полезная часть, потому что каждый из них даёт правдоподобный на вид вывод, который безмолвно проваливает совместимость

Зачем писать постквантовый подписант на чистом Object Pascal?

Потому что альтернатива — по одной нативной зависимости на каждую цель, а библиотеке PDF для Delphi их и так хватает. PDFlibPas собирается на Delphi, C++Builder и FPC/Lazarus для целей Win32, Win64 и Unix; привязка C-библиотеки постквантовой подписи означала бы отслеживание её сборки для каждого из этих слотов плюс поверхность соглашений о вызовах и владения памятью между ними. Чистый Pascal-модуль компилируется везде, где компилируется остальная библиотека, и это весь аргумент

ML-DSA делает это необычно дешёвым, потому что его единственная примитивная зависимость — SHAKE. Нет слоя больших целых, нет эллиптической кривой, нет отдельного набора хэшей. PDFlibPas получил потоковый XOF в релизе непосредственно перед портом: TPLShakeXOF в PDFlibDigest, где PLShakeXOFInit выбирает SHAKE128 (rate 168) или SHAKE256 (rate 136), затем PLShakeXOFAbsorb, PLShakeXOFFinalize и цикл PLShakeXOFSqueeze, продолжающий перестановки для произвольной длины вывода. Каждая подпрограмма rejection-sampling в модуле ML-DSA написана прямо против этого API из четырёх вызовов

Один движок, три набора параметров: TMLDSAParams

PDFlibPas описывает весь набор параметров ML-DSA одной записью и выбирает её по номеру набора, поэтому ML-DSA-44, 65 и 87 идут через одни и те же пути кода. Первая рабочая реализация была фиксированной сборкой 4x4, жёстко привязанной к ML-DSA-44; обобщение означало вынос k и l, eta, tau, beta, gamma1 и gamma2, omega и длины challenge в TMLDSAParams, а затем выведение всего остального. Публичные точки входа стали трёхстрочными обёртками

Type
  TMLDSAParams= Record
    K, L, D, Eta, Tau, Beta, Gamma1, Gamma2, Omega: Integer;
    Alpha, MW1: Cardinal;
    W1BW, EtaBW, Gamma1BW, T1BW: Integer;
    T0Rng: Cardinal;
    CTildaBytes: Integer;
    PublicKeyBytes, SecretKeyBytes, SignatureBytes: Integer;
  End;

// Производные поля вычисляются, а не переписываются из таблицы
Params.Alpha:= 2* Cardinal(Params.Gamma2);
Params.MW1:= (Q- 1)div Params.Alpha;
Params.W1BW:= BitWidth(Params.MW1- 1);
Params.EtaBW:= BitWidth(2* Cardinal(Params.Eta));
Params.Gamma1BW:= BitWidth(Cardinal(Params.Gamma1));

Function MLDSA65Sign(Const SecretKey, Message, Context, Rnd: AnsiString;
  Out Signature: AnsiString): Boolean;
Var
  Params: TMLDSAParams;
Begin
  BuildMLDSAParams(65, Params);
  Result:= MLDSASignInternal(Params, SecretKey, Message, Context, Rnd,
    Signature);
End;

Пять производных полей намеренно вычисляются, а не списываются из таблиц FIPS 204. Вручную переписанные битовые ширины — ровно тот класс констант, который выглядит правильно на ревью и сбит на единицу в продакшене, и два реальных дефекта этого порта были именно такой формы. Заявленные размеры остаются именованными константами для валидации: 1312 / 2560 / 2420 байтов публичного ключа, секретного ключа и подписи для ML-DSA-44, 1952 / 4032 / 3309 для ML-DSA-65, 2592 / 4896 / 4627 для ML-DSA-87

PDFlibPas направляет MLDSA44Sign, MLDSA65Sign и MLDSA87Sign через BuildMLDSAParams в единую запись TMLDSAParams, чьи производные поля вычисляются, а не переписываются, поэтому один общий движок MLDSASignInternal обслуживает все три набора параметров FIPS 204
Три набора параметров делят один движок, потому что номер набора лишь выбирает запись, а производные битовые ширины вычисляются вместо переписывания из таблиц FIPS 204

Где порт ML-DSA с нуля ошибается первым?

В expand_a, алгоритме 32 из FIPS 204, и режим отказа красиво обманчив. Матрица A сэмплируется инициализацией SHAKE128 значением rho и двумя байтами индексов, поэтому буфер сида — 34 байта: rho(32), затем j, затем i. На Pascal с индексацией AnsiString с 1 эти два байта — Msg[33] и Msg[34]. Первый черновик порта записал их в Msg[34] и Msg[35], со сдвигом ровно в один байт, и результатом была пара ключей, чья rho идеально совпадала с тест-вектором, тогда как каждый коэффициент t был неверен. Загрязнена была только матрица, а матрица — то единственное, что публичный ключ не несёт дословно

Ещё два дефекта жили в той же подпрограмме. Длина absorb должна быть 34, а не 35; один лишний мусорный байт меняет весь выжатый поток. И внутренний цикл отбраковки должен потреблять каждую трёхбайтовую группу, какую может дать блок, включая начинающуюся со смещения 165 в 168-байтовом блоке SHAKE128, — это 56 групп на блок. Скрипт перекрёстной проверки, останавливавшийся на смещении 162, отбрасывал хвост каждого блока и сдвигал сэмплированный префикс t1 примерно с тринадцатого байта и далее

SetLength(Msg, 34);
Move(Rho[1], Msg[1], 32);
Msg[33]:= AnsiChar(J);          // сначала индекс столбца
Msg[34]:= AnsiChar(I);          // затем индекс строки
PLShakeXOFInit(Ctx, True);      // SHAKE128, rate 168
PLShakeXOFAbsorb(Ctx, @Msg[1], 34);
PLShakeXOFFinalize(Ctx);
Cnt:= 0;
While Cnt< N Do
Begin
  PLShakeXOFSqueeze(Ctx, @Buf[0], 168);
  BOff:= 0;
  // BOff+2 <= 167 сохраняет группу со смещением 165: 56 троек на блок
  While (BOff+ 2<= High(Buf))And (Cnt< N) Do
  Begin
    T3:= ((Buf[BOff+ 2]and $7F)shl 16)xor (Buf[BOff+ 1]shl 8)xor Buf[BOff];
    If T3< Q Then
    Begin
      Poly^[Cnt]:= T3;
      Inc(Cnt);
    End;
    Inc(BOff, 3);
  End;
End;

После исправления всех трёх SHA-256-дайджесты полных публичного и секретного ключей ML-DSA-44 совпали с known-answer векторами FIPS 204. Один урок отладки тоже стоит назвать, потому что он стоил целой сессии: когда вы строите Python-перекрёстную проверку для цикла отбраковки на XOF, hashlib.shake_128().digest(n) возвращает один и тот же префикс при каждом вызове вместо продолжения потока. Возьмите всю длину один раз, затем нарежьте её на блоки размером с rate, иначе ваша эталонная реализация с удовольствием заново поглотит те самые значения, которые ваш Pascal корректно отбраковал

Подпрограмма expand_a ML-DSA в PDFlibPas инициализирует SHAKE128 34-байтовым буфером с rho, индексом столбца и индексом строки, рядом первый черновик, сдвинувший оба индексных байта, и перекрёстная проверка, отбросившая последнюю трёхбайтовую группу каждого блока
Два дефекта несовпадения на единицу в одной подпрограмме: индексные байты, записанные на позицию позже, и цикл отбраковки, останавливающийся перед трёхбайтовой группой на смещении 165

Сэмплирование eta: почему ML-DSA-65 нужна собственная ветвь

PDFlibPas держит два отдельных пути в expand_s, потому что алгоритм 33 из FIPS 204 действительно определяет два. Для eta = 2 каждый ниббл отбраковывается при достижении 15 и иначе приводится по модулю 5. Для eta = 4 ниббл отбраковывается при 9 и выше, а затем используется напрямую, без всякого приведения по модулю. ML-DSA-65 — единственный поставляемый набор с eta = 4, и переиспользование для него пути mod 5 рассогласует s1 и s2 с самого первого коэффициента, давая пару ключей, которая внутренне согласована, верифицируется против самой себя и не совпадает ни с чем, что производят другие

Procedure StoreNibble(Nibble: Byte);
Var
  M: Integer;
  Centered: Cardinal;
Begin
  If Cnt>= N Then
    Exit;
  If Eta= 4 Then
  Begin
    If Nibble>= 9 Then        // отбраковка, затем ниббл берётся как есть
      Exit;
    M:= Nibble;
  End
  Else
  Begin
    If Nibble>= 15 Then       // eta = 2: отбраковка 15, затем приведение по mod 5
      Exit;
    M:= Nibble mod 5;
  End;
  If Eta>= M Then
    Centered:= Eta- M
  Else
    Centered:= Q- (M- Eta);
  Vec[I][Cnt]:= Centered;
  Inc(Cnt);
End;

Размеры — это тест: длина c-tilde и битовая ширина gamma1

Два параметра кодирования меняются с уровнем безопасности так, что их легко упустить, когда рабочая сборка ML-DSA-44 лежит прямо рядом. Хэш challenge c-tilde — это 2 x lambda / 8 байтов, то есть 32 для ML-DSA-44, 48 для ML-DSA-65 и 64 для ML-DSA-87. Оставить его фиксированным на 32 — значит получить подпись ML-DSA-65 в 3293 байта вместо стандартных 3309, и префикс KAT расходится немедленно. Поле записи CTildaBytes существует именно для того, чтобы это число нельзя было забыть

Второй — ширина упаковки маскирующего полинома z. PDFlibPas вычисляет её как BitWidth(Gamma1), а не как экспоненту: gamma1 = 2^19 для ML-DSA-65 и 87 требует 20 битов на коэффициент, а не 19, и этот единственный бит решает, займёт ли каждый полином z 640 байтов или то, что не разберёт ни один верификатор. Верификатор нёс согласованный дефект во время порта: буфер десериализации z был рассчитан на 192 байта вместо 576. Длина подписи — самый дешёвый регрессионный тест, какой вы напишете: проверьте 2420, 3309 и 4627 против Length(Signature), и большинство ошибок параметризации объявят себя до того, как вы доберётесь до первой криптографической проверки

PDFlibPas привязывает два параметра кодирования ML-DSA к уровню безопасности: хэш challenge c-tilde растёт с 32 до 48 до 64 байтов, а маскирующий полином z упаковывается при ширине BitWidth битов gamma1, с длиной подписи в качестве регрессионного теста
Два параметра меняются с уровнем безопасности, и подпись, вышедшая в 3293 байта вместо 3309, объявляет ошибку до того, как выполнится хоть одна криптографическая проверка

Подписание без бесконечного цикла

Подписание ML-DSA основано на отбраковке, поэтому оно повторяет попытки с увеличенным kappa, пока кандидат подписи не пройдёт проверки нормы и подсказок. PDFlibPas ограничивает это явным внешним бюджетом в 65535 попыток; при исчерпании MLDSASignInternal возвращает False и оставляет подпись пустой, а не крутится внутри потока производства документов. На практике официальный вектор ML-DSA-44 успешен при kappa = 4 с 55 подсказками против потолка omega в 80, так что бюджет — страховочный барьер, а не рабочий предел

Дефект, из-за которого этот барьер показался необходимым, вообще не был численным. Подписание выглядело зависшим, подозрение пало на decompose и make_hint (алгоритмы 36 и 39 из FIPS 204), а настоящей причиной была перепутанная цель накопления: вектор, питающий вычисление подсказок, должен накапливать c*t0, тогда как исходный c*t0 обязан остаться нетронутым для проверки нормы. Направьте оба в один буфер — и цикл отбраковывает вечно при совершенно правильной арифметике. На обоих путях — успех и исчерпание бюджета — модуль обнуляет производные сиды, секретные полиномы, маски, challenge и буферы кодирования; предоставленные вызывающим сид, секретный ключ и rnd остаются зоной ответственности вызывающего, и это правильный раздел для библиотеки, которая не может знать, откуда взялись эти строки

Где ML-DSA встречается со стеком PDF-подписей сегодня?

Будьте точны насчёт того, что существует. PDFlibPas поставляет ML-DSA как верифицированные примитивы подписи плюс привязку механизма PKCS #11, а не как замену вашего текущего вывода PAdES без переделок. Путь токена — TPDFlibPKCS11Client.SignMLDSA, и это намеренно отдельная точка входа, потому что CKM_ML_DSA потребляет исходное сообщение, а не заранее вычисленный дайджест, поэтому существующие колбэки SignHash и внешнего дайджеста не переиспользовать. Обнаружение без сертификатов требует явного включения CertificateOptional вместе с меткой или ID закрытого ключа, и клиент проверяет CKA_PARAMETER_SET по белому списку CKP_ML_DSA_44 / 65 / 87 на этапе подключения, так что используемая по умолчанию пара сертификатов RSA и ECDSA никогда не ослабляется случайно

Интеграция на уровне документов — та часть, которой по-прежнему управляет стандартизация, а не код библиотеки. ISO 32000-2 §12.8 определяет словарь подписи и её нагрузку CMS, а ISO/TS 32002 — транспорт для расширения этой поддержки на новые хэш- и сигнатурные алгоритмы; пока ваши валидаторы и контрагенты не подтянулись, классическое подписание остаётся производственным путём. Практическая стратегия — параллельные треки: продолжайте поставлять подписи PAdES от B-B до B-LTA с проставлением меток времени и данными долговременной валидации для всего, что третья сторона должна валидировать сегодня, параллельно обкатывая обработку ключей ML-DSA и интеграцию с токенами. Для локальных экспериментов тот же процесс самоподписанных сертификатов на CryptoAPI даёт подписывающую идентичность без привлечения публичного CA

Тестируйте смену набора параметров так же, как любую другую смену подписания. Сначала размеры, затем официальные векторы, затем негативные случаи: подменённый байт подписи, несовпавшая строка контекста, обрезанный ключ. PDFlibPas покрывает всё это своим набором DUnitX, и та же дисциплина принадлежит вашему конвейеру, в идеале рядом с стендом соответствия и подписания, пакетно прогоняющим валидацию по корпусу документов, чтобы регрессия никогда не доходила до клиента незамеченной

Постквантовая готовность программ для документов не придёт одним переключателем. Она приходит как примитивы, которые можно тестировать, путь токена, который можно подключить, и стандартизационный трек, которому можно следовать, не ставя на него текущий релиз. Чтобы увидеть, как модуль ML-DSA уживается с остальными инструментами подписания, шифрования и PDF/A в нативной кодовой базе Object Pascal, страница продукта PDFlibPas Delphi PDF library перечисляет полный набор компонентов и матрицу поддерживаемых компиляторов