Teknisk artikkel

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

PDFlibPas implementerer ML-DSA, Module-Lattice-Based Digital Signature Algorithm standardisert i FIPS 204, helt i Object Pascal. Alle tre parametersettene leveres som vanlige funksjoner: MLDSA44Sign, MLDSA65Sign, MLDSA87Sign, pluss tilhørende KeyGen- og Verify-innganger. Ingen OpenSSL, ingen plattform-DLL, ingen C-lim. Den ene uniten PDFlibMLDSA avhenger av ingenting unntatt bibliotekets SHAKE-svamp, og utdataene matcher de offisielle FIPS 204 known-answer-testvektorene byte for byte

Den siste setningen er den eneste delen som tok ekte arbeid. Å skrive lattice-aritmetikk i Pascal er mekanisk; å få den til å stemme med NIST er ikke det. Det som følger er ingeniørberetningen om porteringen: hvordan de tre parametersettene endte opp med å dele én motor, og de konkrete defektene som skilte kompilerer og kjører fra matcher KAT-en. Vurderer du post-kvantum-alternativer for en Delphi- eller C++Builder-dokumentpipeline, er defektene den nyttige delen, for hver av dem produserer plausibel utdata som stille feiler interoperabilitet

Hvorfor skrive en post-kvantum-signer i ren Object Pascal?

Fordi alternativet er én innfødt avhengighet per mål, og et Delphi PDF-bibliotek har allerede nok av dem. PDFlibPas bygges på tvers av Delphi, C++Builder og FPC/Lazarus på Win32-, Win64- og Unix-mål; å binde et C post-kvantum-bibliotek ville bety å vedlikeholde et bygg av det for hver eneste av de plassene, pluss kallekonvensjons- og minneeiendoms-flaten mellom dem. En ren Pascal-unit kompilerer overalt der resten av biblioteket kompilerer, og det er hele argumentet

ML-DSA gjør dette uvanlig billig, fordi den eneste primitive avhengigheten er SHAKE. Intet big-integer-lag, ingen elliptisk kurve, ingen egen hash-samling. PDFlibPas fikk en streaming-XOF i utgivelsen rett før porteringen: TPLShakeXOF i PDFlibDigest, der PLShakeXOFInit velger SHAKE128 (rate 168) eller SHAKE256 (rate 136), etterfulgt av PLShakeXOFAbsorb, PLShakeXOFFinalize og en PLShakeXOFSqueeze-løkke som fortsetter å permutere for vilkårlig utdatalengde. Hver rejection-sampling-rutine i ML-DSA-uniten er skrevet direkte mot det firekalls-API-et

Én motor, tre parametersett: TMLDSAParams

PDFlibPas beskriver et helt ML-DSA-parametersett med én post og velger det etter settnummer, så ML-DSA-44, 65 og 87 går gjennom samme kodebaner. Den første fungerende implementeringen var et fast 4x4-bygg hardkablet for ML-DSA-44; å generalisere det betydde å løfte k og l, eta, tau, beta, gamma1 og gamma2, omega og utfordringslengden inn i TMLDSAParams, og deretter derivere alt annet. De offentlige inngangspunktene ble tre-linjers omslag

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;

// Deriverte felt beregnes, avskrives aldri fra 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 deriverte feltene beregnes med vilje i stedet for å kopieres ut av FIPS 204-tabellene. Håndavskrevne bitbredder er nøyaktig den klassen av konstanter som ser riktige ut i kodegjennomgang og er av med én i produksjon, og to av de virkelige defektene i denne porteringen var av den formen. De deklarerte størrelsene står som navngitte konstanter for validering: 1312 / 2560 / 2420 byte for offentlig nøkkel, hemmelig nøkkel og signatur for ML-DSA-44, 1952 / 4032 / 3309 for ML-DSA-65, 2592 / 4896 / 4627 for ML-DSA-87

PDFlibPas leder MLDSA44Sign, MLDSA65Sign og MLDSA87Sign gjennom BuildMLDSAParams inn i én enkelt TMLDSAParams-post hvis deriverte felt beregnes i stedet for å avskrives, slik én delt MLDSASignInternal-motor betjener alle tre FIPS 204-parametersettene
Tre parametersett deler én motor fordi settnummeret bare velger en post, og de deriverte bitbreddene beregnes i stedet for å avskrives fra FIPS 204-tabellene

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

I expand_a, FIPS 204 Algorithm 32, og feilmoden er vakkert villedende. Matrisen A samples ved å så SHAKE128 med rho etterfulgt av to indeksbyte, så så-bufferen er 34 byte: rho(32), så j, så i. Skrevet i Pascal med 1-basert AnsiString-indeksering er de to bytene Msg[33] og Msg[34]. Det første utkastet av denne porteringen skrev dem til Msg[34] og Msg[35], forskjøvet med nøyaktig én byte, og resultatet var et nøkkelpar hvis rho matchet testvektoren perfekt mens hver koeffisient av t var feil. Bare matrisen var forurenset, og matrisen er det eneste den offentlige nøkkelen ikke bærer ordrett

