Artykuł techniczny

Podpisywanie PDF post-kwantowe i EdDSA z HotPDF w Delphi

PDF niosący podpis ML-DSA-65 może przejść sprawdzenie toolchaina, który go wyprodukował, i wciąż wrócić jako nieobsługiwany algorytm od weryfikatora, który nalega, żeby plik deklarował, które rozszerzenie definiuje jego algorytm. Klucz publiczny ma 1 952 bajty — dokładnie rozmiar FIPS 204 dla tego zestawu parametrów. Bajty podpisu są nietknięte, a zakresy bajtów pokrywają cały plik. Taki weryfikator szuka jednej pozycji w Catalog dokumentu nazywającej to rozszerzenie, bo ISO 32000-2 w ogóle nie wymienia ML-DSA

Strona podpisująca ma inną ścianę. Sprzętowy token, chmurowy HSM albo narodowa karta eID policzą podpis na życzenie, ale żaden nie odda klucza prywatnego twojej aplikacji, więc każdy kod podpisujący, który oczekuje załadować plik PFX i podpisać in-process, nie ma po prostu czego ładować. Oba problemy dotyczą tego, gdzie mieszka decyzja: deklaracja algorytmu mieszka w pliku, a klucz mieszka gdzieś, gdzie twój kod Delphi nie sięga

Czemu poprawny podpis ML-DSA wraca jako nieobsługiwany?

HotPDF weryfikuje podpisy CMS ML-DSA-44, ML-DSA-65 i ML-DSA-87 tylko wtedy, gdy załadowany dokument deklaruje w swoim Catalog rozszerzenie deweloperskie HotPDF. Bez tej deklaracji weryfikator zwraca svUnsupportedAlgorithm dla podpisu kratowego, jakkolwiek zdrowa jest kryptografia. Rozumowanie to powtarzalność: plik, który używa algorytmu, którego standard bazowy nie wymienia, i nie mówi, na jakim rozszerzeniu się opiera, produkuje werdykt, którego inna implementacja nie ma na czym powtórzyć

Po stronie produkującej THotPDF.EnsureMLDSAExtensions zapisuje /Extensions << /HotPDF << /BaseVersion /2.0 /ExtensionLevel 1 >> >> do Catalog i podnosi dokument do PDF 2.0, jeśli celował w starszą wersję. Po stronie czytającej LoadedDocumentDeclaresMLDSAExtension zwraca True tylko wtedy, gdy ten słownik jest obecny z /BaseVersion równym 2.0 i /ExtensionLevel co najmniej 1. VerifyLoadedSignatureWithOptions składa jedno i drugie: bierze flagę AllowMLDSA wywołującego i koniunkuje ją z testem deklaracji, zanim jakakolwiek weryfikacja ruszy, więc ustawienie flagi na niezadeklarowanym pliku nie zmienia niczego

HotPDF zamyka weryfikację PDF ML-DSA za jawną deklaracją rozszerzenia katalogu zapisywaną przez EnsureMLDSAExtensions przed zapisaniem dokumentu i odmawia algorytmów kraty na niezadeklarowanych plikach, nawet gdy AllowMLDSA jest ustawione
HotPDF dopuszcza ML-DSA tylko wtedy, gdy Catalog wciąż deklaruje rozszerzenie, co czyni werdykt postkwantowy powtarzalnym, a wywołanie EnsureMLDSAExtensions przed EndDoc kładzie deklarację wewnątrz podpisanego zakresu bajtów, a nie po nim
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'contract-pq.pdf';
    Pdf.StrictVersionLock := False;   // domyślne; pozwala wywołaniu podnieść plik do PDF 2.0
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 720, 0, 'Supply agreement 2026-114');
    Pdf.EnsureMLDSAExtensions;        // zadeklaruj, zanim jakikolwiek podpis obejmie Catalog
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Dwa szczegóły w tym fragmencie niosą ciężar. Wywołanie musi się wydarzyć, zanim dokument zostanie zapisany i podpisany, bo Catalog siedzi wewnątrz podpisanego zakresu bajtów; wstawienie go po fakcie to albo niepodpisana zmiana podpisanego pliku, albo nowa rewizja przyrostowa, którą każdy walidator wypisze jako modyfikację. Drugim szczegółem jest StrictVersionLock. Z włączoną blokadą i dokumentem przypiętym poniżej PDF 2.0 EnsureMLDSAExtensions wraca, niczego nie zapisując, bo rozszerzenia PDF 2.0 nie da się zadeklarować w pliku 1.7. Jeśli twój generator przypina wersje względami archiwizacji, sprawdź Version = pdf20, zanim zaczniesz polegać na deklaracji

