Teknisk artikel

ML-DSA i Delphi: FIPS 204 post-quantum i PDFlibPas

PDFlibPas implementerer ML-DSA, Module-Lattice-Based Digital Signature Algorithm, der er standardiseret i FIPS 204, udelukkende i Object Pascal. Alle tre parametersæt leveres som almindelige funktioner: MLDSA44Sign, MLDSA65Sign, MLDSA87Sign plus matchende KeyGen- og Verify-indgange. Ingen OpenSSL, ingen platform-DLL, ingen C-lim. Den enkelte unit PDFlibMLDSA afhænger af intet andet end bibliotekets SHAKE-sponge, og dens output matcher de officielle FIPS 204 known-answer-testvektorer byte for byte

Den sidste sætning er den eneste del, der krævede rigtigt arbejde. At skrive gitteraritmetik i Pascal er mekanisk; at få den til at stemme med NIST er ikke. Det følgende er porteringens tekniske beretning: hvordan de tre parametersæt endte med at dele én motor, og de specifikke defekter, der adskilte kompilerer og kører fra matcher KAT. Hvis du evaluerer post-quantum-muligheder til en Delphi- eller C++Builder-dokumentpipeline, er defekterne den nyttige del, for hver enkelt af dem producerer plausibelt udseende output, der stille fejler interoperabilitet

Hvorfor skrive en post-quantum-signeringsenhed i ren Object Pascal?

Fordi alternativet er én native-afhængighed pr. mål, og et Delphi PDF-bibliotek allerede har nok af dem. PDFlibPas bygges på tværs af Delphi, C++Builder og FPC/Lazarus på Win32-, Win64- og Unix-mål; at binde et C post-quantum-bibliotek ville betyde at følge en build af det til hver af de pladser plus kaldkonventions- og hukommelsesejerskabs-fladen mellem dem. En ren Pascal-unit kompilerer alle steder, hvor resten af biblioteket kompilerer, og det er hele argumentet

ML-DSA gør det usædvanligt billigt, fordi dets eneste primitive afhængighed er SHAKE. Der er intet big-integer-lag, ingen elliptisk kurve, ingen separat hash-suite. PDFlibPas fik en streaming-XOF i udgivelsen lige før porteringen: TPLShakeXOF i PDFlibDigest, hvor PLShakeXOFInit vælger SHAKE128 (rate 168) eller SHAKE256 (rate 136), efterfulgt af PLShakeXOFAbsorb, PLShakeXOFFinalize og en PLShakeXOFSqueeze-løkke, der bliver ved med at permuttere for vilkårlig outputlængde. Hver rejection-sampling-rutine i ML-DSA-uniten er skrevet direkte op ad det firekalds-API

Én motor, tre parametersæt: TMLDSAParams

PDFlibPas beskriver et helt ML-DSA-parametersæt med én record og vælger det efter sætnummer, så ML-DSA-44, 65 og 87 kører gennem de samme kodestier. Den første fungerende implementering var en fast 4x4-build hårdkodet til ML-DSA-44; at generalisere den betød at løfte k og l, eta, tau, beta, gamma1 og gamma2, omega og challenge-længden op i TMLDSAParams og derefter udlede alt andet. De offentlige indgangspunkter blev tre-linjers wrappere

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;

// Udlæste felter beregnes, aldrig afskrives fra en 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;

De fem udledte felter beregnes bevidst frem for at blive kopieret ud af FIPS 204-tabellerne. Håndafskrevne bitbredder er præcis den klasse af konstanter, der ser rigtig ud i review og er off by one i produktion, og to af de rigtige defekter i denne portering var af den form. De deklarerede størrelser forbliver som navngivne konstanter til validering: 1312 / 2560 / 2420 bytes offentlig nøgle, hemmelig nøgle og signatur for ML-DSA-44, 1952 / 4032 / 3309 for ML-DSA-65, 2592 / 4896 / 4627 for ML-DSA-87

PDFlibPas sender MLDSA44Sign, MLDSA65Sign og MLDSA87Sign gennem BuildMLDSAParams ind i en enkelt TMLDSAParams-record, hvis udledte felter beregnes frem for afskrives, så én delt MLDSASignInternal-motor betjener alle tre FIPS 204-parametersæt
Tre parametersæt deler én motor, fordi sætnummeret kun vælger en record, og de udledte bitbredder beregnes i stedet for at afskrives fra FIPS 204-tabellerne

Hvor går en ML-DSA-portering fra bunden først galt?

