Technical Article

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

PDFlibPas implements ML-DSA, the Module-Lattice-Based Digital Signature Algorithm standardized in FIPS 204, entirely in Object Pascal. All three parameter sets ship as ordinary functions: MLDSA44Sign, MLDSA65Sign, MLDSA87Sign, plus matching KeyGen and Verify entries. No OpenSSL, no platform DLL, no C glue. The single unit PDFlibMLDSA depends on nothing but the library SHAKE sponge, and its output matches the official FIPS 204 known-answer test vectors byte for byte

That last sentence is the only part that took real work. Writing lattice arithmetic in Pascal is mechanical; getting it to agree with NIST is not. What follows is the engineering account of the port: how the three parameter sets ended up sharing one engine, and the specific defects that separated compiles and runs from matches the KAT. If you are evaluating post-quantum options for a Delphi or C++Builder document pipeline, the defects are the useful part, because every one of them produces plausible-looking output that silently fails interoperability

Why write a post-quantum signer in pure Object Pascal?

Because the alternative is one native dependency per target, and a Delphi PDF library already has enough of those. PDFlibPas builds across Delphi, C++Builder and FPC/Lazarus on Win32, Win64 and Unix targets; binding a C post-quantum library would mean tracking a build of it for every one of those slots, plus the calling-convention and memory-ownership surface between them. A pure Pascal unit compiles wherever the rest of the library compiles, and that is the whole argument

ML-DSA makes this unusually cheap, because its only primitive dependency is SHAKE. There is no big-integer layer, no elliptic curve, no separate hash suite. PDFlibPas gained a streaming XOF in the release immediately before the port: TPLShakeXOF in PDFlibDigest, where PLShakeXOFInit selects SHAKE128 (rate 168) or SHAKE256 (rate 136), followed by PLShakeXOFAbsorb, PLShakeXOFFinalize and a PLShakeXOFSqueeze loop that keeps permuting for arbitrary output length. Every rejection-sampling routine in the ML-DSA unit is written directly against that four-call API

One engine, three parameter sets: TMLDSAParams

PDFlibPas describes an entire ML-DSA parameter set with one record and selects it by set number, so ML-DSA-44, 65 and 87 run through the same code paths. The first working implementation was a fixed 4x4 build hard-wired for ML-DSA-44; generalizing it meant lifting k and l, eta, tau, beta, gamma1 and gamma2, omega and the challenge length into TMLDSAParams, then deriving everything else. The public entry points became three-line wrappers

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;

// Derived fields are computed, never transcribed from a table
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;

The five derived fields are computed rather than copied out of the FIPS 204 tables on purpose. Hand-transcribed bit widths are exactly the class of constant that looks right in review and is off by one in production, and two of the real defects in this port were of that shape. The declared sizes stay as named constants for validation: 1312 / 2560 / 2420 bytes of public key, secret key and signature for ML-DSA-44, 1952 / 4032 / 3309 for ML-DSA-65, 2592 / 4896 / 4627 for ML-DSA-87

PDFlibPas routes MLDSA44Sign, MLDSA65Sign and MLDSA87Sign through BuildMLDSAParams into a single TMLDSAParams record whose derived fields are computed rather than transcribed, so one shared MLDSASignInternal engine serves all three FIPS 204 parameter sets
Three parameter sets share one engine because the set number only selects a record, and the derived bit widths are computed instead of transcribed from the FIPS 204 tables

Where does a from-scratch ML-DSA port go wrong first?

In expand_a, FIPS 204 Algorithm 32, and the failure mode is beautifully misleading. The matrix A is sampled by seeding SHAKE128 with rho followed by two index bytes, so the seed buffer is 34 bytes: rho(32), then j, then i. Written in Pascal with 1-based AnsiString indexing those two bytes are Msg[33] and Msg[34]. The first draft of this port wrote them to Msg[34] and Msg[35], shifted by exactly one byte, and the result was a key pair whose rho matched the test vector perfectly while every coefficient of t was wrong. Only the matrix was polluted, and the matrix is the one thing the public key does not carry verbatim

Two more defects lived in the same routine. The absorb length has to be 34, not 35; one extra garbage byte changes the entire squeezed stream. And the inner rejection loop must consume every three-byte group the block can supply, including the one starting at offset 165 of a 168-byte SHAKE128 block, which is 56 groups per block. A cross-check script that stopped at offset 162 dropped the tail of every block and shifted the sampled t1 prefix from roughly the thirteenth byte onward

SetLength(Msg, 34);
Move(Rho[1], Msg[1], 32);
Msg[33]:= AnsiChar(J);          // column index first
Msg[34]:= AnsiChar(I);          // then the row index
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 keeps the group at offset 165: 56 triples 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;

With those three corrected, the SHA-256 digests of the complete ML-DSA-44 public and secret keys matched the FIPS 204 known-answer vectors. One debugging lesson is worth naming too, because it cost a session: when you build a Python cross-check for a XOF-driven rejection loop, hashlib.shake_128().digest(n) returns the same prefix on every call instead of continuing the stream. Take the whole length once, then slice it into rate-sized blocks, or your reference will happily re-consume the very values your Pascal correctly rejected

The PDFlibPas ML-DSA expand_a routine seeds SHAKE128 with a 34-byte buffer holding rho, the column index and the row index, beside a first draft that shifted both index bytes and a cross-check that dropped the last three-byte group of every block
Two off-by-one defects in the same routine: index bytes written one position late, and a rejection loop that stops before the three-byte group at offset 165

Sampling eta: why ML-DSA-65 needs its own branch

