Technisch artikel

Post-kwantum en EdDSA PDF-ondertekening met HotPDF in Delphi

Een PDF met een ML-DSA-65-handtekening kan de check van de toolchain die hem produceerde doorstaan en toch bij een verificateur als niet-ondersteund algoritme terugkomen, één die eist dat het bestand declareert welke extensie zijn algoritme definieert. De publieke sleutel is 1.952 bytes, precies de FIPS 204-grootte voor die parameterset. De handtekeningbytes zijn intact en de byte ranges dekken het hele bestand. Wat zo'n verificateur zoekt is één entry in de Catalog van het document die die extensie noemt, want ISO 32000-2 vermeldt ML-DSA helemaal niet

De ondertekenkant heeft een andere muur. Een hardwaretoken, een cloud-HSM of een nationale eID-kaart rekent op verzoek een handtekening, maar geen enkele geeft de privésleutel aan je applicatie, dus ondertekeningscode die verwacht een PFX-bestand te laden en in-process te ondertekenen heeft simpelweg niets te laden. Beide problemen gaan over waar een beslissing woont: de algoritmedeclaratie woont in het bestand, en de sleutel woont ergens waar je Delphi-code niet kan komen

Waarom komt een geldige ML-DSA-handtekening als unsupported terug?

HotPDF verifieert ML-DSA-44-, ML-DSA-65- en ML-DSA-87-CMS-handtekeningen alleen wanneer het geladen document de HotPDF-developer-extensie in zijn Catalog declareert. Zonder die declaratie geeft de verificateur svUnsupportedAlgorithm terug voor een lattice-handtekening, hoe gezond de cryptografie ook is. De redenering is reproduceerbaarheid: een bestand dat een algoritme gebruikt die de basestandaard niet noemt, en niet zegt op welke extensie hij vertrouwt, levert een oordeel op dat een andere implementatie geen basis heeft om te herhalen

Aan de producerende kant schrijft THotPDF.EnsureMLDSAExtensions /Extensions << /HotPDF << /BaseVersion /2.0 /ExtensionLevel 1 >> >> in de Catalog en tilt het document naar PDF 2.0 als het op een oudere versie mikte. Aan de lezende kant geeft LoadedDocumentDeclaresMLDSAExtension alleen True terug wanneer dat dictionary aanwezig is met /BaseVersion gelijk aan 2.0 en een /ExtensionLevel van ten minste 1. VerifyLoadedSignatureWithOptions combineert de twee: hij neemt de AllowMLDSA-vlag van de aanroeper en ANDt die met de declaratietest voordat er überhaupt geverifieerd wordt, dus de vlag zetten op een niet-gedeclareerd bestand verandert niets

HotPDF zet ML-DSA-PDF-verificatie achter een expliciete Catalog-extensiedeclaratie geschreven door EnsureMLDSAExtensions voordat het document wordt opgeslagen, en weigert lattice-algoritmen op niet-gedeclareerde bestanden zelfs wanneer AllowMLDSA is gezet
HotPDF staat ML-DSA alleen toe wanneer de Catalog de extensie nog declareert, wat een post-quantum-oordeel reproduceerbaar houdt, en EnsureMLDSAExtensions vóór EndDoc aanroepen zet de declaratie binnen het ondertekende bytebereik in plaats van erna
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'contract-pq.pdf';
    Pdf.StrictVersionLock := False;   // de default; laat de aanroep het bestand naar PDF 2.0 tillen
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 720, 0, 'Supply agreement 2026-114');
    Pdf.EnsureMLDSAExtensions;        // declareer voordat een handtekening de Catalog dekt
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Twee details in dat fragment dragen gewicht. De aanroep moet gebeuren voordat het document wordt opgeslagen en ondertekend, want de Catalog zit binnen het ondertekende bytebereik; hem daarna inpatchen is óf een niet-ondertekende wijziging van een ondertekend bestand, óf een nieuwe incrementele revisie die elke validator als modificatie zal noteren. Het tweede detail is StrictVersionLock. Met het slot aan en het document vastgepind onder PDF 2.0 keert EnsureMLDSAExtensions terug zonder iets te schrijven, want een PDF 2.0-extensie kan niet in een 1.7-bestand worden gedeclareerd. Als je generator versies vastpint voor archiveringsredenen, controleer dan Version = pdf20 voordat je op de declaratie vertrouwt

Eén verificatieaanroep voor EdDSA en ML-DSA

