Technický článek

ML-DSA v Delphi: postkvantová FIPS 204 v PDFlibPas

PDFlibPas implementuje ML-DSA, algoritmus digitálního podpisu založený na modulových mřížkách standardizovaný ve FIPS 204, celý v Object Pascalu. Všechny tři sady parametrů se dodávají jako obyčejné funkce: MLDSA44Sign, MLDSA65Sign, MLDSA87Sign, plus odpovídající vstupy KeyGen a Verify. Žádný OpenSSL, žádná platformová DLL, žádné C lepidlo. Jediná jednotka PDFlibMLDSA nezávisí na ničem jiném než na SHAKE houbě knihovny a její výstup odpovídá oficiálním testovacím vektorům known-answer z FIPS 204 bajt za bajt

Ta poslední věta je jediná část, která stála skutečnou práci. Napsat mřížkovou aritmetiku v Pascalu je mechanika; dostat ji k souladu s NIST není. Co následuje, je inženýrský rozbor portu: jak tři sady parametrů skončily u jednoho sdíleného enginu a jaké konkrétní vady dělaly rozdíl mezi přeloží se a běží a sedí na KAT. Pokud hodnotíte postkvantové volby pro dokumentovou pipeline v Delphi nebo C++Builderu, vady jsou ta užitečná část, protože každá z nich produkuje věrohodně vypadající výstup, který potichu selhává v interoperabilitě

Proč psát postkvantového podepisujícího v čistém Object Pascalu?

Protože alternativa je jedna nativní závislost na každý cíl a PDF knihovna pro Delphi jich už má dost. PDFlibPas staví napříč Delphi, C++Builder a FPC/Lazarus na cílech Win32, Win64 a Unix; vazba na C postkvantovou knihovnu by znamenala sledovat její build pro každý z těch slotů, plus povrch konvencí volání a vlastnictví paměti mezi nimi. Čistá Pascal jednotka se přeloží všude, kde se přeloží zbytek knihovny, a to je celý argument

ML-DSA to činí neobvykle levným, protože jeho jediná primitivní závislost je SHAKE. Není tu vrstva velkých celých čísel, žádná eliptická křivka, žádný samostatný balík hashů. PDFlibPas získal streamovací XOF ve vydání těsně před portem: TPLShakeXOF v PDFlibDigest, kde PLShakeXOFInit volí SHAKE128 (rate 168) nebo SHAKE256 (rate 136), následované PLShakeXOFAbsorb, PLShakeXOFFinalize a smyčkou PLShakeXOFSqueeze, která permutuje pro libovolnou délku výstupu. Každá rutina rejection samplingu v jednotce ML-DSA je psaná přímo proti tomuto čtyřvolánímu API

Jeden engine, tři sady parametrů: TMLDSAParams

PDFlibPas popisuje celou sadu parametrů ML-DSA jedním záznamem a vybírá ji číslem sady, takže ML-DSA-44, 65 a 87 běží stejnými cestami kódu. První funkční implementace byla pevná 4x4 sestava natvrdo zapojená pro ML-DSA-44; zobecnit to znamenalo vyzvednout k a l, eta, tau, beta, gamma1 a gamma2, omega a délku challenge do TMLDSAParams a všechno ostatní z toho odvodit. Veřejné vstupní body se staly třířádkovými obálkami

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;

// Odvozená pole se počítají, nikdy se neopsávají z tabulky
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;

Pět odvozených polí se počítá místo kopírování z tabulek FIPS 204 záměrně. Ručně přepsané bitové šířky jsou přesně třída konstant, které v revizi vypadají správně a v produkci se pletou o jedničku, a dvě skutečné vady tohoto portu byly přesně takového tvaru. Deklarované velikosti zůstávají jako pojmenované konstanty pro validaci: 1312 / 2560 / 2420 bajtů veřejného klíče, soukromého klíče a podpisu pro ML-DSA-44, 1952 / 4032 / 3309 pro ML-DSA-65, 2592 / 4896 / 4627 pro ML-DSA-87

PDFlibPas směruje MLDSA44Sign, MLDSA65Sign a MLDSA87Sign přes BuildMLDSAParams do jediného záznamu TMLDSAParams, jehož odvozená pole se počítají místo opisování, takže jeden sdílený engine MLDSASignInternal obslouží všechny tři sady parametrů FIPS 204
Tři sady parametrů sdílí jeden engine, protože číslo sady jen volí záznam a odvozené bitové šířky se počítají místo opisování z tabulek FIPS 204

Kde se port ML-DSA od nuly pokazí nejdřív?

