Articol tehnic

ML-DSA în Delphi: post-cuantic FIPS 204 în PDFlibPas

PDFlibPas implementează ML-DSA, Module-Lattice-Based Digital Signature Algorithm standardizat în FIPS 204, integral în Object Pascal. Toate cele trei seturi de parametri sosesc ca funcții obișnuite: MLDSA44Sign, MLDSA65Sign, MLDSA87Sign, plus intrările KeyGen și Verify corespunzătoare. Fără OpenSSL, fără DLL de platformă, fără lipici C. Unitatea unică PDFlibMLDSA nu depinde de nimic altceva decât de buretele SHAKE al bibliotecii, iar rezultatul ei se potrivește octet cu octet cu vectorii de test cu răspuns cunoscut din FIPS 204

Ultima frază este singura parte care a cerut muncă adevărată. A scrie aritmetică de rețele în Pascal este mecanic; a o face să se potrivească cu NIST nu este. Ce urmează este relatarea inginerească a portării: cum cele trei seturi de parametri au ajuns să partajeze un singur motor și defectele concrete care au despărțit se compilează și rulează de se potrivește cu KAT. Dacă evaluați opțiuni post-cuantice pentru o conductă de documente Delphi sau C++Builder, defectele sunt partea utilă, pentru că fiecare dintre ele produce un rezultat cu aspect plauzibil care eșuează pe furiș interoperabilitatea

De ce să scrieți un semnatar post-cuantic în Object Pascal pur?

Pentru că alternativa este o dependență nativă per țintă, iar o bibliotecă PDF pentru Delphi are deja destule. PDFlibPas se compilează pe Delphi, C++Builder și FPC/Lazarus pentru ținte Win32, Win64 și Unix; legarea unei biblioteci C post-cuantice ar însemna urmărirea unui build al ei pentru fiecare dintre aceste sloturi, plus suprafața de convenție de apel și de proprietate a memoriei dintre ele. O unitate Pascal pură se compilează oriunde se compilează restul bibliotecii, și acesta este tot argumentul

ML-DSA face asta neobișnuit de ieftin, pentru că singura sa dependență primitivă este SHAKE. Nu există strat de numere întregi mari, nu există curbă eliptică, nu există suită de hash separată. PDFlibPas a câștigat un XOF în flux chiar în versiunea de dinaintea portării: TPLShakeXOF în PDFlibDigest, unde PLShakeXOFInit selectează SHAKE128 (rate 168) sau SHAKE256 (rate 136), urmat de PLShakeXOFAbsorb, PLShakeXOFFinalize și o buclă PLShakeXOFSqueeze care continuă să permute pentru o lungime de ieșire arbitrară. Fiecare rutină de eșantionare prin respingere din unitatea ML-DSA este scrisă direct contra acelui API de patru apeluri

Un motor, trei seturi de parametri: TMLDSAParams

PDFlibPas descrie un întreg set de parametri ML-DSA cu o singură înregistrare și îl selectează după numărul setului, astfel încât ML-DSA-44, 65 și 87 rulează prin aceleași căi de cod. Prima implementare funcțională a fost un build fix 4x4 hard-codat pentru ML-DSA-44; generalizarea a însemnat ridicarea lui k și l, eta, tau, beta, gamma1 și gamma2, omega și a lungimii de provocare în TMLDSAParams, apoi derivarea a tot restul. Punctele publice de intrare au devenit învelișuri de trei rânduri

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;

// Câmpurile derivate sunt calculate, niciodată transcrise dintr-o tabelă
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;

Cele cinci câmpuri derivate sunt calculate, și nu copiate din tabelele FIPS 204, intenționat. Lățimile de biți transcrise de mână sunt exact clasa de constantă care arată bine în revizuire și este decalată cu unu în producție, iar două dintre defectele reale din această portare au avut acea formă. Dimensiunile declarate rămân constante numite pentru validare: 1312 / 2560 / 2420 de octeți de cheie publică, cheie secretă și semnătură pentru ML-DSA-44, 1952 / 4032 / 3309 pentru ML-DSA-65, 2592 / 4896 / 4627 pentru ML-DSA-87

PDFlibPas trimite MLDSA44Sign, MLDSA65Sign și MLDSA87Sign prin BuildMLDSAParams într-o singură înregistrare TMLDSAParams ale cărei câmpuri derivate sunt calculate, nu transcrise, astfel încât un motor partajat MLDSASignInternal deservește toate cele trei seturi de parametri FIPS 204
Trei seturi de parametri partajează un motor pentru că numărul setului doar selectează o înregistrare, iar lățimile de biți derivate sunt calculate în loc să fie transcrise din tabelele FIPS 204

Unde greșește întâi o portare ML-DSA de la zero?