To flere defekter bodde i samme rutine. Absorb-lengden må være 34, ikke 35; én ekstra søppelbyte endrer hele den pressede strømmen. Og den indre avvisningsløkken må konsumere hver tre-byte-gruppe blokken kan levere, inkludert den som starter ved offset 165 i en SHAKE128-blokk på 168 byte, som er 56 grupper per blokk. Et krysssjekk-skript som stoppet ved offset 162 droppet halen av hver blokk og forskjøvet det samplede t1-prefikset fra omtrent trettende byte og utover

SetLength(Msg, 34);
Move(Rho[1], Msg[1], 32);
Msg[33]:= AnsiChar(J);          // kolonneindeksen først
Msg[34]:= AnsiChar(I);          // deretter radindeksen
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 beholder gruppen ved offset 165: 56 tripler per blokk
  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 matchet SHA-256-digestene av de komplette ML-DSA-44 offentlige og hemmelige nøklene FIPS 204 known-answer-vektorene. Én feilsøkingslærdom er verdt å nevne også, for den kostet en økt: når du bygger et Python-krysssjekk for en XOF-drevet avvisningsløkke, returnerer hashlib.shake_128().digest(n) samme prefiks ved hvert kall i stedet for å fortsette strømmen. Ta hele lengden én gang, og del den deretter opp i rate-store blokker, ellers gjenkonsumerer referansen i beste velgående nettopp verdiene Pascal-koden korrekt avviste

PDFlibPas ML-DSA expand_a-rutine sår SHAKE128 med en 34-byte-buffer som holder rho, kolonneindeksen og radindeksen, ved siden av et første utkast som forskjøvet begge indeksbytene og et krysssjekk som droppet den siste tre-byte-gruppen av hver blokk
To av-én-defekter i samme rutine: indeksbyte skrevet én posisjon for sent, og en avvisningsløkke som stopper før tre-byte-gruppen ved offset 165

Sampling av eta: hvorfor ML-DSA-65 trenger sin egen gren

PDFlibPas holder to separate baner i expand_s fordi FIPS 204 Algorithm 33 genuint definerer to. For eta = 2 avvises hver nibble når den når 15, ellers reduseres den mod 5. For eta = 4 avvises nibbleen ved 9 eller over og brukes deretter direkte, uten noen modulær reduksjon i det hele tatt. ML-DSA-65 er det eneste leverte settet med eta = 4, og å gjenbruke mod-5-banen for det feiljusterer s1 og s2 fra den aller første koeffisienten, noe som produserer et nøkkelpar som er internt konsistent, verifiserer mot seg selv og matcher ingenting andre produserer

Procedure StoreNibble(Nibble: Byte);
Var
  M: Integer;
  Centered: Cardinal;
Begin
  If Cnt>= N Then
    Exit;
  If Eta= 4 Then
  Begin
    If Nibble>= 9 Then        // avvis, bruk deretter nibbleen som den er
      Exit;
    M:= Nibble;
  End
  Else
  Begin
    If Nibble>= 15 Then       // eta = 2: avvis 15, reduser deretter 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ørrelsene er testen: c-tilde-lengden og gamma1-bitbredden

To kodingsparametre varierer med sikkerhetsnivået på måter som er lette å bomme på når et fungerende ML-DSA-44-bygg ligger der allerede. Utfordringshashen c-tilde er 2 x lambda / 8 byte, altså 32 for ML-DSA-44, 48 for ML-DSA-65 og 64 for ML-DSA-87. Å la den stå fast på 32 gir en ML-DSA-65-signatur på 3293 byte i stedet for standard 3309, og KAT-prefikset divergerer umiddelbart. Postfeltet CTildaBytes finnes presis slik at det tallet ikke kan glemmes

Den andre er pakkebredden til maskepolynomet z. PDFlibPas beregner den som BitWidth(Gamma1), ikke som eksponenten: gamma1 = 2^19 for ML-DSA-65 og 87 trenger 20 bit per koeffisient, ikke 19, og den ene biten avgjør om hvert z-polynom opptar 640 byte eller noe ingen verifikator vil tolke. Verifikatoren bar en tilsvarende defekt under porteringen, der z-deserialiseringsbufferen ble dimensjonert til 192 byte i stedet for 576. Signaturlengden er den billigste regresjonstesten du noen gang skriver: assert 2420, 3309 og 4627 mot Length(Signature), og de fleste parameteriseringsfeil annonserer seg selv før du kommer til et eneste kryptografisk utsagn

