PDFlibPas implements ML-DSA, the Module-Lattice-Based Digital Signature Algorithm standardised 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; generalising 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
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
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 deserialisation 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 parameterisation mistakes announce themselves before you reach a single cryptographic assertion
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 zeroises 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 to 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