Teknisk artikel

ML-DSA i Delphi: FIPS 204 postkvantum i PDFlibPas

PDFlibPas implementerar ML-DSA, Module-Lattice-Based Digital Signature Algorithm som standardiserats i FIPS 204, helt i Object Pascal. Alla tre parameteruppsättningar levereras som vanliga funktioner: MLDSA44Sign, MLDSA65Sign, MLDSA87Sign, plus matchande KeyGen- och Verify-ingångar. Ingen OpenSSL, ingen plattforms-DLL, inget C-lim. Den enda uniten PDFlibMLDSA beror på ingenting utom bibliotekets SHAKE-svamp, och dess utdata matchar de officiella FIPS 204-known-answer-testvektorerna byte för byte

Den sista meningen är den enda del som krävde verkligt arbete. Att skriva gitteraritmetik i Pascal är mekaniskt; att få den att hålla med NIST är det inte. Det som följer är den tekniska redogörelsen för porteringen: hur de tre parameteruppsättningarna hamnade på att dela en motor, och de specifika fel som skiljde kompilerar-och-kör från matchar-KAT. Om du utvärderar postkvanta-alternativ för en Delphi- eller C++Builder-dokumentpipeline är felen den användbara delen, eftersom vart och ett av dem producerar troligt seende utdata som tyst fallerar interoperabilitet

Varför skriva en postkvant-signerare i ren Object Pascal?

Eftersom alternativet är ett inbyggt beroende per mål, och ett Delphi-PDF-bibliotek redan har nog av dem. PDFlibPas bygger över Delphi, C++Builder och FPC/Lazarus på Win32-, Win64- och Unix-mål; att binda ett C-postkvant-bibliotek skulle innebära att spåra en byggning av det för varje sådan plats, plus anropskonventions- och minnesägar-ytan mellan dem. En ren Pascal-unit kompilerar varhelst resten av biblioteket kompilerar, och det är hela argumentet

ML-DSA gör detta ovanligt billigt, eftersom dess enda primitiva beroende är SHAKE. Det finns inget stor-heltalslager, ingen elliptisk kurva, ingen separat hash-svit. PDFlibPas fick en strömmande XOF i utgåvan omedelbart före porteringen: TPLShakeXOF i PDFlibDigest, där PLShakeXOFInit väljer SHAKE128 (rate 168) eller SHAKE256 (rate 136), följt av PLShakeXOFAbsorb, PLShakeXOFFinalize och en PLShakeXOFSqueeze-loop som fortsätter permutera för godtycklig utdatalängd. Varje avvisande-samplingsrutin i ML-DSA-uniten är skriven direkt mot det fyr-anrops-API:t

En motor, tre parameteruppsättningar: TMLDSAParams

PDFlibPas beskriver en hel ML-DSA-parameteruppsättning med en post och väljer den med uppsättningsnummer, så att ML-DSA-44, 65 och 87 kör genom samma kodvägar. Den första fungerande implementeringen var en fast 4x4-byggnad hårt kopplad till ML-DSA-44; att generalisera den innebar att lyfta k och l, eta, tau, beta, gamma1 och gamma2, omega och utmaningslängden in i TMLDSAParams, och sedan härleda allt annat. De publika ingångspunkterna blev tre-radswrappare

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;

// Härledda fält beräknas, aldrig avskrivna från en tabell
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;

De fem härledda fälten beräknas i stället för att kopieras ur FIPS 204-tabellerna, med flit. Handavskrivna bitbredder är precis den klass av konstanter som ser rätt ut i granskning och är fel med ett i produktion, och två av de verkliga felen i denna portering var av det slaget. De deklarerade storlekarna finns kvar som namngivna konstanter för validering: 1312 / 2560 / 2420 byte för publik nyckel, hemlig nyckel och signatur för ML-DSA-44, 1952 / 4032 / 3309 för ML-DSA-65, 2592 / 4896 / 4627 för ML-DSA-87

PDFlibPas leder MLDSA44Sign, MLDSA65Sign och MLDSA87Sign genom BuildMLDSAParams in i en enda TMLDSAParams-post vars härledda fält beräknas i stället för avskrivna, så att en gemensam MLDSASignInternal-motor betjänar alla tre FIPS 204-parameteruppsättningarna
Tre parameteruppsättningar delar en motor eftersom uppsättningsnumret bara väljer en post, och de härledda bitbredderna beräknas i stället för att avskrivas från FIPS 204-tabellerna

Var går en ML-DSA-portering från grunden fel först?