Jedno wywołanie weryfikacji dla EdDSA i ML-DSA

VerifyLoadedSignatureWithOptions bierze indeks podpisu, strumień źródłowy, rekord THPDFCMSVerifyOptions i out THPDFSignatureInfo. Zacznij rekord od THPDFCMSVerifyOptions.Default, które zostawia AllowMLDSA wyłączone, capuje kontener CMS na 256 signerów i ogranicza zagnieżdżenie kontrpodpisów do 8. Trzy pola mają znaczenie dla nowszych algorytmów. SignatureProvider pozwala twojemu własnemu weryfikatorowi odpowiedzieć pierwszemu. OpenSSLLibraryPath nazywa OpenSSL 3 libcrypto, który wykonuje weryfikację Ed25519 i Ed448 w trybie pure. AllowMLDSA włącza algorytmy kratowe, z zastrzeżeniem testu Catalog powyżej. Rekord niesie też pola przypinania polityki podpisu (RequireSignaturePolicy, ExpectedSignaturePolicyOID, ExpectedSignaturePolicyHash), które dotyczą każdej rodziny algorytmów

Wbudowana ścieżka rozdziela się według algorytmu. Ed25519 i Ed448 idą przez OpenSSL 3; gdy ścieżka nie jest podana, provider platformowy próbuje libcrypto-3-x64.dll, libcrypto-3.dll i libcrypto.dll przez normalną kolejność szukania DLL na Win64. ML-DSA idzie przez CNG-owego providera algorytmu ML-DSA z Windows, a na buildzie Windows, który go nie oferuje, wynik brzmi unsupported. Ustaw OpenSSLLibraryPath na kopię, którą wysyłasz obok pliku wykonywalnego. Zostawienie go pustego znaczy, że werdykt zależy od tego, jaki libcrypto cudzy instalator rzucił na ścieżkę szukania, a to trudna rzecz do wyjaśnienia w audycie

Twój własny SignatureProvider jest pytany pierwszy. Jeśli zwróci spsUnsupported, wbudowany provider platformowy dostaje to samo żądanie, więc provider znający wyłącznie ML-DSA może siedzieć przed domyślną obsługą EdDSA, nie reimplementując jej

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);            // przekaż EdDSA providerowi platformowemu
      // MyFips204Verify to twoja własna obudowa wokół biblioteki ML-DSA;
      // Req.Input trzyma DER-owane podpisane atrybuty (tryb pure)
      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;             // wciąż koniunkcja z deklaracją Catalog
    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;                             // rekord opcji go nie posiada
  end;
end;

Loguj Info.SignatureAlgorithmOID, a nie to, jakiego algorytmu oczekiwała twoja konfiguracja. Dla ML-DSA-65 to 2.16.840.1.101.3.4.3.18, a ślad audytowy rejestrujący OID znaleziony w pliku odpowiada później na pytania, na które wartość konfiguracji nie umie. Jeśli w ogóle nie potrzebujesz własnego providera, krótsze przeciążenie VerifyLoadedSignature(SigIndex, Source, Info) buduje opcje domyślne i ustawia AllowMLDSA z deklaracji Catalog za ciebie

Reguły kodowania, na których wywracają się zewnętrznie wyprodukowane ML-DSA i EdDSA CMS

