HotPDF signerer en PDF med et certifikat, der allerede ligger i Windows' certifikatlager, ved at overlade digestet til selve Windows, og Windows fuldfører den anmodning gennem en af to backends til private nøgler: CNG, som returnerer RSA-signaturen big-endian, eller den ældre CryptoAPI CSP, som returnerer den little-endian. Forveksles de to, bliver den CMS-signatur, HotPDF indlejrer, byte-vendt for uanset hvilken backend der faktisk svarede, så en korrekt validator rapporterer signaturen som ugyldig, selvom dokumentets bytes aldrig blev rørt
To urelaterede problemer gemmer sig bag den ene sætning, og HotPDFs signerer til systemcertifikater skal løse begge, før den overhovedet signerer noget. Byte-rækkefølgefejlen er tavs: signeringskaldet returnerer stadig True, PDF'en åbner stadig, og fejlen viser sig kun, når en fremviser gennemgår CMS-strukturen og afviser den. Det andet problem er højlydt og specifikt for C++Builder: et halvt dusin crypt32-funktioner nægter at linke, fordi den importbibliotek RAD Studio leverer, ikke eksporterer dem. Ingen af problemerne opstår, hvis man udelukkende signerer med en PFX-fil, hvilket er grunden til, at det typisk rammer udviklere, der bevæger sig fra PFX-baseret signering med ét kald til et certifikat, en it-afdeling allerede har installeret i brugerens profil
Valg af certifikat fra lageret
HotPDF eksponerer denne vej som HPDFSignPDFStreamWithSystemCertificate og HPDFSignPDFFileWithSystemCertificate, begge styret af en THPDFCertificateStoreSelector-record: Location (cslCurrentUser eller cslLocalMachine), StoreName ('MY', det personlige lager, som standard), et SHA-1 Thumbprint og et AllowUI-flag. Thumbprint normaliseres internt, så bindestreger eller mellemrum kopieret direkte fra Certificate Manager-brugerfladen fjernes, før sammenligningen kører
var
Selector: THPDFCertificateStoreSelector;
Options: THPDFCMSSignOptions;
begin
Selector := THPDFCertificateStoreSelector.Default; // cslCurrentUser, store 'MY'
Selector.Thumbprint := 'A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0';
Selector.AllowUI := False;
Options := HPDFCMSDefaultOptions(palBaseline_B_B);
if not HPDFSignPDFFileWithSystemCertificate('invoice.pdf',
'invoice-signed.pdf', Selector, Options) then
raise Exception.Create('Certificate-store signing failed');
end;
AllowUI = False betyder mere, end det ser ud til, fordi det mapper direkte til CRYPT_ACQUIRE_SILENT_FLAG, og Windows respekterer det bogstaveligt: hvis det fundne certifikats private nøgle ligger på et smartcard eller token, der kræver en PIN-prompt, Windows ikke allerede har cachet, fejler CryptAcquireCertificatePrivateKey i stedet for at popup en dialog fra det, der måske er en serviceproces. Den fejl er højlydt, en EHPDFCMSError, man ser med det samme, men den er let at fejllæse som "certifikat ikke fundet", når den reelle årsag er et token, der venter på en PIN, ingen kommer til at taste
Hvorfor er CNG og CAPI uenige om byte-rækkefølge?
Hvilken backend der svarer, er ikke et gæt: CryptAcquireCertificatePrivateKey rapporterer det direkte via en KeySpec-udparameter, og netop den ene værdi er, hvad HotPDFs signerer forgrener sig på. En nøgle fra en CNG Key Storage Provider kommer tilbage med KeySpec sat til sentinel-værdien CERT_NCRYPT_KEY_SPEC ($FFFFFFFF); alt andet er en traditionel CryptoAPI CSP-nøgle. De fleste personlige certifikater udstedt eller importeret på en nutidig Windows-installation ender som CNG, selvom en ældre CSP-shim stadig findes af hensyn til kompatibilitet, hvilket er grunden til, at HotPDF anmoder om CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG sammen med CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG, før den kigger på, hvilken værdi der kom tilbage
De to backends kalder ikke blot forskellige funktioner, NCryptSignHash mod en CNG-nøgle, CryptSignHashA mod en CSP-nøgle; de returnerer den rå RSA-signatur i modsat byte-rækkefølge. CNG's output matcher allerede, hvad PKCS#1 forventer: en big-endian oktetstreng, mest betydende byte først, præcis hvad RFC 8017's I2OSP-konvertering producerer, og hvad en CMS SignerInfo (RFC 5652) kræver i sit signaturfelt under ISO 32000-1 §12.8.3. CryptoAPI's CryptSignHash returnerer derimod signaturen little-endian, en dokumenteret særhed, der stammer fra, hvordan klassiske CSP'er internt repræsenterede store tal. Springer man byte-vendingen over på CAPI-vejen, ender hver byte i signaturen på forkert plads; RSA-matematikken er stadig korrekt, men oktetstrengen en verifikator læser, er ikke den, PKCS#1 definerer
// CryptSignHashA returns the RSA signature least-significant byte first;
// CMS/PKCS#7 (ISO 32000-1 Section 12.8.3) needs it most-significant byte first.
for I := 0 to (Length(Signature) div 2) - 1 do
begin
Temp := Signature[I];
Signature[I] := Signature[High(Signature) - I];
Signature[High(Signature) - I] := Temp;
end;
Hvad med et brugerdefineret signerer-callback?
Alle, der springer HotPDFs indbyggede certifikatlager-signerer over, arver den samme byte-rækkefølgeregel. HPDFCMSSignPDFStreamWithExternalSigner tager en THPDFCMSSignDigestCallback, en closure af typen reference to function(const SignedAttributesSHA256: TBytes): TBytes, til signering via en HSM, en smartcard-middleware-stak eller alt andet, der ikke er et certifikat, Windows-lageret kan give dig et nøglehandle til. Uanset hvilken backend der ligger bag det callback, skal de bytes, den returnerer, ligge i big-endian rækkefølge, før HotPDF folder dem ind i CMS-strukturen
Signer :=
function(const SignedAttributesSHA256: TBytes): TBytes
begin
if UsesCngKeyStorageProvider then
Result := SignWithMyCngKey(SignedAttributesSHA256) // already big-endian
else
Result := ReverseBytes(SignWithMyLegacyToken(SignedAttributesSHA256));
end;
HPDFCMSSignPDFStreamWithExternalSigner(InputStream, OutputStream,
CertificateDER, Signer, Options);
Det er værd at være tydelig om en afgrænsning her: HotPDFs to indbyggede signeringsveje, CNG via NCryptSignHash med PKCS#1-padding og CAPI via CryptSignHashA, målretter begge RSA-nøgler, der signerer et 32-byte SHA-256-digest. Ingen af dem forhandler et ECDSA-signaturformat. Et certifikat, hvis private nøgle er EC-baseret, kræver en signerer, man selv skriver mod HPDFCMSSignPDFStreamWithExternalSigner, og koder ECDSA-signaturen, som CMS forventer, i stedet for at antage en RSA-byte-streng med fast længde, så forvent ikke, at den indbyggede certifikatlager-signerer gør det rigtige for et token udstedt med et EC-certifikat
Hvorfor fejler C++Builder med at linke CertOpenStore?
Fordi RAD Studios standard C++Builder-importbibliotek, import32.lib, ikke eksporterer CertOpenStore eller fem af dens naboer: CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore og CryptAcquireCertificatePrivateKey. Delphi-builds ser aldrig dette, fordi dcc32/dcc64 løser en statisk external 'crypt32.dll'-import direkte ind i PE-importtabellen. C++Builder er anderledes: Delphi-kompileren udsender en OMF-.obj-fil til pakkebuilden, ilink32 linker den, og på det tidspunkt er den samme external-erklæring blot et uløst symbol, der venter på et importbibliotek på kommandolinjen. At pege linkeren mod Windows SDK'ets psdk-mappe, hvor det fulde crypt32.lib faktisk eksporterer alle seks symboler, løser det heller ikke: ilink32 linker kun de importbiblioteker, der faktisk er nævnt på dens kommandolinje, import32.lib cp32mt.lib som standard, og det at tilføje en søgesti trækker ikke noget ekstra ind fra den sti. At køre tdump mod import32.lib bekræfter hullet direkte, nul hits for CertOpenStore, mod seks rene hits i SDK'ets crypt32.lib
HotPDF løser dette på samme måde, som den allerede håndterer certifikatoptælling andre steder i biblioteket: i stedet for at bede linkeren om disse symboler, indlæser den dem ved runtime. En intern THPDFCryptoProcs-record bærer et crypt32.dll-handle, et advapi32.dll-handle og elleve funktionspointer-felter; LoadCryptoProcs indlæser begge DLL'er og finder hvert entrypunkt med GetProcAddress nøjagtig én gang, ved starten af HPDFSignPDFStreamWithSystemCertificate, og udløser straks EHPDFCMSError, hvis noget mangler, i stedet for at fejle senere med en access violation dybt inde i signeringsflowet
type
TCertOpenStoreFn = function(lpszStoreProvider: Pointer; dwEncodingType: DWORD;
hCryptProv: NativeUInt; dwFlags: DWORD; pvPara: Pointer): HCERTSTORE; stdcall;
var
Crypt32Handle: HMODULE;
CertOpenStore: TCertOpenStoreFn;
begin
Crypt32Handle := LoadLibrary('crypt32.dll');
if Crypt32Handle = 0 then
raise Exception.Create('crypt32.dll could not be loaded');
@CertOpenStore := GetProcAddress(Crypt32Handle, 'CertOpenStore');
// ... use CertOpenStore, then FreeLibrary(Crypt32Handle) when signing returns
end;
Indlæsningen sker én gang pr. kald i stedet for dovent inde i hver hjælpefunktion, fordi den closure, der vælger mellem CNG og CAPI, indfanger den indlæste funktionstabel som værdi og skal forblive i live gennem hele signeringsflowet, inklusive kaldet ind i HPDFCMSSignPDFStreamWithExternalSigner; begge DLL-handles frigives i den yderste finally-blok, når signeringen afsluttes eller kaster en undtagelse. Intet af dette rører den offentlige overflade: HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate og THPDFCertificateStoreSelector beholder præcis de signaturer, de havde før, så at hente rettelsen er en rebuild for eksisterende kaldere, ikke en kodeændring
Hvad dette ikke dækker
At få byte-rækkefølgen og C++Builder-linket rigtigt giver en CMS SignerInfo, en validator kan parse, og en signatur, den kan kontrollere aritmetisk; det siger intet om, hvorvidt den validator bør stole på certifikatet bag den, da kædeopbygning, spærringskontrol og tidsstempelpolitik er separate hensyn lagt oven på gennem CMS-indstillingerne, ikke noget byte-rækkefølgekorrekthed giver gratis. To praktiske detaljer er lige så vigtige som kryptografien: den PCCERT_CONTEXT, der returneres af certifikatopslaget, skal frigives med CertFreeCertificateContext, før lageret lukkes, og et erhvervet CNG- eller CSP-nøglehandle skal, når API'et rapporterer, at kalderen ejer det, frigives gennem den matchende backends egen kald, aldrig den andens. Hvis det svValid-resultat, man får tilbage efter alt dette, viser sig snævrere end forventet, gennemgår artiklen om verificering af PDF-signaturer nøjagtigt, hvad det flag lover, og hvad det ikke gør. Fordi certifikatet hele tiden forbliver i Windows' varetægt her, undgår certifikatlager-signering en hel angrebsflade: der er ingen PKCS#12-fil at parse og ingen ASN.1 at gennemgå selv, hvilket er det problem HotPDFs PKCS#12- og ASN.1-hærdning løser for PFX-fil-signeringsvejen i stedet
Certifikatlager-signering, PFX-signering og eksterne signerer-callbacks er tre døre ind til den samme CMS/PKCS#7-pipeline i HotPDF PDF-komponenten til Delphi og C++Builder, og valget af den rigtige handler mest om, hvem der får lov at holde den private nøgle: din proces, en PFX-fil, eller Windows selv