Technical Article

Post-Quantum and EdDSA PDF Signing with HotPDF in Delphi

A PDF carrying an ML-DSA-65 signature can pass the check of the toolchain that produced it and still come back as an unsupported algorithm from a verifier that insists the file declare which extension defines its algorithm. The public key is 1,952 bytes, exactly the FIPS 204 size for that parameter set. The signature bytes are intact and the byte ranges cover the whole file. What such a verifier looks for is one entry in the document Catalog naming that extension, because ISO 32000-2 does not list ML-DSA at all

The signing side has a different wall. A hardware token, a cloud HSM or a national eID card will compute a signature on request, but none of them will hand the private key to your application, so any signing code that expects to load a PFX file and sign in-process simply has nothing to load. Both problems are about where a decision lives: the algorithm declaration lives in the file, and the key lives somewhere your Delphi code cannot reach

Why does a valid ML-DSA signature come back unsupported?

HotPDF verifies ML-DSA-44, ML-DSA-65 and ML-DSA-87 CMS signatures only when the loaded document declares the HotPDF developer extension in its Catalog. Without that declaration the verifier returns svUnsupportedAlgorithm for a lattice signature, however sound the cryptography is. The reasoning is reproducibility: a file that uses an algorithm the base standard does not name, and does not say which extension it relies on, produces a verdict that another implementation has no basis to repeat

On the producing side, THotPDF.EnsureMLDSAExtensions writes /Extensions << /HotPDF << /BaseVersion /2.0 /ExtensionLevel 1 >> >> into the Catalog and raises the document to PDF 2.0 if it was targeting an older version. On the reading side, LoadedDocumentDeclaresMLDSAExtension returns True only when that dictionary is present with /BaseVersion equal to 2.0 and /ExtensionLevel of at least 1. VerifyLoadedSignatureWithOptions combines the two: it takes the caller's AllowMLDSA flag and ANDs it with the declaration test before any verification runs, so setting the flag on an undeclared file changes nothing

HotPDF gates ML-DSA PDF verification behind an explicit Catalog extension declaration written by EnsureMLDSAExtensions before the document is saved, and refuses lattice algorithms on undeclared files even when AllowMLDSA is set
HotPDF admits ML-DSA only when the Catalog still declares the extension, which keeps a post-quantum verdict reproducible, and calling EnsureMLDSAExtensions before EndDoc puts the declaration inside the signed byte range rather than after it
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'contract-pq.pdf';
    Pdf.StrictVersionLock := False;   // the default; lets the call raise the file to PDF 2.0
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 720, 0, 'Supply agreement 2026-114');
    Pdf.EnsureMLDSAExtensions;        // declare before any signature covers the Catalog
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Two details in that snippet carry weight. The call has to happen before the document is saved and signed, because the Catalog sits inside the signed byte range; patching it in afterwards is either an unsigned change to a signed file or a new incremental revision that any validator will list as a modification. The second detail is StrictVersionLock. With the lock on and the document pinned below PDF 2.0, EnsureMLDSAExtensions returns without writing anything, since a PDF 2.0 extension cannot be declared in a 1.7 file. If your generator pins versions for archival reasons, check Version = pdf20 before you rely on the declaration

One verification call for EdDSA and ML-DSA

VerifyLoadedSignatureWithOptions takes a signature index, the source stream, a THPDFCMSVerifyOptions record and an out THPDFSignatureInfo. Start the record from THPDFCMSVerifyOptions.Default, which leaves AllowMLDSA off, caps a CMS container at 256 signers and limits countersignature nesting to 8. Three fields matter for the newer algorithms. SignatureProvider lets your own verifier answer first. OpenSSLLibraryPath names the OpenSSL 3 libcrypto that performs pure-mode Ed25519 and Ed448 verification. AllowMLDSA opts into the lattice algorithms, subject to the Catalog test above. The record also carries signature-policy pinning fields (RequireSignaturePolicy, ExpectedSignaturePolicyOID, ExpectedSignaturePolicyHash), which apply to every algorithm family

