Technischer Artikel

ML-DSA in Delphi: FIPS 204 Post-Quantum in PDFlibPas

PDFlibPas implementiert ML-DSA, den in FIPS 204 standardisierten Module-Lattice-Based Digital Signature Algorithm, vollständig in Object Pascal. Alle drei Parametersätze liegen als gewöhnliche Funktionen vor: MLDSA44Sign, MLDSA65Sign, MLDSA87Sign, dazu passende KeyGen- und Verify-Einstiege. Kein OpenSSL, keine Plattform-DLL, kein C-Kleber. Die eine Unit PDFlibMLDSA hängt an nichts als dem SHAKE-Schwamm der Bibliothek, und ihre Ausgabe stimmt Byte für Byte mit den offiziellen FIPS-204-Known-Answer-Testvektoren überein

Dieser letzte Satz ist der einzige Teil, der echte Arbeit gemacht hat. Gitterarithmetik in Pascal zu schreiben ist mechanisch; sie mit NIST übereinstimmen zu lassen ist es nicht. Was folgt, ist der technische Bericht über den Port: wie die drei Parametersätze dazu kamen, sich eine Engine zu teilen, und welche konkreten Defekte kompiliert und läuft von stimmt mit dem KAT überein trennten. Wer Post-Quantum-Optionen für eine Delphi- oder C++Builder-Dokumentenpipeline abwägt, für den sind die Defekte der nützliche Teil, denn jeder einzelne von ihnen produziert plausibel aussehende Ausgaben, die still die Interoperabilität verfehlen

Warum einen Post-Quantum-Signer in reinem Object Pascal schreiben?

Weil die Alternative eine native Abhängigkeit pro Ziel ist, und eine Delphi-PDF-Bibliothek hat bereits genug davon. PDFlibPas baut auf Delphi, C++Builder und FPC/Lazarus unter Win32, Win64 und Unix-Zielen; eine C-Post-Quantum-Bibliothek zu binden hieße, für jeden dieser Slots einen Build davon zu verfolgen, plus die Aufrufkonventions- und Speicherbesitz-Oberfläche dazwischen. Eine reine Pascal-Unit kompiliert überall dort, wo der Rest der Bibliothek kompiliert, und das ist das gesamte Argument

ML-DSA macht das ungewöhnlich günstig, weil seine einzige primitive Abhängigkeit SHAKE ist. Es gibt keine Big-Integer-Schicht, keine elliptische Kurve, keine separate Hash-Suite. PDFlibPas bekam ein Streaming-XOF in dem Release unmittelbar vor dem Port: TPLShakeXOF in PDFlibDigest, wo PLShakeXOFInit SHAKE128 (Rate 168) oder SHAKE256 (Rate 136) wählt, gefolgt von PLShakeXOFAbsorb, PLShakeXOFFinalize und einer PLShakeXOFSqueeze-Schleife, die für beliebige Ausgabelänge weiter permutiert. Jede Rejection-Sampling-Routine in der ML-DSA-Unit ist direkt gegen diese Vier-Aufruf-API geschrieben

Eine Engine, drei Parametersätze: TMLDSAParams

PDFlibPas beschreibt einen gesamten ML-DSA-Parametersatz mit einem Record und wählt ihn über die Satznummer, sodass ML-DSA-44, 65 und 87 durch dieselben Codepfade laufen. Die erste funktionierende Implementierung war ein fixer 4x4-Build, fest verdrahtet auf ML-DSA-44; zu verallgemeinern hieß, k und l, eta, tau, beta, gamma1 und gamma2, omega und die Challenge-Länge in TMLDSAParams zu heben und alles Weitere daraus abzuleiten. Die öffentlichen Einstiegspunkte wurden Dreizeiler-Wrapper

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;

// Abgeleitete Felder werden berechnet, nie aus einer Tabelle abgeschrieben
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;

Die fünf abgeleiteten Felder sind mit Absicht berechnet statt aus den FIPS-204-Tabellen abgeschrieben. Handübertragene Bitbreiten sind exakt die Klasse von Konstante, die im Review richtig aussieht und in Produktion um eins danebenliegt, und zwei der echten Defekte in diesem Port hatten genau diese Form. Die deklarierten Größen bleiben als benannte Konstanten zur Validierung stehen: 1312 / 2560 / 2420 Bytes für öffentlichen Schlüssel, geheimen Schlüssel und Signatur bei ML-DSA-44, 1952 / 4032 / 3309 bei ML-DSA-65, 2592 / 4896 / 4627 bei ML-DSA-87