PDFlibPas knytter to ML-DSA-kodingsparametre til sikkerhetsnivået: c-tilde-utfordringshashen vokser fra 32 til 48 til 64 byte, og maskepolynomet z pakkes ved BitWidth av gamma1 biter, med signaturlengde som regresjonstest
To parametre varierer med sikkerhetsnivået, og en signatur på 3293 byte i stedet for 3309 annonserer feilen før noe kryptografisk utsagn kjøres

Å signere uten en ubegrenset løkke

ML-DSA-signering er avvisningsbasert, så den prøver igjen med økt kappa til en kandidatsignatur består sine norm- og hint-sjekker. PDFlibPas setter et tak på det med et eksplisitt eksternt budsjett på 65535 forsøk; ved utmattelse returnerer MLDSASignInternal False og lar signaturen stå tom i stedet for å spinne inne i en dokumentproduserende tråd. I praksis lykkes den offisielle ML-DSA-44-vektoren ved kappa = 4 med 55 hint mot omega-taket på 80, så budsjettet er en sikringsskinne snarere enn en arbeidsgrense

Feilen som fikk den skinnen til å virke nødvendig, var ikke numerisk i det hele tatt. Signeringen så ut til å henge, mistanken falt på decompose og make_hint (FIPS 204 Algorithms 36 og 39), og den virkelige årsaken var et vendt akkumuleringsmål: vektoren som mater hint-beregningen må akkumulere c*t0, mens den opprinnelige c*t0 må overleve urørt for normsjekken. Sikte begge mot samme buffer, og løkken avviser for alltid med fullstendig korrekt aritmetikk. På både suksess- og budsjettutmattelsesbanene nullstiller uniten deriverte frø, hemmelige polynomer, masker, utfordringen og kodebufferne; frøet, den hemmelige nøkkelen og rnd levert av kalleren forblir kallerens ansvar, som er riktig splitt for et bibliotek som ikke kan vite hvor de strengene kom fra

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

Vær presis med hva som finnes. PDFlibPas leverer ML-DSA som verifiserte signaturprimitiver pluss en PKCS #11-mekanisme-binding, ikke som en drop-in-erstatning for dagens PAdES-utdata. Token-banen er TPDFlibPKCS11Client.SignMLDSA, og den er bevisst et separat inngangspunkt fordi CKM_ML_DSA konsumerer rå meldingen i stedet for en forhåndsberegnet digest, så de eksisterende SignHash- og ekstern-digest-callbacks kan ikke gjenbrukes. Sertifikatfri oppdagelse krever at CertificateOptional aktiveres eksplisitt sammen med en privatnøkkel-etikett eller -ID, og klienten validerer CKA_PARAMETER_SET mot hvitelisten CKP_ML_DSA_44 / 65 / 87 ved tilkobling, så standard RSA- og ECDSA-sertifikatparing løsnes aldri ved et uhell

Integrasjon på dokumentnivå er delen som fortsatt styres av standardiseringsarbeid snarere enn av bibliotekskode. ISO 32000-2 §12.8 definerer signaturordboken og dens CMS-pakke, og ISO/TS 32002 er kjøretøyet for å utvide den støtten til nyere hash- og signaturalgoritmer; helt til validatorene og motpartene dine følger etter, forblir klassisk signering produksjonsbanen. Den praktiske holdningen er parallelle spor: fortsett å levere PAdES B-B til B-LTA-signaturer med tidsstempling og langsiktige valideringsdata for alt en tredjepart må validere i dag, samtidig som du prøver ut ML-DSA-nøkkelhåndtering og token-integrasjon ved siden av. For lokale eksperimenter gir samme selvsignert sertifikat-arbeidsflyt bygget på CryptoAPI deg en signeringsidentitet uten å involvere en offentlig CA

Test en parametersett-endring slik du ville testet enhver annen signeringsendring. Størrelser først, så de offisielle vektorene, så de negative tilfellene: en manipulert signaturbyte, en feilpassende kontekststreng, en avkortet nøkkel. PDFlibPas dekker alt dette i DUnitX-suiten sin, og samme disiplin hører hjemme i din egen pipeline, ideelt sett ved siden av samsvars- og signeringsbenken som bunkevaliderer på tvers av en dokumentkorpus så en regresjon aldri når en kunde ubemerket

Post-kvantum-beredskap for dokumentprogramvare kommer ikke som én enkelt bryter. Den kommer som primitiver du kan teste, en token-bane du kan koble til, og et standardspor du følger uten å satse gjeldende utgivelse på det. For å se hvordan ML-DSA-uniten ligger ved siden av resten av signerings-, krypterings- og PDF/A-verktøyet i en innfødt Object Pascal-kodebase, lister produktssiden for PDFlibPas Delphi PDF library opp hele komponentsettet og den støttede kompilatormatrisen