În expand_a, Algoritmul 32 din FIPS 204, iar modul de eșec este seducător de înșelător. Matricea A este eșantionată prin însămânțarea SHAKE128 cu rho urmat de doi octeți de index, deci bufferul de sămânță are 34 de octeți: rho(32), apoi j, apoi i. Scris în Pascal cu indexare AnsiString de la 1, cei doi octeți sunt Msg[33] și Msg[34]. Prima schiță a acestei portări i-a scris în Msg[34] și Msg[35], deplasată cu exact un octet, iar rezultatul a fost o pereche de chei al cărei rho se potrivea perfect cu vectorul de test, în timp ce fiecare coeficient din t era greșit. Doar matricea a fost poluată, iar matricea este exact lucrul pe care cheia publică nu îl poartă întocmai

Încă două defecte au trăit în aceeași rutină. Lungimea de absorb trebuie să fie 34, nu 35; un octet suplimentar de gunoi schimbă întregul flux stors. Iar bucla interioară de respingere trebuie să consume fiecare grup de trei octeți pe care blocul îl poate oferi, inclusiv cel care începe la offsetul 165 al unui bloc SHAKE128 de 168 de octeți, ceea ce înseamnă 56 de grupuri per bloc. Un script de verificare încrucișată care s-a oprit la offsetul 162 a renunțat la coada fiecărui bloc și a deplasat prefixul t1 eșantionat de pe la al treisprezecelea octet încoace

SetLength(Msg, 34);
Move(Rho[1], Msg[1], 32);
Msg[33]:= AnsiChar(J);          // întâi indexul coloanei
Msg[34]:= AnsiChar(I);          // apoi indexul rândului
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 păstrează grupul de la offsetul 165: 56 de triplete per bloc
  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;

Cu acele trei corectate, digeste SHA-256 ale cheilor publică și secretă ML-DSA-44 complete s-au potrivit cu vectorii cu răspuns cunoscut din FIPS 204. Merită numită și o lecție de depanare, pentru că a costat o sesiune: când construiți o verificare încrucișată în Python pentru o buclă de respingere condusă de XOF, hashlib.shake_128().digest(n) întoarce același prefix la fiecare apel în loc să continue fluxul. Luați întreaga lungime o dată, apoi tăiați-o în blocuri de mărimea rate-ului, altfel referința dumneavoastră va reconsuma fericit exact valorile pe care Pascalul dumneavoastră le-a respins corect

Rutina expand_a ML-DSA din PDFlibPas însămânțează SHAKE128 cu un buffer de 34 de octeți care conține rho, indexul coloanei și indexul rândului, alături de o primă schiță care deplasa ambii octeți de index și de o verificare încrucișată care renunța la ultimul grup de trei octeți al fiecărui bloc
Două defecte de tip off-by-one în aceeași rutină: octeți de index scriși o poziție mai târziu și o buclă de respingere care se oprește înaintea grupului de trei octeți de la offsetul 165

Eșantionarea eta: de ce ML-DSA-65 are nevoie de propria ramură

PDFlibPas păstrează două căi separate în expand_s pentru că Algoritmul 33 din FIPS 204 definește chiar două. Pentru eta = 2 fiecare nibble este respins când ajunge la 15 și altfel redus mod 5. Pentru eta = 4 nibble-ul este respins la 9 sau peste și apoi folosit direct, fără nicio reducere modulară. ML-DSA-65 este singurul set livrat cu eta = 4, iar reutilizarea căii mod 5 pentru el dezaliniază s1 și s2 începând cu primul coeficient, producând o pereche de chei care este consistentă intern, se verifică contra propriei persoane și nu se potrivește cu nimic din ce produc alții

Procedure StoreNibble(Nibble: Byte);
Var
  M: Integer;
  Centered: Cardinal;
Begin
  If Cnt>= N Then
    Exit;
  If Eta= 4 Then
  Begin
    If Nibble>= 9 Then        // respinge, apoi ia nibble-ul ca atare
      Exit;
    M:= Nibble;
  End
  Else
  Begin
    If Nibble>= 15 Then       // eta = 2: respinge 15, apoi reduce 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;

Dimensiunile sunt testul: lungimea c-tilde și lățimea în biți a lui gamma1

Doi parametri de codificare variază odată cu nivelul de securitate în feluri ușor de ratat când un build funcțional ML-DSA-44 stă chiar acolo. Hash-ul de provocare c-tilde are 2 x lambda / 8 octeți, adică 32 pentru ML-DSA-44, 48 pentru ML-DSA-65 și 64 pentru ML-DSA-87. Lăsat fix la 32 produce o semnătură ML-DSA-65 de 3293 de octeți în loc de cei standard 3309, iar prefixul KAT diverge imediat. Câmpul de înregistrare CTildaBytes există tocmai ca acel număr să nu poată fi uitat

Al doilea este lățimea de împachetare a polinomului de mască z. PDFlibPas o calculează ca BitWidth(Gamma1), nu ca exponent: gamma1 = 2^19 pentru ML-DSA-65 și 87 are nevoie de 20 de biți per coeficient, nu 19, iar acel singur bit decide dacă fiecare polinom z ocupă 640 de octeți sau ceva ce niciun verificator nu va parsa. Verificatorul a purtat un defect pereche în timpul portării, acolo unde bufferul de deserializare z era dimensionat la 192 de octeți în loc de 576. Lungimea semnăturii este cel mai ieftin test de regresie pe care îl veți scrie vreodată: afirmați 2420, 3309 și 4627 contra Length(Signature) și cele mai multe greșeli de parametrizare se anunță singure înainte să ajungeți la o singură afirmație criptografică