PDFlibPas führt MLDSA44Sign, MLDSA65Sign und MLDSA87Sign über BuildMLDSAParams in einen einzigen TMLDSAParams-Record, dessen abgeleitete Felder berechnet statt abgeschrieben werden, sodass eine geteilte MLDSASignInternal-Engine alle drei FIPS-204-Parametersätze bedient
Drei Parametersätze teilen sich eine Engine, weil die Satznummer nur einen Record wählt, und die abgeleiteten Bitbreiten werden berechnet statt aus den FIPS-204-Tabellen abgeschrieben

Wo geht ein ML-DSA-Port von Grund auf zuerst schief?

In expand_a, FIPS 204 Algorithmus 32, und die Fehlerform ist herrlich irreführend. Die Matrix A wird gesampelt, indem SHAKE128 mit rho plus zwei Indexbytes gespeist wird, der Seed-Puffer ist also 34 Bytes groß: rho(32), dann j, dann i. In Pascal mit 1-basierter AnsiString-Indizierung sind diese beiden Bytes Msg[33] und Msg[34]. Der erste Entwurf dieses Ports schrieb sie nach Msg[34] und Msg[35], um genau ein Byte verschoben, und das Ergebnis war ein Schlüsselpaar, dessen rho perfekt zum Testvektor passte, während jedes Koeffizientent von t falsch war. Nur die Matrix war verseucht, und die Matrix ist das eine Ding, das der öffentliche Schlüssel nicht wortgetreu mitführt

Zwei weitere Defekte wohnten in derselben Routine. Die Absorb-Länge muss 34 sein, nicht 35; ein zusätzliches Müllbyte verändert den gesamten gequetschten Strom. Und die innere Ablehnungsschleife muss jede Drei-Byte-Gruppe verbrauchen, die der Block liefern kann, einschließlich der Gruppe bei Offset 165 eines 168-Byte-SHAKE128-Blocks, das sind 56 Gruppen pro Block. Ein Gegenprüf-Skript, das bei Offset 162 aufhörte, warf den Schwanz jedes Blocks weg und verschob das gesampelte t1-Präfix ab etwa dem dreizehnten Byte

SetLength(Msg, 34);
Move(Rho[1], Msg[1], 32);
Msg[33]:= AnsiChar(J);          // zuerst der Spaltenindex
Msg[34]:= AnsiChar(I);          // dann der Zeilenindex
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 hält die Gruppe bei Offset 165: 56 Tripel pro 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;

Mit diesen drei Korrekturen stimmten die SHA-256-Digests des vollständigen ML-DSA-44-öffentlichen und geheimen Schlüssels mit den FIPS-204-Known-Answer-Vektoren überein. Eine Debug-Lektion ist es wert, benannt zu werden, denn sie kostete eine Sitzung: Wer einen Python-Gegencheck für eine XOF-getriebene Ablehnungsschleife baut, dem gibt hashlib.shake_128().digest(n) bei jedem Aufruf dasselbe Präfix zurück, statt den Strom fortzusetzen. Nehmen Sie die volle Länge einmal und schneiden Sie sie in ratengroße Blöcke, sonst konsumiert Ihre Referenz fröhlich erneut genau die Werte, die Ihr Pascal korrekt verworfen hat

Die ML-DSA-expand_a-Routine von PDFlibPas speist SHAKE128 mit einem 34-Byte-Puffer aus rho, Spaltenindex und Zeilenindex, daneben ein erster Entwurf, der beide Indexbytes verschob, und ein Gegencheck, der die letzte Drei-Byte-Gruppe jedes Blocks fallen ließ
Zwei Off-by-one-Defekte in derselben Routine: Indexbytes eine Position zu spät geschrieben, und eine Ablehnungsschleife, die vor der Drei-Byte-Gruppe bei Offset 165 stoppt

Eta sampeln: warum ML-DSA-65 einen eigenen Zweig braucht