I expand_a, FIPS 204-algoritm 32, och felläget är vackert vilseledande. Matrisen A samplas genom att ladda SHAKE128 med rho följt av två indexbyte, så fröbufferten är 34 byte: rho(32), sedan j, sedan i. Skrivet i Pascal med ettbaserad AnsiString-indexering är de två bytena Msg[33] och Msg[34]. Det första utkastet av denna portering skrev dem till Msg[34] och Msg[35], förskjutna med exakt en byte, och resultatet var ett nyckelpar vars rho matchade testvektorn perfekt medan varje koefficient av t var fel. Endast matrisen var förorenad, och matrisen är det enda som den publika nyckeln inte bär ordagrant

Två fler fel bodde i samma rutin. Absorberingslängden måste vara 34, inte 35; en extra skräpbyte ändrar hela den pressade strömmen. Och den inre avvisningsslingan måste konsumera varje tre-byte-grupp blocket kan leverera, inklusive den som börjar på offset 165 i ett 168-byte SHAKE128-block, vilket är 56 grupper per block. Ett krysskontrollskript som stannade på offset 162 tappade svansen på varje block och försköt det samplade t1-prefixet från ungefär den trettonde byten och framåt

SetLength(Msg, 34);
Move(Rho[1], Msg[1], 32);
Msg[33]:= AnsiChar(J);          // kolumnindex först
Msg[34]:= AnsiChar(I);          // sedan radindexet
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 behåller gruppen på offset 165: 56 trippar per block
  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;

Med de tre rättade matchade SHA-256-sammandragen av de kompletta ML-DSA-44 publika och hemliga nycklarna FIPS 204-known-answer-vektorerna. En felsökningsläxa är värd att nämna också, eftersom den kostade en session: när du bygger en Python-krysskontroll för en XOF-driven avvisningsslinga returnerar hashlib.shake_128().digest(n) samma prefix vid varje anrop i stället för att fortsätta strömmen. Ta hela längden en gång, och skär den sedan i rate-stora block, annars konsumerar din referens gärna igen exakt de värden din Pascal korrekt avvisade

PDFlibPas ML-DSA-rutin expand_a laddar SHAKE128 med en 34-byte buffert som håller rho, kolumnindexet och radindexet, jämte ett första utkast som försköt båda indexbytena och en krysskontroll som tappade den sista tre-byte-gruppen i varje block
Två off-by-one-fel i samma rutin: indexbyte skrivna en position för sent, och en avvisningsslinga som stannar före tre-byte-gruppen på offset 165

Sampling av eta: varför ML-DSA-65 behöver sin egen gren

PDFlibPas behåller två separata vägar i expand_s eftersom FIPS 204-algoritm 33 genuint definierar två. För eta = 2 avvisas varje nibble när den når 15 och reduceras i övrigt mod 5. För eta = 4 avvisas nibble vid 9 eller över och används sedan direkt, utan någon modulär reduktion alls. ML-DSA-65 är den enda levererade uppsättningen med eta = 4, och att återanvända mod 5-vägen för den felinriktar s1 och s2 från allra första koefficienten, vilket ger ett nyckelpar som är internt konsekvent, verifierar mot sig själv och matchar ingenting någon annan producerar

Procedure StoreNibble(Nibble: Byte);
Var
  M: Integer;
  Centered: Cardinal;
Begin
  If Cnt>= N Then
    Exit;
  If Eta= 4 Then
  Begin
    If Nibble>= 9 Then        // avvisa, ta sedan nibble som den är
      Exit;
    M:= Nibble;
  End
  Else
  Begin
    If Nibble>= 15 Then       // eta = 2: avvisa 15, reducera sedan 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;

Storlekarna är testet: c-tilde-längd och gamma1-bitbredd

Två kodningsparametrar varierar med säkerhetsnivån på sätt som är lätta att missa när en fungerande ML-DSA-44-byggnad står precis där. Utmaningshashen c-tilde är 2 x lambda / 8 byte, vilket är 32 för ML-DSA-44, 48 för ML-DSA-65 och 64 för ML-DSA-87. Att lämna den fast på 32 ger en ML-DSA-65-signatur på 3293 byte i stället för de standardiserade 3309, och KAT-prefixet divergerar omedelbart. Postfältet CTildaBytes finns precis så att det talet inte kan glömmas

Den andra är packningsbredden hos maskpolynomet z. PDFlibPas beräknar den som BitWidth(Gamma1), inte som exponenten: gamma1 = 2^19 för ML-DSA-65 och 87 behöver 20 bitar per koefficient, inte 19, och den enda biten avgör om varje z-polynom upptar 640 byte eller något ingen verifierare kommer att tolka. Verifieraren bar ett matchande fel under porteringen, där z-avserialiseringsbufferten dimensionerades till 192 byte i stället för 576. Signaturlängd är det billigaste regressionstest du någonsin skriver: asserta 2420, 3309 och 4627 mot Length(Signature), och de flesta parametriseringsmisstag anmäler sig innan du når en enda kryptografisk assertion