VerifyLoadedSignatureWithOptions neemt een handtekeningsindex, de bronstroom, een THPDFCMSVerifyOptions-record en een out-THPDFSignatureInfo. Begin het record bij THPDFCMSVerifyOptions.Default, dat AllowMLDSA uit laat, een CMS-container bij 256 ondertekenaars kappt en nesting van countersignatures tot 8 beperkt. Drie velden doen ertoe voor de nieuwere algoritmen. SignatureProvider laat je eigen verificateur het eerst antwoorden. OpenSSLLibraryPath noemt de OpenSSL 3 libcrypto die pure-mode Ed25519- en Ed448-verificatie uitvoert. AllowMLDSA kiest voor de lattice-algoritmen, ondergeschikt aan de Catalog-test hierboven. Het record draagt ook velden om signature-policies vast te pinnen (RequireSignaturePolicy, ExpectedSignaturePolicyOID, ExpectedSignaturePolicyHash), die op elke algoritmafamilie van toepassing zijn

Het ingebouwde pad splitst per algoritme. Ed25519 en Ed448 lopen via OpenSSL 3; als er geen pad wordt opgegeven, probeert de platformprovider libcrypto-3-x64.dll, libcrypto-3.dll en libcrypto.dll via de normale DLL-zoekvolgorde op Win64. ML-DSA loopt via de Windows CNG ML-DSA-algoritmeprovider, en op een Windows-build die hem niet aanbiedt is het resultaat niet-ondersteund. Zet OpenSSLLibraryPath op een exemplaar dat je naast de executable meestuurt. Hem leeg laten betekent dat het oordeel afhangt van welke libcrypto een andere installer toevallig op het zoekpad heeft gedropt, en dat is lastig uit te leggen in een audit

Je eigen SignatureProvider wordt het eerst gevraagd. Geeft hij spsUnsupported terug, dan krijgt de ingebouwde platformprovider hetzelfde verzoek, dus een provider die alleen ML-DSA kent kan vóór de default EdDSA-afhandeling zitten zonder die na te bouwen

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);            // geef EdDSA door aan de platformprovider
      // MyFips204Verify is je eigen wikkel rond een ML-DSA-library;
      // Req.Input bevat de DER-gecodeerde 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;             // wordt nog steeds ge-AND met de Catalog-declaratie
    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;                             // het options-record is geen eigenaar
  end;
end;

Log Info.SignatureAlgorithmOID in plaats van welk algoritme je configuratie ook verwachtte. Voor ML-DSA-65 is dat 2.16.840.1.101.3.4.3.18, en een audit trail die de in het bestand gevonden OID registreert beantwoordt later vragen die een configuratiewaarde niet kan. Heb je helemaal geen custom provider nodig, dan bouwt de kortere overload VerifyLoadedSignature(SigIndex, Source, Info) default-opties en zet ze AllowMLDSA naar de Catalog-declaratie voor je

Encodingregels waar extern geproduceerde ML-DSA- en EdDSA-CMS over struikelt

Beide algoritmefamilies worden in pure mode over de volledige DER-gecodeerde signed attributes geverifieerd, en een SignerInfo zonder signed attributes wordt als niet-ondersteund gemeld. Voor EdDSA moeten de AlgorithmIdentifier-parameters afwezig zijn. Een veelgemaakte fout in handgemaakte CMS is eerst de signed attributes met SHA-512 of SHAKE256 te hashen en die digest te ondertekenen, wat stilletjes PureEdDSA in een prehash-variant verandert die niets in de CMS declareert. Voor ML-DSA controleert de verificateur ook de koppeling tussen parameterset en message digest: ML-DSA-44 accepteert SHA-256, SHA-384, SHA-512 of SHAKE256, ML-DSA-65 heeft SHA-384 of sterker nodig, en ML-DSA-87 heeft SHA-512 of SHAKE256 nodig. Een verkeerde koppeling verschijnt in Info.DigestConsistencyIssue, het eerste veld om te lezen wanneer een bestand uit een andere toolchain faalt

EdDSA heeft zijn eigen registratieverhaal. ISO/TS 32002 is de extensie die Ed25519 en Ed448 voor PDF 2.0-handtekeningen registreert, en EnsureISO32002Extensions voegt de bijbehorende ISO_-entry met ExtensionLevel 32002 toe aan de Catalog. Verificatie accepteert EdDSA ook zonder die entry, maar een bestand dat je produceert hoort hem toch te dragen, om dezelfde reden van reproduceerbaarheid als de ML-DSA-declaratie

Wat een signing-provider gevraagd wordt te doen

Een signing-provider ontvangt één THPDFSignatureProviderRequest en geeft een THPDFSignatureProviderStatus terug plus, bij succes, de handtekeningbytes. Het verzoek draagt het algoritme en zijn OID, de digest-algoritme-OID, de PSS-zoutlengte, of Input een boodschap is (sikMessage) of een vooraf berekende digest (sikDigest), de invoerbytes, het ondertekenaarscertificaat, een sleutelidentificatie en een bewerkingsidentificatie die uniek is voor de aanroep. Dat is het vocabulaire dat een token-driver of een signing-service al spreekt, en geen ervan vraagt dat de sleutel jouw proces binnenkomt