The built-in path splits by algorithm. Ed25519 and Ed448 go through OpenSSL 3; when no path is given, the platform provider tries libcrypto-3-x64.dll, libcrypto-3.dll and libcrypto.dll through the normal DLL search order on Win64. ML-DSA goes through the Windows CNG ML-DSA algorithm provider, and on a Windows build that does not offer it the result is unsupported. Set OpenSSLLibraryPath to a copy you ship next to the executable. Leaving it empty means the verdict depends on whichever libcrypto another installer happened to drop on the search path, and that is a hard thing to explain in an audit

Your own SignatureProvider is asked first. If it returns spsUnsupported, the built-in platform provider gets the same request, so a provider that only knows ML-DSA can sit in front of the default EdDSA handling without reimplementing it

var
  Pdf: THotPDF;
  PQ: THPDFCallbackSignatureProvider;
  Opts: THPDFCMSVerifyOptions;
  Info: THPDFSignatureInfo;
  Status: THPDFSignatureVerifyStatus;
  Src: TFileStream;
begin
  PQ := THPDFCallbackSignatureProvider.Create(
    function(const Req: THPDFSignatureProviderRequest): THPDFSignatureProviderStatus
    begin
      if not (Req.Algorithm in [hsaMLDSA44, hsaMLDSA65, hsaMLDSA87]) then
        Exit(spsUnsupported);            // hand EdDSA on to the platform provider
      // MyFips204Verify is your own wrapper around an ML-DSA library;
      // Req.Input holds the DER-encoded signed attributes (pure mode)
      Result := MyFips204Verify(Req.AlgorithmOID, Req.PublicKey,
        Req.Input, Req.Signature);
    end);
  Pdf := THotPDF.Create(nil);
  Src := TFileStream.Create('contract-pq.pdf', fmOpenRead or fmShareDenyNone);
  try
    Pdf.LoadFromFile('contract-pq.pdf');
    Opts := THPDFCMSVerifyOptions.Default;
    Opts.SignatureProvider := PQ;
    Opts.OpenSSLLibraryPath := ExtractFilePath(ParamStr(0)) + 'libcrypto-3-x64.dll';
    Opts.AllowMLDSA := True;             // still ANDed with the Catalog declaration
    Status := Pdf.VerifyLoadedSignatureWithOptions(0, Src, Opts, Info);
    if Status = svValid then
      Memo1.Lines.Add(string(Info.SignatureAlgorithm) + ' / ' +
        string(Info.SignatureAlgorithmOID));
  finally
    Src.Free;
    Pdf.Free;
    PQ.Free;                             // the options record does not own it
  end;
end;

Log Info.SignatureAlgorithmOID rather than whatever algorithm your configuration expected. For ML-DSA-65 that is 2.16.840.1.101.3.4.3.18, and an audit trail that records the OID found in the file answers questions later that a configuration value cannot. If you do not need a custom provider at all, the shorter VerifyLoadedSignature(SigIndex, Source, Info) overload builds default options and sets AllowMLDSA from the Catalog declaration for you

Encoding rules that trip up externally produced ML-DSA and EdDSA CMS

Both algorithm families are verified in pure mode over the complete DER-encoded signed attributes, and a SignerInfo without signed attributes is reported as unsupported. For EdDSA the AlgorithmIdentifier parameters must be absent. A common mistake in hand-rolled CMS is to hash the signed attributes with SHA-512 or SHAKE256 first and sign that digest, which quietly turns PureEdDSA into a prehash variant that nothing in the CMS declares. For ML-DSA the verifier also checks the pairing between parameter set and message digest: ML-DSA-44 accepts SHA-256, SHA-384, SHA-512 or SHAKE256, ML-DSA-65 needs SHA-384 or stronger, and ML-DSA-87 needs SHA-512 or SHAKE256. A mismatched pairing shows up in Info.DigestConsistencyIssue, which is the first field to read when a file from another toolchain fails

EdDSA has its own registration story. ISO/TS 32002 is the extension that registers Ed25519 and Ed448 for PDF 2.0 signatures, and EnsureISO32002Extensions adds the matching ISO_ entry with ExtensionLevel 32002 to the Catalog. Verification accepts EdDSA without that entry, but a file you produce should still carry it, for the same reproducibility reason as the ML-DSA declaration