PDFlibPas leagă doi parametri de codificare ML-DSA de nivelul de securitate: hash-ul de provocare c-tilde crește de la 32 la 48 și apoi 64 de octeți, iar polinomul de mască z este împachetat la BitWidth de biți ai lui gamma1, cu lungimea semnăturii ca test de regresie
Doi parametri variază odată cu nivelul de securitate, iar o semnătură care iese cu 3293 de octeți în loc de 3309 anunță greșeala înainte de orice afirmație criptografică

Semnare fără o buclă fără limite

Semnarea ML-DSA se bazează pe respingere, deci reîncearcă cu un kappa incrementat până când o semnătură candidat își trece verificările de normă și de hint. PDFlibPas plafonează asta cu un buget extern explicit de 65535 de încercări; la epuizare MLDSASignInternal întoarce False și lasă semnătura goală în loc să se învârtă în interiorul unui fir de producere de documente. În practică, vectorul oficial ML-DSA-44 reușește la kappa = 4 cu 55 de hint-uri contra plafonului omega de 80, deci bugetul este o șină de siguranță, nu o limită funcțională

Defectul care a făcut ca acea șină să pară necesară nu a fost deloc numeric. Semnarea părea să se blocheze, suspiciunea a căzut pe decompose și make_hint (Algoritmele 36 și 39 din FIPS 204), iar cauza reală a fost o țintă de acumulare inversată: vectorul care hrănește calculul hint-ului trebuie să acumuleze c*t0, în timp ce c*t0 original trebuie să supraviețuiască neatins pentru verificarea normei. Îndreptați ambele spre același buffer și bucla respinge la infinit cu o aritmetică perfect corectă. Pe ambele căi, succesul și epuizarea bugetului, unitatea zeroizează semințele derivate, polinoamele secrete, măștile, provocarea și bufferele de codificare; sămânța, cheia secretă și rnd-ul furnizate de apelant rămân responsabilitatea apelantului, care este despărțirea corectă pentru o bibliotecă ce nu poate ști de unde au venit acele șiruri

Unde se întâlnește ML-DSA azi cu stiva de semnături PDF?

Fiți precis cu privire la ce există. PDFlibPas livrează ML-DSA ca primitive de semnătură verificate plus o legare de mecanism PKCS #11, nu ca înlocuitor direct al rezultatului PAdES actual. Calea către token este TPDFlibPKCS11Client.SignMLDSA, și este deliberat un punct de intrare separat pentru că CKM_ML_DSA consumă mesajul brut, nu un digest precalculat, deci callback-urile existente SignHash și de digest extern nu pot fi reutilizate. Descoperirea fără certificate cere ca CertificateOptional să fie activat explicit împreună cu o etichetă sau un ID de cheie privată, iar clientul validează CKA_PARAMETER_SET contra listei albe CKP_ML_DSA_44 / 65 / 87 la momentul conectării, deci împerecherea implicită de certificate RSA și ECDSA nu este slăbită niciodată din accident

Integrarea la nivel de document este partea încă guvernată de munca de standarde, nu de codul bibliotecii. ISO 32000-2 §12.8 definește dicționarul de semnătură și sarcina sa utilă CMS, iar ISO/TS 32002 este vehiculul pentru extinderea acelui suport către algoritmi de hash și de semnătură mai noi; până când validatorii și contrapartidele dumneavoastră urmează, semnarea clasică rămâne calea de producție. Postura practică este de linii paralele: continuați să livrați semnături PAdES B-B până la B-LTA cu adnotare de timp și date de validare pe termen lung pentru orice trebuie validat azi de o terță parte, dovedind în paralel tratarea cheilor ML-DSA și integrarea token-ului. Pentru experimente locale, același flux de certificate auto-semnate construit pe CryptoAPI vă dă o identitate de semnare fără a implica o CA publică

Testați o schimbare de set de parametri așa cum ați testa orice altă schimbare de semnare. Mai întâi dimensiunile, apoi vectorii oficiali, apoi cazurile negative: un octet de semnătură alterat, un șir de context nepotrivit, o cheie trunchiată. PDFlibPas acoperă toate acestea în suita sa DUnitX, iar aceeași disciplină vă aparține în propria conductă, ideal alături de banca de lucru de conformitate și semnare care grupează validarea pe un corpus de documente, astfel încât o regresie să nu ajungă niciodată neatinsă la un client

Pregătirea post-cuantică pentru software de documente nu va sosi ca un singur comutator. Sosește ca primitive pe care le puteți testa, o cale de token pe care o puteți lega și o linie de standarde pe care o urmați fără a paria versiunea curentă pe ea. Pentru a vedea cum stă unitatea ML-DSA alături de restul uneltelor de semnare, criptare și PDF/A într-o bază de cod Object Pascal nativă, pagina de produs a bibliotecii PDF PDFlibPas pentru Delphi listează setul complet de componente și matricea de compilatoare suportate