PDFlibPas knyter två ML-DSA-kodningsparametrar till säkerhetsnivån: c-tilde-utmaningshashen växer från 32 till 48 till 64 byte, och maskpolynomet z packas vid BitWidth av gamma1-bitarna, med signaturlängd som regressionstest
Två parametrar varierar med säkerhetsnivån, och en signatur som blir 3293 byte i stället för 3309 anmäler misstaget innan någon kryptografisk assertion körs

Signering utan en obegränsad slinga

ML-DSA-signering är avvisningsbaserad, så den försöker igen med en ökad kappa tills en kandidatsignatur passerar sina norm- och hintkontroller. PDFlibPas takar det med en uttrycklig extern budget på 65535 försök; vid uttömning returnerar MLDSASignInternal False och lämnar signaturen tom i stället för att snurra inuti en dokumentproduktionstråd. I praktiken lyckas den officiella ML-DSA-44-vektorn vid kappa = 4 med 55 hints mot omegatak på 80, så budgeten är en säkerhetsskena snarare än en arbetande gräns

Felet som fick den skenan att kännas nödvändig var inte numeriskt alls. Signeringen verkade hänga sig, misstankarna föll på decompose och make_hint (FIPS 204-algoritmer 36 och 39), och den verkliga orsaken var ett omvänt ackumuleringsmål: vektorn som matar hintberäkningen måste ackumulera c*t0, medan den ursprungliga c*t0 måste överleva orörd för normkontrollen. Sikta båda mot samma buffert och slingan avvisar för alltid med perfekt korrekt aritmetik. På både framgångs- och budgettömningsvägarna nollställer uniten härledda frön, hemliga polynom, masker, utmaningen och kodningsbuffertarna; anroparens frö, hemliga nyckel och rnd förblir anroparens ansvar, vilket är den rätta uppdelningen för ett bibliotek som inte kan veta var de strängarna kom ifrån

Var möter ML-DSA PDF-signaturstacken i dag?

Var precis med vad som existerar. PDFlibPas levererar ML-DSA som verifierade signaturprimitiver plus en PKCS #11-mekanismbindning, inte som en drop-in-ersättning för din nuvarande PAdES-utdata. Tokenvägen är TPDFlibPKCS11Client.SignMLDSA, och den är medvetet en separat ingång eftersom CKM_ML_DSA konsumerar det råa meddelandet snarare än ett förberäknat sammandrag, så de befintliga SignHash- och extern-sammandrag-callbacks kan inte återanvändas. Certifikatfri upptäckt kräver att CertificateOptional aktiveras uttryckligen tillsammans med en privat-nyckel-etikett eller ett ID, och klienten validerar CKA_PARAMETER_SET mot vitlistan CKP_ML_DSA_44 / 65 / 87 vid anslutningstid, så att standardparningen av RSA- och ECDSA-certifikat aldrig luckras upp av misstag

Dokumentnivåintegrering är den del som fortfarande styrs av standardiseringsarbete snarare än av bibliotekskod. ISO 32000-2 §12.8 definierar signaturordboken och dess CMS-payload, och ISO/TS 32002 är fordonet för att utvidga det stödet till nyare hash- och signaturalgoritmer; tills dina validatorer och motparter följer med förblir klassisk signering produktionsvägen. Den praktiska hållningen är parallella spår: fortsätt leverera PAdES B-B till B-LTA-signaturer med tidsstämpling och långtidsvalideringsdata för allt en tredje part måste validera i dag, medan du bevisar ML-DSA-nyckelhantering och tokenintegrering vid sidan av. För lokala experiment ger samma självsignerade certifikatarbetsflöde byggt på CryptoAPI dig en signeringsidentitet utan att involvera en publik CA

Testa en parameteruppsättningsändring som du skulle testa vilken annan signeringsändring som helst. Storlekar först, sedan de officiella vektorerna, sedan de negativa fallen: en manipulerad signaturbyte, en felmatchad kontextsträng, en avhuggen nyckel. PDFlibPas täcker alla dessa i sin DUnitX-svit, och samma disciplin hör hemma i din egen pipeline, idealt jämte efterlevnads- och signeringsverkbanken som batchar validering över en dokumentkorpus så att en regression aldrig når en kund obemärkt

Postkvant-beredskap för dokumentprogramvara kommer inte som en enda omkopplare. Den kommer som primitiver du kan testa, en tokenväg du kan koppla ihop, och ett standardspår du följer utan att satsa den nuvarande utgåvan på det. För att se hur ML-DSA-uniten står jämte resten av signerings-, krypterings- och PDF/A-verktygen i en inhemsk Object Pascal-kodbas listar produktsidan PDFlibPas Delphi PDF library hela komponentuppsättningen och den stödda kompilatormatrisen