What a signing provider is asked to do

A signing provider receives one THPDFSignatureProviderRequest and returns a THPDFSignatureProviderStatus plus, on success, the signature bytes. The request carries the algorithm and its OID, the digest algorithm OID, the PSS salt length, whether Input is a message (sikMessage) or a precomputed digest (sikDigest), the input bytes, the signer certificate, a key identifier and an operation identifier unique to the call. That is the vocabulary a token driver or a signing service already speaks, and none of it requires the key to cross into your process

Each provider also advertises a set of capabilities such as spcSign, spcCancel, spcHardwareKey and spcRemoteKey. Several implementations ship in the library:

  • THPDFCallbackSignatureProvider wraps anonymous methods for verify, sign and cancel, which is the shortest route from an existing in-house signing routine to a PDF signature
  • THPDFRemoteSignatureProvider wraps a transport function with a retry limit, a cancellation registry and bounds on input and signature size, so a misbehaving remote service cannot push an oversized response into the CMS encoder
  • THPDFPKCS11SignatureProvider borrows a session and private-key handle that your code has already opened and logged in. HotPDF never sees the PIN and does not manage the token session; it serialises C_Sign calls, prepends the DigestInfo for RSA PKCS#1 v1.5, passes the real salt length to RSA-PSS through mechanism parameters, and converts a raw ECDSA r||s result into the DER form CMS expects
  • THPDFWindowsKeyStorageSignatureProvider signs through Windows CNG key storage, which covers smart cards and TPM-backed keys, and can require a hardware-backed key

The PKCS#11 provider takes digest input, so it handles RSA PKCS#1 v1.5, RSA-PSS and ECDSA. Ed25519 and Ed448 sign the full signed-attributes message, and those go through the callback or remote provider, where your code passes Req.Input to the token or service as a message

HotPDF pluggable signature providers exchange one plain request record and return a six-value status, shown for the callback, remote and PKCS#11 implementations while the private key never enters the Delphi process
The callback, remote and PKCS#11 providers speak the same request record and answer with the same six-value status, which lets hardware tokens, remote HSM services and national eID cards keep their keys instead of exporting them
var
  Provider: THPDFRemoteSignatureProvider;
  Opts: THPDFCMSSignOptions;
  CertDER: TBytes;
  InStream, OutStream: TFileStream;
begin
  CertDER := TFile.ReadAllBytes('signer.cer');   // System.IOUtils; DER certificate of the remote key
  Provider := THPDFRemoteSignatureProvider.Create(
    function(const Req: THPDFSignatureProviderRequest; Attempt: Integer;
      out Signature: TBytes): THPDFSignatureProviderStatus
    begin
      // PostToSigningService is your own HTTP client: map timeouts and 5xx to
      // spsProviderError, a declined approval to spsCancelled, an unknown key to spsUnsupported
      Result := PostToSigningService(Req.KeyIdentifier, Req.AlgorithmOID,
        Req.Input, Signature);
    end,
    3,          // RetryLimit: up to four attempts in total
    1048576,    // MaxInputBytes
    65536);     // MaxSignatureBytes
  Opts := HPDFCMSDefaultOptions(palBaseline_B_B);
  Opts.SignatureScheme := cmsRSAPSS;
  Opts.DigestAlgorithm := cmsdaSHA256;
  Opts.PSSSaltLength := 32;
  // contract-unsigned.pdf was saved with a placeholder from AddSignedSignatureField
  InStream := TFileStream.Create('contract-unsigned.pdf', fmOpenRead or fmShareDenyWrite);
  OutStream := TFileStream.Create('contract-signed.pdf', fmCreate);
  try
    HPDFCMSSignPDFStreamWithProvider(InStream, OutStream, CertDER,
      Provider, 'contracts-2026', Opts);
  finally
    OutStream.Free;
    InStream.Free;
    Provider.Free;
  end;
end;

