Технічна стаття

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 (швидкість 168) або SHAKE256 (швидкість 136), далі PLShakeXOFAbsorb, PLShakeXOFFinalize і цикл PLShakeXOFSqueeze, що продовжує перестановки для довільної довжини виводу. Кожна підпрограма rejection-семплінгу в ML-DSA юніті написана прямо проти того API з чотирьох викликів

Один рушій, три набори параметрів: TMLDSAParams

PDFlibPas описує весь набір параметрів ML-DSA одним записом і обирає його за номером набору, тож ML-DSA-44, 65 і 87 йдуть крізь ті самі шляхи коду. Перша робоча реалізація була фіксованою збіркою 4x4, жорстко привʼязаною до ML-DSA-44; узагальнення означало підняття k і l, eta, tau, beta, gamma1 і gamma2, omega та довжини виклику в 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 від одиниці, ці два байти — Msg[33] і Msg[34]. Перший чорновий варіант порту записав їх у Msg[34] і Msg[35], зі зміщенням рівно на один байт, і результатом була пара ключів, чиє rho збігалося з тестовим вектором ідеально, тоді як кожен коефіцієнт t був неправильним. Забрудненою була лише матриця, а матриця — це єдине, що публічний ключ не несе дослівно

Ще два дефекти жили в тій самій підпрограмі. Довжина поглинання мусить бути 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, швидкість 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) повертає той самий префікс при кожному виклику, замість продовження потоку. Візьміть усю довжину один раз, потім наріжте її на блоки розміру швидкості, інакше ваша референсна реалізація охоче повторно споживатиме саме ті значення, які ваш Pascal правильно відхилив

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

Семплінг eta: чому ML-DSA-65 потребує власної гілки

PDFlibPas тримає два окремі шляхи в expand_s, бо алгоритм 33 з FIPS 204 справді визначає два. Для eta = 2 кожен нібл відхиляється, коли досягає 15, і в іншому разі зводиться mod 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 стоїть поруч. Хеш виклику 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 до рівня безпеки: хеш виклику 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 має вижити недоторканим для перевірки норми. Прикладте обидва до одного буфера — і цикл відхилятиме вічно з абсолютно правильними обчисленнями. На обох шляхах — успішному й вичерпаному бюджеті — юніт зануляє похідні насіння, секретні поліноми, маски, виклик і буфери кодування; насіння, секретний ключ і 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 перелічує повний набір компонентів і матрицю підтримуваних компіляторів