Elke provider adverteert ook een set capabilities zoals spcSign, spcCancel, spcHardwareKey en spcRemoteKey. Er verschepen verschillende implementaties met de library mee:

  • THPDFCallbackSignatureProvider wikkelt anonieme methodes voor verify, sign en cancel, de kortste route van een bestaande interne ondertekeningsroutine naar een PDF-handtekening
  • THPDFRemoteSignatureProvider wikkelt een transportfunctie met een retry-limiet, een annuleringsregister en grenzen aan invoer- en handtekeninggrootte, zodat een wangedragende remote-service geen te grote respons in de CMS-encoder kan duwen
  • THPDFPKCS11SignatureProvider leent een sessie en een private-key-handle die je code al heeft geopend en ingelogd. HotPDF ziet de PIN nooit en beheert de token-sessie niet; hij serialiseert C_Sign-aanroepen, zet de DigestInfo vóór RSA PKCS#1 v1.5, geeft de echte zoutlengte via mechaniekparameters aan RSA-PSS door en zet een rauw ECDSA-r||s-resultaat om naar de DER-vorm die CMS verwacht
  • THPDFWindowsKeyStorageSignatureProvider ondertekent via Windows CNG key storage, wat smartcards en TPM-ondersteunde sleutels dekt, en kan een hardware-ondersteunde sleutel eisen

De PKCS#11-provider neemt digest-invoer aan, dus hij behandelt RSA PKCS#1 v1.5, RSA-PSS en ECDSA. Ed25519 en Ed448 ondertekenen de volledige signed-attributes-boodschap, en die lopen via de callback- of remote-provider, waar jouw code Req.Input als boodschap aan het token of de service doorgeeft

HotPDF pluggable signature providers wisselen één plat verzoekrecord uit en geven een status met zes waarden terug, getoond voor de callback-, remote- en PKCS#11-implementaties, terwijl de privésleutel nooit het Delphi-proces binnenkomt
De callback-, remote- en PKCS#11-providers spreken hetzelfde verzoekrecord en antwoorden met dezelfde zes-waarden-status, waardoor hardwaretokens, remote-HSM-services en nationale eID-kaarten hun sleutels kunnen houden in plaats van ze te exporteren
var
  Provider: THPDFRemoteSignatureProvider;
  Opts: THPDFCMSSignOptions;
  CertDER: TBytes;
  InStream, OutStream: TFileStream;
begin
  CertDER := TFile.ReadAllBytes('signer.cer');   // System.IOUtils; DER-certificaat van de remote-sleutel
  Provider := THPDFRemoteSignatureProvider.Create(
    function(const Req: THPDFSignatureProviderRequest; Attempt: Integer;
      out Signature: TBytes): THPDFSignatureProviderStatus
    begin
      // PostToSigningService is je eigen HTTP-client: map timeouts en 5xx op
      // spsProviderError, een geweigerde goedkeuring op spsCancelled, een onbekende sleutel op spsUnsupported
      Result := PostToSigningService(Req.KeyIdentifier, Req.AlgorithmOID,
        Req.Input, Signature);
    end,
    3,          // RetryLimit: in totaal maximaal vier pogingen
    1048576,    // MaxInputBytes
    65536);     // MaxSignatureBytes
  Opts := HPDFCMSDefaultOptions(palBaseline_B_B);
  Opts.SignatureScheme := cmsRSAPSS;
  Opts.DigestAlgorithm := cmsdaSHA256;
  Opts.PSSSaltLength := 32;
  // contract-unsigned.pdf was opgeslagen met een placeholder uit 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;

Zes statussen, en wat elke ervan zou moeten triggeren

THPDFSignatureProviderStatus heeft zes waarden omdat de juiste reactie per waarde verschilt. spsInvalid is een cryptografisch falen en hoort in een securitylog. spsUnsupported betekent dat de provider het algoritme niet implementeert, een deployprobleem. spsMalformed betekent dat verzoek of respons de verkeerde vorm had. spsProviderError is een transport- of apparaatstoring die bij herkansing kan slagen. spsCancelled betekent dat een persoon de token-prompt heeft geweigerd, en die herhalen irriteert alleen maar