Six statuses, and what each one should trigger

THPDFSignatureProviderStatus has six values because the right reaction differs for each. spsInvalid is a cryptographic failure and belongs in a security log. spsUnsupported means the provider does not implement the algorithm, which is a deployment problem. spsMalformed means the request or response had the wrong shape. spsProviderError is a transport or device fault that may succeed on retry. spsCancelled means a person declined the token prompt, and retrying it just annoys them

The remote provider builds that policy in. It retries only on spsProviderError, checks the cancellation registry before every attempt, rejects empty or oversized input as spsMalformed before calling the transport, and downgrades a "valid" answer with an empty or oversized signature to spsMalformed. On the verification side, a provider's answer maps onto the verify status: spsInvalid becomes svSignatureInvalid, spsMalformed becomes svMalformed, spsProviderError and spsCancelled both become svCryptoProviderError, and spsUnsupported becomes svUnsupportedAlgorithm once the platform provider has declined the request as well

If you write a provider, resist mapping every unexpected condition onto spsInvalid. That turns a missing DLL or an expired service token into a report that the customer's signature is forged, and someone will have to explain that to the customer. Return spsInvalid only when the math actually failed. For signing, return spsValid only together with a non-empty signature; both entry points raise EHPDFCMSError when a provider returns success with no bytes

Where the provider's signature lands in the file

Two functions connect a provider to real PDF bytes. HPDFCMSBuildSignedDataWithProvider builds detached CMS from a document digest you computed elsewhere; the digest length must match Options.DigestAlgorithm, so a SHA-384 configuration expects 48 bytes and rejects a 32-byte SHA-256 digest with an exception. HPDFCMSSignPDFStreamWithProvider signs the placeholder that AddSignedSignatureField laid out, hashes both /ByteRange windows, asks the provider for the signature over the signed attributes, and patches the hex result into /Contents. If the CMS outgrows the placeholder, the call raises an exception and restores the unsigned placeholder in the output stream instead of truncating anything

The layout is the same one the PFX-based signing path writes: two ranges that skip the hex signature window. The verifier holds every file to that shape, checking that the gap between the ranges contains exactly this signature's /Contents string and nothing else, and reporting svInvalidByteRange otherwise. A document signed through a PKCS#11 token therefore verifies with the same signature verification code as one signed from a PFX file. The profile rules that sit above the algorithm choice are covered in PAdES baseline signatures in Delphi, and the ECDSA encoding traps that the PKCS#11 conversion deals with are in ECDSA CMS verification and P1363 signature formats

Two HotPDF provider signing entry points, HPDFCMSBuildSignedDataWithProvider for an externally computed digest and HPDFCMSSignPDFStreamWithProvider for a laid-out placeholder, converge on one CMS layout that preserves the /ByteRange ranges validators check first
Either entry point writes the same signed shape of two covered ranges around the hex window, so a provider-signed document has the same byte layout as one signed from a PFX file

Rolling out post-quantum signatures without stranding readers

A PDF reader that does not implement ML-DSA reports such a signature as unverifiable, and the person looking at the document sees a warning rather than a green check. That makes the migration a scheduling problem. Keep RSA or ECDSA as the signature that today's validators will judge. Where a policy demands quantum-resistant evidence, declare the extension with EnsureMLDSAExtensions and add a second signature produced by an ML-DSA-capable signer alongside the classical one. That signature comes from your own FIPS 204 signer, since the HotPDF CMS builder does not create ML-DSA signatures; the library's part is to verify it. Move the primary signature only when the systems that consume your documents can verify it

In that plan HotPDF covers both ends you control from Delphi. Its CMS builders sign with RSA PKCS#1 v1.5, RSA-PSS, ECDSA, Ed25519 and Ed448 through any of the providers above, and its verifier checks ML-DSA, EdDSA and the classical algorithms from one call, with the OID that was actually found recorded in the result. HotPDF is a native VCL PDF component for Delphi and C++Builder that needs no external PDF runtime, so the signing and verification code compiles into your executable. Editions, the signature feature set and a trial download are on the HotPDF Delphi PDF component page