I expand_a, FIPS 204 Algorithm 32, og fejlmåden er smukt vildledende. Matrixen A samples ved at seede SHAKE128 med rho efterfulgt af to index-bytes, så seed-bufferen er 34 bytes: rho(32), så j, så i. Skrevet i Pascal med 1-baseret AnsiString-indeksering er de to bytes Msg[33] og Msg[34]. Dette porteringens første udkast skrev dem til Msg[34] og Msg[35], forskudt præcis én byte, og resultatet var et nøglepar, hvis rho matchede testvektoren perfekt, mens hver koefficient af t var forkert. Kun matrixen var forurenet, og matrixen er det eneste, den offentlige nøgle ikke bærer ordret

To flere defekter boede i samme rutine. Absorb-længden skal være 34, ikke 35; én ekstra affaldsbyte ændrer hele den squeezeede strøm. Og den indre afvisningsløkke skal indtage hver tre-byte-gruppe, blokken kan levere, inklusive den, der starter ved offset 165 i en 168-byte SHAKE128-blok, hvilket er 56 grupper pr. blok. Et cross-check-script, der stoppede ved offset 162, tabte halen af hver blok og forskød det samplede t1-præfiks fra omtrent den trettende byte og frem

SetLength(Msg, 34);
Move(Rho[1], Msg[1], 32);
Msg[33]:= AnsiChar(J);          // kolonneindeks først
Msg[34]:= AnsiChar(I);          // så rækkeindekset
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 bevarer gruppen ved offset 165: 56 trippler pr. 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;

Med de tre rettet matchede SHA-256-digests af de komplette ML-DSA-44 offentlige og hemmelige nøgler FIPS 204 known-answer-vektorerne. Én debug-lektion er også værd at navngive, for den kostede en session: når du bygger et Python cross-check til en XOF-drevet afvisningsløkke, returnerer hashlib.shake_128().digest(n) det samme præfiks ved hvert kald i stedet for at fortsætte strømmen. Tag hele længden én gang, og skær den derefter i rate-store blokke, ellers fortærer din reference gladelig de værdier, din Pascal korrekt afviste

PDFlibPas ML-DSA expand_a-rutinen seeder SHAKE128 med en 34-byte-buffer, der holder rho, kolonneindekset og rækkeindekset, ved siden af et første udkast, der forskød begge index-bytes, og et cross-check, der tabte den sidste tre-byte-gruppe af hver blok
To off-by-one-defekter i samme rutine: index-bytes skrevet én position for sent, og en afvisningsløkke, der stopper før tre-byte-gruppen ved offset 165

Sampling af eta: hvorfor ML-DSA-65 behøver sin egen gren

PDFlibPas holder to separate stier i expand_s, fordi FIPS 204 Algorithm 33 reelt definerer to. For eta = 2 afvises hver nibble, når den når 15, og ellers reduceres den mod 5. For eta = 4 afvises nibblen ved 9 eller derover og bruges derefter direkte uden nogen modulær reduktion overhovedet. ML-DSA-65 er det eneste leverede sæt med eta = 4, og genbrug af mod-5-stien til den forskubber s1 og s2 fra den allerførste koefficient, hvilket producerer et nøglepar, der er internt konsistent, verificerer mod sig selv og matcher intet, andre producerer

Procedure StoreNibble(Nibble: Byte);
Var
  M: Integer;
  Centered: Cardinal;
Begin
  If Cnt>= N Then
    Exit;
  If Eta= 4 Then
  Begin
    If Nibble>= 9 Then        // afvis, tag derefter nibblen som den er
      Exit;
    M:= Nibble;
  End
  Else
  Begin
    If Nibble>= 15 Then       // eta = 2: afvis 15, reducer derefter 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;

Størrelserne er testen: c-tilde-længden og gamma1-bitbredden

To kodningsparametre varierer med sikkerhedsniveauet på måder, der er lette at overse, når en fungerende ML-DSA-44-build ligger lige dér. Challenge-hashen c-tilde er 2 x lambda / 8 bytes, hvilket er 32 for ML-DSA-44, 48 for ML-DSA-65 og 64 for ML-DSA-87. At lade den stå fast på 32 giver en ML-DSA-65-signatur på 3293 bytes i stedet for standardens 3309, og KAT-præfikset divergerer straks. Record-feltet CTildaBytes findes præcis, så det tal ikke kan glemmes

Den anden er pakkingsbredden af mask-polynomiet z. PDFlibPas beregner den som BitWidth(Gamma1), ikke som eksponenten: gamma1 = 2^19 for ML-DSA-65 og 87 behøver 20 bits pr. koefficient, ikke 19, og den ene bit afgør, om hvert z-polynomium optager 640 bytes eller noget, ingen verifier vil parse. Verifieren bar en matchende defekt under porteringen, hvor z-deserialiseringsbufferen var sat til 192 bytes i stedet for 576. Signaturlængden er den billigste regressionstest, du nogensinde skriver: assert 2420, 3309 og 4627 mod Length(Signature), og de fleste parametriseringsfejl annoncerer sig selv, før du når en eneste kryptografisk assertion