Obie rodziny algorytmów są weryfikowane w trybie pure po kompletnych DER-owanych podpisanych atrybutach, a SignerInfo bez podpisanych atrybutów jest raportowany jako nieobsługiwany. Dla EdDSA parametry AlgorithmIdentifier muszą być nieobecne. Częsty błąd w ręcznie składanym CMS to najpierw zhashowanie podpisanych atrybutów SHA-512 albo SHAKE256 i podpisanie tego digesta, co po cichu zamienia PureEdDSA w wariant prehash, którego nic w CMS nie deklaruje. Dla ML-DSA weryfikator sprawdza też parowanie między zestawem parametrów a digestem wiadomości: ML-DSA-44 przyjmuje SHA-256, SHA-384, SHA-512 albo SHAKE256, ML-DSA-65 potrzebuje SHA-384 lub mocniejszego, a ML-DSA-87 potrzebuje SHA-512 albo SHAKE256. Niespójne parowanie pokazuje się w Info.DigestConsistencyIssue — to pierwsze pole do przeczytania, gdy plik z obcego toolchaina pada

EdDSA ma swoją własną historię rejestracji. ISO/TS 32002 to rozszerzenie rejestrujące Ed25519 i Ed448 dla podpisów PDF 2.0, a EnsureISO32002Extensions dodaje pasującą pozycję ISO_ z ExtensionLevel 32002 do Catalog. Weryfikacja przyjmuje EdDSA bez tej pozycji, ale plik, który produkujesz, powinien ją nieść — z tego samego powodu powtarzalności co deklaracja ML-DSA

O co provider podpisujący jest proszony

Provider podpisujący dostaje jeden THPDFSignatureProviderRequest i zwraca THPDFSignatureProviderStatus plus, przy sukcesie, bajty podpisu. Żądanie niesie algorytm i jego OID, OID algorytmu digesta, długość soli PSS, czy Input to wiadomość (sikMessage) czy wcześniej policzony digest (sikDigest), bajty wejścia, certyfikat signera, identyfikator klucza i identyfikator operacji unikalny dla wywołania. To słownik, którym driver tokena albo usługa podpisująca już mówią, i nic z tego nie wymaga, żeby klucz przekroczył granicę twojego procesu

Każdy provider reklamuje też zestaw zdolności jak spcSign, spcCancel, spcHardwareKey i spcRemoteKey. Kilka implementacji przychodzi z biblioteką:

  • THPDFCallbackSignatureProvider opakowuje metody anonimowe dla weryfikacji, podpisu i anulowania — najkrótsza droga od istniejącej firmowej rutyny podpisującej do podpisu PDF
  • THPDFRemoteSignatureProvider opakowuje funkcję transportową limitem ponowień, rejestrem anulowań i ograniczeniami rozmiaru wejścia i podpisu, więc źle zachowująca się zdalna usługa nie wpychnie przesadnej odpowiedzi do enkodera CMS
  • THPDFPKCS11SignatureProvider pożycza sesję i uchwyt klucza prywatnego, które twój kod już otworzył i na które już się zalogował. HotPDF nigdy nie widzi PIN-u i nie zarządza sesją tokena; serializuje wywołania C_Sign, dokleja DigestInfo dla RSA PKCS#1 v1.5, podaje prawdziwą długość soli do RSA-PSS przez parametry mechanizmu i zamienia surowy wynik ECDSA r||s w formę DER, jakiej CMS oczekuje
  • THPDFWindowsKeyStorageSignatureProvider podpisuje przez Windows CNG key storage, co pokrywa karty smart i klucze oparte o TPM, i może wymagać klucza sprzętowego

Provider PKCS#11 bierze wejście digestowe, więc obsługuje RSA PKCS#1 v1.5, RSA-PSS i ECDSA. Ed25519 i Ed448 podpisują pełną wiadomość podpisanych atrybutów, a te idą przez providera callbackowego albo zdalnego, gdzie twój kod podaje Req.Input tokenowi albo usłudze jako wiadomość