PDFlibPas keeps two separate paths in expand_s because FIPS 204 Algorithm 33 genuinely defines two. For eta = 2 each nibble is rejected when it reaches 15 and otherwise reduced mod 5. For eta = 4 the nibble is rejected at 9 or above and then used directly, with no modular reduction at all. ML-DSA-65 is the only shipped set with eta = 4, and reusing the mod 5 path for it misaligns s1 and s2 from the very first coefficient, producing a key pair that is internally consistent, verifies against itself, and matches nothing anyone else produces

Procedure StoreNibble(Nibble: Byte);
Var
  M: Integer;
  Centered: Cardinal;
Begin
  If Cnt>= N Then
    Exit;
  If Eta= 4 Then
  Begin
    If Nibble>= 9 Then        // reject, then take the nibble as-is
      Exit;
    M:= Nibble;
  End
  Else
  Begin
    If Nibble>= 15 Then       // eta = 2: reject 15, then 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;

The sizes are the test: c-tilde length and the gamma1 bit width

Two encoding parameters vary with the security level in ways that are easy to miss when a working ML-DSA-44 build is sitting right there. The challenge hash c-tilde is 2 x lambda / 8 bytes, which is 32 for ML-DSA-44, 48 for ML-DSA-65 and 64 for ML-DSA-87. Leaving it fixed at 32 yields an ML-DSA-65 signature of 3293 bytes instead of the standard 3309, and the KAT prefix diverges immediately. The record field CTildaBytes exists precisely so that number cannot be forgotten

The second is the packing width of the mask polynomial z. PDFlibPas computes it as BitWidth(Gamma1), not as the exponent: gamma1 = 2^19 for ML-DSA-65 and 87 needs 20 bits per coefficient, not 19, and that single bit decides whether each z polynomial occupies 640 bytes or something no verifier will parse. The verifier carried a matching defect during the port, where the z deserialization buffer was sized 192 bytes instead of 576. Signature length is the cheapest regression test you will ever write: assert 2420, 3309 and 4627 against Length(Signature) and most parameterization mistakes announce themselves before you reach a single cryptographic assertion

PDFlibPas ties two ML-DSA encoding parameters to the security level: the c-tilde challenge hash grows from 32 to 48 to 64 bytes, and the mask polynomial z is packed at BitWidth of gamma1 bits, with signature length as the regression test
Two parameters vary with the security level, and a signature that comes out 3293 bytes instead of 3309 announces the mistake before any cryptographic assertion runs

Signing without an unbounded loop

ML-DSA signing is rejection-based, so it retries with an incremented kappa until a candidate signature passes its norm and hint checks. PDFlibPas caps that with an explicit external budget of 65535 attempts; on exhaustion MLDSASignInternal returns False and leaves the signature empty rather than spinning inside a document-production thread. In practice the official ML-DSA-44 vector succeeds at kappa = 4 with 55 hints against the omega ceiling of 80, so the budget is a safety rail rather than a working limit

The bug that made that rail feel necessary was not numerical at all. Signing appeared to hang, suspicion fell on decompose and make_hint (FIPS 204 Algorithms 36 and 39), and the real cause was a reversed accumulation target: the vector feeding the hint computation must accumulate c*t0, while the original c*t0 has to survive untouched for the norm check. Aim both at the same buffer and the loop rejects forever with perfectly correct arithmetic. On both the success and budget-exhausted paths the unit zeroizes derived seeds, secret polynomials, masks, the challenge and the encoding buffers; the caller-supplied seed, secret key and rnd stay the caller's responsibility, which is the right split for a library that cannot know where those strings came from

Where does ML-DSA meet the PDF signature stack today?

Be precise about what exists. PDFlibPas ships ML-DSA as verified signature primitives plus a PKCS #11 mechanism binding, not as a drop-in replacement for your current PAdES output. The token path is TPDFlibPKCS11Client.SignMLDSA, and it is deliberately a separate entry point because CKM_ML_DSA consumes the raw message rather than a precomputed digest, so the existing SignHash and external-digest callbacks cannot be reused. Certificate-free discovery requires CertificateOptional to be enabled explicitly along with a private-key label or ID, and the client validates CKA_PARAMETER_SET against the CKP_ML_DSA_44 / 65 / 87 whitelist at connect time, so the default RSA and ECDSA certificate pairing never gets loosened by accident

Document-level integration is the part still governed by standards work rather than by library code. ISO 32000-2 §12.8 defines the signature dictionary and its CMS payload, and ISO/TS 32002 is the vehicle for extending that support to newer hash and signature algorithms; until your validators and counterparties follow, classical signing remains the production path. The practical posture is parallel tracks: keep shipping PAdES B-B through B-LTA signatures with timestamping and long-term validation data for anything a third party must validate today, while proving out ML-DSA key handling and token integration alongside it. For local experiments the same self-signed certificate workflow built on CryptoAPI gives you a signing identity without involving a public CA

Test a parameter-set change the way you would test any other signing change. Sizes first, then the official vectors, then the negative cases: a tampered signature byte, a mismatched context string, a truncated key. PDFlibPas covers all of those in its DUnitX suite, and the same discipline belongs in your own pipeline, ideally alongside the compliance and signing workbench that batches validation across a document corpus so a regression never reaches a customer unnoticed

Post-quantum readiness for document software will not arrive as a single switch. It arrives as primitives you can test, a token path you can wire up, and a standards track you follow without betting the current release on it. To see how the ML-DSA unit sits alongside the rest of the signing, encryption and PDF/A tooling in a native Object Pascal codebase, the PDFlibPas Delphi PDF library product page lists the full component set and the supported compiler matrix