V expand_a, FIPS 204 algoritmus 32, a režim selhání je nádherně klamavý. Matice A se vzorkuje osazením SHAKE128 hodnotou rho následovanou dvěma indexovými bajty, takže seed buffer má 34 bajtů: rho(32), pak j, pak i. Zapsané v Pascalu s indexováním AnsiString od jedničky jsou ty dva bajty Msg[33] a Msg[34]. První návrh tohoto portu je zapsal do Msg[34] a Msg[35], posunuté přesně o jeden bajt, a výsledkem byl pár klíčů, jehož rho sedělo na testovacím vektoru dokonale, zatímco každý koeficient t byl špatně. Znečištěná byla jen matice a matice je právě ta věc, kterou veřejný klíč nosí doslova

Dvě další vady žily ve stejné rutině. Délka absorb musí být 34, ne 35; jeden navíc bajt odpadu změní celý vytlačený proud. A vnitřní odmítací smyčka musí spotřebovat každou tříbajtovou skupinu, kterou blok může podat, včetně té začínající na offsetu 165 168bajtového bloku SHAKE128, což je 56 skupin na blok. Křížový kontrolní skript, který skončil na offsetu 162, odepsal ocas každého bloku a posunul vzorkovaný prefix t1 zhruba od třináctého bajtu

SetLength(Msg, 34);
Move(Rho[1], Msg[1], 32);
Msg[33]:= AnsiChar(J);          // nejdřív index sloupce
Msg[34]:= AnsiChar(I);          // pak index řádku
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 drží skupinu na offsetu 165: 56 trojic na blok
  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;

S těmi třemi opravami seděly SHA-256 digesty kompletních veřejných a soukromých klíčů ML-DSA-44 na vektorech known-answer FIPS 204. Jedna lekcí ladění stojí za pojmenování, protože stála session: když stavíte Python křížovou kontrolu pro odmítací smyčku řízenou XOF, hashlib.shake_128().digest(n) vrací při každém volání stejný prefix místo pokračování v proudu. Vezměte celou délku jednou a pak ji rozkrájejte na bloky velikosti rate, jinak vaše reference ochotně znovu spotřebuje přesně ty hodnoty, které váš Pascal správně odmítl

Rutina PDFlibPas ML-DSA expand_a osazuje SHAKE128 34bajtovým bufferem nesoucím rho, index sloupce a index řádku, vedle prvního návrhu, který posunul oba indexové bajty, a křížové kontroly, která odepsala poslední tříbajtovou skupinu každého bloku
Dvě vady o jedničku ve stejné rutině: indexové bajty zapsané jednu pozici pozdě a odmítací smyčka, která se zastaví před tříbajtovou skupinou na offsetu 165

Vzorkování eta: proč ML-DSA-65 žádá vlastní větev

PDFlibPas drží dvě oddělené cesty v expand_s, protože FIPS 204 algoritmus 33 je skutečně definuje dvě. Pro eta = 2 se každý nibble odmítne, když dosáhne 15, a jinak se redukuje mod 5. Pro eta = 4 se nibble odmítne na 9 a výš a pak se použije přímo, bez jakékoli modulární redukce. ML-DSA-65 je jediná dodávaná sada s eta = 4 a znovupoužití cesty mod 5 pro ni rozsadí s1 a s2 od prvního koeficientu, takže vznikne pár klíčů, který je vnitřně konzistentní, ověří se sám proti sobě a nesedí s ničím, co vyprodukují ostatní

Procedure StoreNibble(Nibble: Byte);
Var
  M: Integer;
  Centered: Cardinal;
Begin
  If Cnt>= N Then
    Exit;
  If Eta= 4 Then
  Begin
    If Nibble>= 9 Then        // odmítnout, pak vzít nibble tak, jak je
      Exit;
    M:= Nibble;
  End
  Else
  Begin
    If Nibble>= 15 Then       // eta = 2: odmítnout 15, pak redukovat 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;

Velikosti jsou test: délka c-tilde a bitová šířka gamma1

Dva kódovací parametry se mění s bezpečnostní úrovní způsoby, které snadno přehlédnete, když funkční sestava ML-DSA-44 sedí hned vedle. Challenge hash c-tilde má 2 x lambda / 8 bajtů, což je 32 pro ML-DSA-44, 48 pro ML-DSA-65 a 64 pro ML-DSA-87. Nechat ho fixní na 32 dá podpis ML-DSA-65 o 3293 bajtech místo standardních 3309 a prefix KAT diverguje okamžitě. Pole záznamu CTildaBytes existuje přesně proto, aby se na tohle číslo nemohlo zapomenout

Druhým je balicí šířka maskového polynomu z. PDFlibPas ji počítá jako BitWidth(Gamma1), ne jako exponent: gamma1 = 2^19 pro ML-DSA-65 a 87 žádá 20 bitů na koeficient, ne 19, a ten jediný bit rozhoduje, zda každý polynom z zabere 640 bajtů, nebo něco, co žádný verifikátor neparsovat. Verifikátor nesl odpovídající vadu během portu, kde se buffer deserializace z rozměřoval na 192 bajtů místo 576. Délka podpisu je nejlevnější regresní test, jaký kdy napíšete: asertujte 2420, 3309 a 4627 proti Length(Signature) a většina chyb parametrizace se přizná, dřív než dojdete k jediné kryptografické aserci