PDFlibPas kobler to ML-DSA-kodningsparametre til sikkerhedsniveauet: c-tilde-challenge-hashen vokser fra 32 til 48 til 64 bytes, og mask-polynomiet z pakkes ved BitWidth af gamma1 bits, med signaturlængden som regressionstest
To parametre varierer med sikkerhedsniveauet, og en signatur, der kommer ud på 3293 bytes i stedet for 3309, annoncerer fejlen, før nogen kryptografisk assertion køres

Signering uden en ubegrænset løkke

ML-DSA-signering er afvisningsbaseret, så den prøver igen med forøget kappa, indtil en kandidatsignatur består sine norm- og hint-tjek. PDFlibPas begrænser det med et eksplicit eksternt budget på 65535 forsøg; ved udmattelse returnerer MLDSASignInternal False og efterlader signaturen tom i stedet for at snurre rundt inde i en dokumentproduktionstråd. I praksis lykkes den officielle ML-DSA-44-vektor ved kappa = 4 med 55 hints mod omega-loftet på 80, så budgettet er et sikkerhedsnet snarere end en arbejdsgrænse

Fejlen, der fik det sikkerhedsnet til at føles nødvendigt, var slet ikke numerisk. Signeringen så ud til at hænge, mistanken faldt på decompose og make_hint (FIPS 204 Algorithms 36 og 39), og den reelle årsag var et omvendt akkumuleringsmål: vektoren, der fodrer hint-beregningen, skal akkumulere c*t0, mens den oprindelige c*t0 skal overleve urørt til normtjekket. Sig begge mod samme buffer, og løkken afviser for evigt med perfekt korrekt aritmetik. På både succes- og budget-udmattelses-stierne nulstiller uniten udledte seeds, hemmelige polynomier, masker, challenge og kodningsbufferne; den kalder-leverede seed, hemmelige nøgle og rnd forbliver kalderens ansvar, hvilket er den rigtige deling for et bibliotek, der ikke kan vide, hvor de strenge kom fra

Hvor møder ML-DSA PDF-signaturstakken i dag?

Vær præcis omkring, hvad der findes. PDFlibPas leverer ML-DSA som verificerede signaturprimitiver plus en PKCS #11-mekanisme-binding, ikke som en drop-in-erstatning for dit nuværende PAdES-output. Tokenstien er TPDFlibPKCS11Client.SignMLDSA, og den er bevidst et separat indgangspunkt, fordi CKM_ML_DSA indtager den rå besked snarere end en forudberegnet digest, så de eksisterende SignHash- og external-digest-callbacks ikke kan genbruges. Certifikatfri opdagelse kræver, at CertificateOptional aktiveres eksplicit sammen med en privatnøglelabel eller et id, og klienten validerer CKA_PARAMETER_SET mod whitelisten CKP_ML_DSA_44 / 65 / 87 ved forbindelsestid, så standard RSA- og ECDSA-certifikatparring aldrig bliver løsnet ved et uheld

Dokumentniveau-integration er den del, der stadig styres af standardarbejde snarere end af bibliotekskode. ISO 32000-2 §12.8 definerer signaturordbogen og dens CMS-payload, og ISO/TS 32002 er køretøjet for at udvide den understøttelse til nyere hash- og signaturalgoritmer; indtil dine validatorer og modparter følger med, forbliver klassisk signering produktionsstien. Den praktiske holdning er parallelle spor: fortsæt med at levere PAdES B-B til B-LTA-signaturer med tidsstempling og langtidsvalideringsdata til alt, hvad en tredjepart skal validere i dag, mens du afprøver ML-DSA-nøglehåndtering og token-integration ved siden af. Til lokale eksperimenter giver samme selvsigneret certifikatworkflow bygget på CryptoAPI dig en signeringsidentitet uden at involvere en offentlig CA

Test et parametersæt-skifte, som du ville teste enhver anden signeringsændring. Størrelser først, så de officielle vektorer, så de negative tilfælde: en manipuleret signaturbyte, en mismatched kontekststreng, en afkortet nøgle. PDFlibPas dækker dem alle i sin DUnitX-suite, og samme disciplin hører hjemme i din egen pipeline, ideelt sammen med compliance- og signeringsarbejdsbænken, der batcher validering på tværs af et dokumentkorpus, så en regression aldrig når en kunde ubemærket

Post-quantum-klarhed til dokumentsoftware kommer ikke som en enkelt kontakt. Den kommer som primitiver, du kan teste, en tokensti, du kan tilslutte, og en standardspor, du følger uden at satse den aktuelle udgivelse på det. For at se, hvordan ML-DSA-uniten står ved siden af resten af signerings-, krypterings- og PDF/A-værktøjet i en native Object Pascal-kodebase, lister PDFlibPas Delphi PDF Library-produktsiden det fulde komponentsæt og den understøttede compiler-matrix