Podłączalne dostawcy podpisów HotPDF wymieniają jeden zwykły rekord żądania i zwracają status o sześciu wartościach, pokazany dla implementacji callback, zdalnej i PKCS#11, podczas gdy klucz prywatny nigdy nie wchodzi do procesu Delphi
Wszystkie trzy dostarczane implementacje mówią tym samym rekordem żądania i odpowiadają tym samym statusem sześciowartościowym, co pozwala sprzętowym tokenom, zdalnym usługom HSM i narodowym kartom eID trzymać swoje klucze zamiast ich eksportować
var
  Provider: THPDFRemoteSignatureProvider;
  Opts: THPDFCMSSignOptions;
  CertDER: TBytes;
  InStream, OutStream: TFileStream;
begin
  CertDER := TFile.ReadAllBytes('signer.cer');   // System.IOUtils; certyfikat DER zdalnego klucza
  Provider := THPDFRemoteSignatureProvider.Create(
    function(const Req: THPDFSignatureProviderRequest; Attempt: Integer;
      out Signature: TBytes): THPDFSignatureProviderStatus
    begin
      // PostToSigningService to twój własny klient HTTP: mapuj timeouty i 5xx na
      // spsProviderError, odmówioną aprobatę na spsCancelled, nieznany klucz na spsUnsupported
      Result := PostToSigningService(Req.KeyIdentifier, Req.AlgorithmOID,
        Req.Input, Signature);
    end,
    3,          // RetryLimit: do czterech prób łącznie
    1048576,    // MaxInputBytes
    65536);     // MaxSignatureBytes
  Opts := HPDFCMSDefaultOptions(palBaseline_B_B);
  Opts.SignatureScheme := cmsRSAPSS;
  Opts.DigestAlgorithm := cmsdaSHA256;
  Opts.PSSSaltLength := 32;
  // contract-unsigned.pdf był zapisany z placeholderem z 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;

Sześć statusów i co każdy powinien wyzwolić

THPDFSignatureProviderStatus ma sześć wartości, bo właściwa reakcja różni się dla każdej. spsInvalid to porażka kryptograficzna i należy do dziennika bezpieczeństwa. spsUnsupported znaczy, że provider nie implementuje algorytmu — to problem wdrożeniowy. spsMalformed znaczy, że żądanie albo odpowiedź miały zły kształt. spsProviderError to usterka transportu albo urządzenia, która może się udać przy ponowie. spsCancelled znaczy, że człowiek odmówił w okienku tokena, a ponawianie go tylko zirytuje

Provider zdalny wbudowuje tę politykę. Ponowi wyłącznie przy spsProviderError, sprawdza rejestr anulowań przed każdą próbą, odrzuca puste albo przesadne wejście jako spsMalformed przed wołaniem transportu i zdejmuje odpowiedź „poprawną” z pustym albo przesadnym podpisem do spsMalformed. Po stronie weryfikacji odpowiedź providera mapuje się na status weryfikacji: spsInvalid staje się svSignatureInvalid, spsMalformed staje się svMalformed, spsProviderError i spsCancelled stają się oba svCryptoProviderError, a spsUnsupported staje się svUnsupportedAlgorithm, gdy i provider platformowy odmówił żądania

Jeśli piszesz providera, oprzyj się mapowaniu każdego nieoczekiwanego stanu na spsInvalid. To zamienia brakujący DLL albo wygasły token usługi w raport, że podpis klienta jest podrobiony, a ktoś będzie musiał wyjaśnić to klientowi. Zwracaj spsInvalid tylko wtedy, gdy matematyka faktycznie padła. Do podpisu zwracaj spsValid wyłącznie razem z niepustym podpisem; oba punkty wejścia podnoszą EHPDFCMSError, gdy provider zwraca sukces bez bajtów

Gdzie podpis providera ląduje w pliku

