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