PDFlibPas hält zwei getrennte Pfade in expand_s, weil FIPS 204 Algorithmus 33 wirklich zwei definiert. Für eta = 2 wird jedes Nibble verworfen, sobald es 15 erreicht, sonst mod 5 reduziert. Für eta = 4 wird das Nibble bei 9 oder höher verworfen und dann direkt übernommen, ganz ohne modulare Reduktion. ML-DSA-65 ist der einzige ausgelieferte Satz mit eta = 4, und der mod-5-Pfad für ihn verschob s1 und s2 ab dem allerersten Koeffizienten und produzierte ein Schlüsselpaar, das intern konsistent ist, sich gegen sich selbst verifizieren lässt und mit nichts übereinstimmt, was irgendjemand sonst produziert

Procedure StoreNibble(Nibble: Byte);
Var
  M: Integer;
  Centered: Cardinal;
Begin
  If Cnt>= N Then
    Exit;
  If Eta= 4 Then
  Begin
    If Nibble>= 9 Then        // verwerfen, dann das Nibble unverändert übernehmen
      Exit;
    M:= Nibble;
  End
  Else
  Begin
    If Nibble>= 15 Then       // eta = 2: 15 verwerfen, dann mod 5 reduzieren
      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;

Die Größen sind der Test: c-tilde-Länge und die gamma1-Bitbreite

Zwei Encoding-Parameter variieren mit der Sicherheitsstufe auf Arten, die leicht übersehen werden, wenn direkt daneben ein funktionierender ML-DSA-44-Build steht. Der Challenge-Hash c-tilde ist 2 x lambda / 8 Bytes groß, also 32 für ML-DSA-44, 48 für ML-DSA-65 und 64 für ML-DSA-87. Lässt man ihn fix auf 32, ergibt sich eine ML-DSA-65-Signatur von 3293 Bytes statt der normativen 3309, und das KAT-Präfix weicht sofort ab. Das Record-Feld CTildaBytes existiert genau dafür, dass diese Zahl nicht vergessen werden kann

Der zweite ist die Packbreite des Maskenpolynoms z. PDFlibPas berechnet sie als BitWidth(Gamma1), nicht als den Exponenten: gamma1 = 2^19 für ML-DSA-65 und 87 braucht 20 Bits pro Koeffizient, nicht 19, und dieses eine Bit entscheidet, ob jedes z-Polynom 640 Bytes belegt oder etwas, das kein Verifizierer parsen wird. Der Verifizierer trug den passenden Defekt während des Ports mit sich, wo der z-Deserialisierungspuffer mit 192 statt 576 Bytes dimensioniert war. Signaturlänge ist der billigste Regressionstest, den Sie je schreiben werden: 2420, 3309 und 4627 gegen Length(Signature) behaupten, und die meisten Parametrisierungsfehler melden sich, bevor Sie zu einer einzigen kryptografischen Behauptung kommen

PDFlibPas bindet zwei ML-DSA-Encoding-Parameter an die Sicherheitsstufe: der c-tilde-Challenge-Hash wächst von 32 auf 48 auf 64 Bytes, und das Maskenpolynom z wird mit BitWidth von gamma1 Bits gepackt, mit der Signaturlänge als Regressionstest
Zwei Parameter variieren mit der Sicherheitsstufe, und eine Signatur, die mit 3293 statt 3309 Bytes herauskommt, meldet den Fehler, bevor irgendeine kryptografische Behauptung läuft

Signieren ohne unbeendete Schleife

ML-DSA-Signieren basiert auf Ablehnung, es wiederholt also mit erhöhtem kappa, bis eine Kandidatensignatur ihre Norm- und Hint-Prüfungen besteht. PDFlibPas klemmt das mit einem expliziten äußeren Budget von 65535 Versuchen; bei Erschöpfung gibt MLDSASignInternal False zurück und lässt die Signatur leer, statt in einem Dokumentproduktions-Thread zu rotieren. In der Praxis gelingt der offizielle ML-DSA-44-Vektor bei kappa = 4 mit 55 Hints gegen die omega-Obergrenze von 80, das Budget ist also ein Sicherheitsgeländer, kein Arbeitslimit