Dwie funkcje spinają providera z prawdziwymi bajtami PDF. HPDFCMSBuildSignedDataWithProvider buduje odłączony CMS z digesta dokumentu policzonego gdzie indziej; długość digesta musi pasować do Options.DigestAlgorithm, więc konfiguracja SHA-384 oczekuje 48 bajtów i odrzuca 32-bajtowy digest SHA-256 wyjątkiem. HPDFCMSSignPDFStreamWithProvider podpisuje placeholder ułożony przez AddSignedSignatureField, hashuje oba okna /ByteRange, prosi providera o podpis nad podpisanymi atrybutami i wkleja szesnastkowy wynik do /Contents. Jeśli CMS urośnie ponad placeholder, wywołanie podnosi wyjątek i odtwarza niepodpisany placeholder w strumieniu wyjściowym, zamiast coś przycinać

Układ jest ten sam, jaki pisze ścieżka podpisywania z PFX: dwa zakresy omijające szesnastkowe okno podpisu. Weryfikator trzyma każdy plik do tego kształtu — sprawdza, że przerwa między zakresami zawiera dokładnie string /Contents tego podpisu i nic poza tym, a inaczej raportuje svInvalidByteRange. Dokument podpisany przez token PKCS#11 weryfikuje się więc tym samym kodem weryfikacji podpisów co podpisany z pliku PFX. Reguły profilu siedzące nad wyborem algorytmu są pokryte w podpisach bazowych PAdES w Delphi, a pułapki kodowania ECDSA, z którymi radzi sobie konwersja PKCS#11 — w weryfikacji CMS ECDSA i formatach podpisów P1363

Dwa punkty wejścia podpisywania dostawców HotPDF, HPDFCMSBuildSignedDataWithProvider dla zewnętrznie policzonego skrótu i HPDFCMSSignPDFStreamWithProvider dla ułożonego placeholdera, zbiegają się w jednym układzie CMS zachowującym zakresy /ByteRange, które walidatory sprawdzają najpierw
Każdy punkt wejścia zapisuje identyczny podpisany kształt dwóch objętych zakresów wokół okna szesnastkowego, więc dokument podpisany przez dostawcę dociera do każdego walidatora dokładnie jak podpisany z pliku PFX

Wdrażanie podpisów postkwantowych bez zostawiania czytelników na lodzie

Czytnik PDF, który nie implementuje ML-DSA, raportuje taki podpis jako niezweryfikowalny, a osoba patrząca na dokument widzi ostrzeżenie zamiast zielonego ptaszka. To czyni migrację problemem do zaplanowania. Trzymaj RSA albo ECDSA jako podpis, który dzisiejsze walidatory osądzą. Tam, gdzie polityka wymaga dowodu odpornego kwantowo, zadeklaruj rozszerzenie przez EnsureMLDSAExtensions i dodaj obok klasycznego drugi podpis wyprodukowany przez signera potrafiącego ML-DSA. Ten podpis pochodzi z twojego własnego signera FIPS 204, bo budowniczy CMS HotPDF nie tworzy podpisów ML-DSA; częścią biblioteki jest jego weryfikacja. Przesuń podpis podstawowy dopiero wtedy, gdy systemy konsumujące twoje dokumenty potrafią go zweryfikować

W tym planie HotPDF pokrywa oba końce, którymi sterujesz z Delphi. Jego budowniczowie CMS podpisują RSA PKCS#1 v1.5, RSA-PSS, ECDSA, Ed25519 i Ed448 przez dowolnego z providerów powyżej, a jego weryfikator sprawdza ML-DSA, EdDSA i algorytmy klasyczne z jednego wywołania, z OID-em faktycznie znalezionym zapisanym w wyniku. HotPDF to natywny komponent VCL PDF dla Delphi i C++Builder, który nie potrzebuje zewnętrznego runtime'u PDF, więc kod podpisywania i weryfikacji kompiluje się do twojego pliku wykonywalnego. Edycje, zestaw funkcji podpisu i trial do pobrania są na stronie komponentu PDF HotPDF dla Delphi