PDFlibPas váže dva kódovací parametry ML-DSA na bezpečnostní úroveň: challenge hash c-tilde roste z 32 na 48 na 64 bajtů a maskový polynom z se balí na BitWidth bitů gamma1, s délkou podpisu jako regresním testem
Dva parametry se mění s bezpečnostní úrovní a podpis, který vyletí o 3293 bajtech místo 3309, přizná chybu, dřív než poběží jakákoli kryptografická aserce

Podepisování bez neomezené smyčky

Podepisování ML-DSA je odmítací, takže opakuje s inkrementovaným kappa, dokud kandidátní podpis neprojde kontrolou normy a hintů. PDFlibPas to stropí výslovným externím rozpočtem 65535 pokusů; při vyčerpání vrátí MLDSASignInternal False a nechá podpis prázdný místo točení uvnitř vlákna výroby dokumentů. V praxi oficiální vektor ML-DSA-44 uspěje na kappa = 4 s 55 hinty proti stropu omega 80, takže rozpočet je zábradlí, ne pracovní limit

Chyba, kvůli které se to zábradlí zdálo nutné, nebyla vůbec číselná. Podepisování vypadalo jako zatuhnutí, podezření padlo na decompose a make_hint (FIPS 204 algoritmy 36 a 39) a skutečnou příčinou byl obrácený akumulační cíl: vektor krmící výpočet hintů musí akumulovat c*t0, zatímco původní c*t0 musí pro kontrolu normy přežít nedotčený. Namiřte oba na stejný buffer a smyčka odmítá navždy s dokonale správnou aritmetikou. Na cestě úspěchu i vyčerpaného rozpočtu jednotka vynuluje odvozené seed, soukromé polynomy, masky, challenge a kódovací buffery; seed, soukromý klíč a rnd dodané volajícím zůstávají v odpovědnosti volajícího, což je správné rozdělení pro knihovnu, která nemůže vědět, odkud ty řetězce přišly

Kde se ML-DSA dnes setkává se zásobníkem PDF podpisů?

Buďte přesní v tom, co existuje. PDFlibPas dodává ML-DSA jako ověřené kryptografické primitivy plus vazbu na mechanismus PKCS #11, ne jako drop-in náhradu vašeho současného PAdES výstupu. Tokenová cesta je TPDFlibPKCS11Client.SignMLDSA a záměrně je samostatným vstupním bodem, protože CKM_ML_DSA spotřebovává syrovou zprávu místo předpočítaného digestu, takže stávající SignHash a callbacky externího digestu nelze znovu použít. Objev bez certifikátu žádá výslovně povolené CertificateOptional spolu s labelem nebo ID soukromého klíče a klient validuje CKA_PARAMETER_SET proti whitelistu CKP_ML_DSA_44 / 65 / 87 už při připojení, takže výchozí párování certifikátů RSA a ECDSA se nikdy neutrhne náhodou

Integrace na úrovni dokumentu je část stále řízená standardizační prací místo kódu knihovny. ISO 32000-2 §12.8 definuje slovník podpisu a jeho CMS payload a ISO/TS 32002 je vozidlo pro rozšíření této podpory na novější hash a podpisové algoritmy; dokud vaši verifikátoři a protistrany nepodají, klasické podepisování zůstává produkční cestou. Praktický postoj jsou dvě koleje: dál dodávejte podpisy PAdES B-B až B-LTA s časovým razítkem a daty dlouhodobé validace pro cokoli, co dnes musí třetí strana ověřit, a bokem prověřujte manipulaci s klíči ML-DSA a tokenovou integraci. Pro lokální experimenty vám stejný workflow samopodepsaných certifikátů postavený na CryptoAPI dá podpisovou identitu bez zapojení veřejné CA

Testujte změnu sady parametrů tak, jak byste testovali jakoukoli jinou změnu podepisování. Nejdřív velikosti, pak oficiální vektory, pak negativní případy: poškrábaný bajt podpisu, nesedící kontextový řetězec, zkrácený klíč. PDFlibPas pokrývá všechno tohle ve své sadě DUnitX a stejná disciplína patří i do vaší vlastní pipeline, ideálně vedle workbenche shody a podepisování, který dávkově validuje přes korpus dokumentů, takže regrese nikdy nedojde k zákazníkovi nepovšimnutá

Postkvantová připravenost pro dokumentový software nepřijde jako jediný přepínač. Přijde jako primitivy, které můžete testovat, tokenová cesta, kterou můžete propojit, a standardizační kolej, kterou sledujete, aniž byste na ni vsadili aktuální vydání. Kdybyste chtěli vidět, jak jednotka ML-DSA sedí vedle zbytku nástrojů podepisování, šifrování a PDF/A v nativním kódu Object Pascalu, produktová stránka knihovny PDFlibPas Delphi PDF vypisuje celou sadu komponent a podporovanou matici kompilátorů