Der Bug, der dieses Geländer nötig erscheinen ließ, war gar nicht numerisch. Das Signieren schien zu hängen, der Verdacht fiel auf decompose und make_hint (FIPS 204 Algorithmen 36 und 39), und die wahre Ursache war ein verdrehtes Akkumulationsziel: Der Vektor, der die Hint-Berechnung speist, muss c*t0 akkumulieren, während das ursprüngliche c*t0 für die Normprüfung unangetastet überleben muss. Beide auf denselben Puffer gerichtet, und die Schleife verwirft für immer, mit tadellos korrekter Arithmetik. Auf dem Erfolgs- wie auf dem Budget-erschoepften Pfad nullt die Unit abgeleitete Seeds, geheime Polynome, Masken, die Challenge und die Encoding-Puffer; der vom Aufrufer gelieferte Seed, der geheime Schlüssel und rnd bleiben Sache des Aufrufers, was die richtige Aufteilung für eine Bibliothek ist, die nicht wissen kann, woher diese Strings kommen

Wo trifft ML-DSA heute auf den PDF-Signaturstapel?

Seien Sie präzise, was existiert. PDFlibPas liefert ML-DSA als verifizierte Signaturprimitiven plus eine PKCS-#11-Mechanismus-Bindung, nicht als Drop-in-Ersatz für Ihre jetzige PAdES-Ausgabe. Der Token-Pfad ist TPDFlibPKCS11Client.SignMLDSA, und er ist mit Absicht ein separater Einstiegspunkt, weil CKM_ML_DSA die rohe Nachricht statt eines vorberechneten Digests konsumiert, sodass die vorhandenen SignHash- und External-Digest-Callbacks nicht wiederverwendet werden können. Zertifikatsfreie Entdeckung verlangt, dass CertificateOptional zusammen mit einem Private-Key-Label oder einer ID explizit aktiviert wird, und der Client validiert CKA_PARAMETER_SET zur Verbindungszeit gegen die Whitelist CKP_ML_DSA_44 / 65 / 87, sodass die Standard-RSA- und ECDSA-Zertifikatspaarung nie versehentlich aufgeweicht wird

Die Integration auf Dokumentebene ist der Teil, der noch von Normungsarbeit gesteuert wird, nicht von Bibliothekscode. ISO 32000-2 §12.8 definiert das Signaturwörterbuch und seine CMS-Nutzlast, und ISO/TS 32002 ist das Vehikel, um diese Unterstützung auf neuere Hash- und Signaturalgorithmen auszudehnen; bis Ihre Validierer und Gegenstellen nachziehen, bleibt klassisches Signieren der Produktionspfad. Die praktische Haltung sind parallele Gleise: PAdES B-B bis B-LTA-Signaturen mit Zeitstempelung und Long-Term-Validation-Daten weiter ausliefern für alles, was heute ein Dritter validieren muss, während man daneben ML-DSA-Schlüsselhandhabung und Token-Integration beweist. Für lokale Experimente liefert derselbe auf CryptoAPI aufgebaute Self-Signed-Certificate-Workflow eine Signatur-Identität, ohne eine öffentliche CA einzubeziehen

Testen Sie eine Parametersatz-Änderung so, wie Sie jede andere Signaturänderung testen würden. Erst die Größen, dann die offiziellen Vektoren, dann die Negativfälle: ein manipuliertes Signaturbyte, ein nicht passender Context-String, ein abgeschnittener Schlüssel. PDFlibPas deckt all das in seiner DUnitX-Suite ab, und dieselbe Disziplin gehört in Ihre eigene Pipeline, idealerweise neben der Compliance- und Signing-Workbench, die Validierung über einen Dokumentbestand stapelt, damit eine Regression nie unbemerkt beim Kunden ankommt

Post-Quantum-Bereitschaft für Dokumentensoftware kommt nicht als einzelner Schalter. Sie kommt als Primitive, die Sie testen können, als Token-Pfad, den Sie verdrahten können, und als Normungsstrang, dem Sie folgen, ohne das aktuelle Release darauf zu verwetten. Um zu sehen, wie die ML-DSA-Unit neben dem Rest der Signing-, Encryption- und PDF/A-Werkzeuge in einer nativen Object-Pascal-Codebasis sitzt, listet die Produktseite der PDFlibPas Delphi PDF Library den vollen Komponentenumfang und die unterstützte Compiler-Matrix auf