De remote-provider bouwt dat beleid in. Hij herkanst alleen op spsProviderError, controleert het annuleringsregister vóór elke poging, wijst lege of te grote invoer al als spsMalformed af voordat hij het transport aanroept, en degradeert een "geldig" antwoord met een lege of te grote handtekening naar spsMalformed. Aan de verificatiekant wordt het antwoord van een provider op de verifystatus afgebeeld: spsInvalid wordt svSignatureInvalid, spsMalformed wordt svMalformed, spsProviderError en spsCancelled worden allebei svCryptoProviderError, en spsUnsupported wordt svUnsupportedAlgorithm zodra ook de platformprovider het verzoek heeft geweigerd

Schrijf je zelf een provider, weersta dan de neiging elke onverwachte situatie op spsInvalid te mappen. Dat verandert een ontbrekende DLL of een verlopen service-token in een rapport dat de handtekening van de klant vervalst is, en iemand moet dat ooit aan de klant uitleggen. Geef spsInvalid alleen terug wanneer de wiskunde echt gefaald heeft. Geef bij ondertekenen spsValid alleen terug samen met een niet-lege handtekening; beide ingangspunten gooien een EHPDFCMSError wanneer een provider succes zonder bytes meldt

Waar de handtekening van de provider in het bestand landt

Twee functies koppelen een provider aan echte PDF-bytes. HPDFCMSBuildSignedDataWithProvider bouwt detached CMS vanuit een documentdigest die je elders berekende; de digestlengte moet bij Options.DigestAlgorithm passen, dus een SHA-384-configuratie verwacht 48 bytes en wijst een SHA-256-digest van 32 bytes met een exception af. HPDFCMSSignPDFStreamWithProvider ondertekent de placeholder die AddSignedSignatureField uittekende, hasht beide /ByteRange-vensters, vraagt de provider om de handtekening over de signed attributes en patcht het hexresultaat in /Contents. Groeit de CMS uit de placeholder, dan gooit de aanroep een exception en herstelt de niet-ondertekende placeholder in de outputstream in plaats van iets af te snijden

De layout is dezelfde die het PFX-ondertekeningspad schrijft: twee bereiken die het hexhandtekeningvenster overslaan. De verificateur houdt elk bestand aan die vorm, controleert dat het gat tussen de bereiken precies de /Contents-string van deze handtekening bevat en niets anders, en meldt anders svInvalidByteRange. Een document dat via een PKCS#11-token is ondertekend verifieert dus met dezelfde handtekeningverificatiecode als een document uit een PFX-bestand. De profielregels die boven de algoritmekeuze zitten worden behandeld in PAdES baseline-handtekeningen in Delphi, en de ECDSA-encodingvallen waar de PKCS#11-conversie mee afrekent staan in ECDSA CMS-verificatie en P1363-handtekeningsformaten

Twee HotPDF-provider-ondertekeningingangspunten, HPDFCMSBuildSignedDataWithProvider voor een extern berekende digest en HPDFCMSSignPDFStreamWithProvider voor een uitgetekende placeholder, komen samen in één CMS-layout die de /ByteRange-bereiken bewaart die validators het eerst controleren
Beide ingangspunten schrijven dezelfde ondertekende vorm van twee gedekte bereiken rond het hexvenster, dus een door een provider ondertekend document heeft dezelfde bytelayout als een document uit een PFX-bestand

Post-kwantumhandtekeningen uitrollen zonder lezers te stranden

Een PDF-reader die ML-DSA niet implementeert meldt zo'n handtekening als onverifieerbaar, en wie naar het document kijkt ziet een waarschuwing in plaats van een groen vinkje. Daarmee is de migratie een planningsprobleem. Houd RSA of ECDSA als de handtekening die de validators van vandaag zullen beoordelen. Waar een beleid kwantumbestendig bewijs vraagt, declareer dan de extensie met EnsureMLDSAExtensions en voeg naast de klassieke een tweede handtekening toe, geproduceerd door een ML-DSA-capabele ondertekenaar. Die handtekening komt uit je eigen FIPS 204-ondertekenaar, want de CMS-builder van HotPDF maakt geen ML-DSA-handtekeningen; het deel van de library is hem te verifiëren. Verschuif de primaire handtekening pas wanneer de systemen die je documenten consumeren hem kunnen verifiëren

In dat plan dekt HotPDF beide einden die je vanuit Delphi beheerst. Zijn CMS-builders ondertekenen met RSA PKCS#1 v1.5, RSA-PSS, ECDSA, Ed25519 en Ed448 via een van de providers hierboven, en zijn verificateur controleert ML-DSA, EdDSA en de klassieke algoritmen vanuit één aanroep, met de werkelijk gevonden OID geregistreerd in het resultaat. HotPDF is een native VCL-PDF-component voor Delphi en C++Builder die geen externe PDF-runtime nodig heeft, dus de ondertekenings- en verificatiecode compileert in je executable. Edities, de handtekeningsfeature-set en een proefdownload staan op de HotPDF Delphi PDF component-pagina