Teknisk artikel

HotPDF-signering från certifikatarkivet: byteordning CNG vs CAPI

HotPDF signerar en PDF mot ett certifikat som redan ligger i Windows certifikatarkiv genom att lämna över hashsumman till Windows självt, och Windows fullföljer den begäran genom en av två privatnyckel-backends: CNG, som returnerar RSA-signaturen i big-endian, eller den äldre CryptoAPI-CSP:n, som returnerar den i little-endian. Blandas de två ihop blir CMS-signaturen som HotPDF bäddar in byte-omvänd för vilken backend som faktiskt svarade, så en regelrätt validerare rapporterar signaturen som ogiltig trots att dokumentets byte-innehåll aldrig rördes

Två orelaterade problem döljer sig bakom den enda meningen, och HotPDF:s signerare för systemcertifikat måste lösa båda innan den signerar något alls. Byteordningsfelet är tyst: signeringsanropet returnerar fortfarande True, PDF-filen öppnas fortfarande, och felet visar sig först när en läsare går igenom CMS-strukturen och avvisar den. Det andra problemet är högljutt och specifikt för C++Builder: ett halvdussin crypt32-funktioner vägrar att länkas, eftersom importbiblioteket som RAD Studio levererar inte exporterar dem. Inget av problemen existerar om man bara någonsin signerar med en PFX-fil, vilket är varför det ofta drabbar utvecklare som går från PFX-baserad engångssignering till ett certifikat som en IT-avdelning redan har installerat i användarens profil

Att välja ett certifikat från arkivet

HotPDF exponerar den här vägen som HPDFSignPDFStreamWithSystemCertificate och HPDFSignPDFFileWithSystemCertificate, båda styrda av en THPDFCertificateStoreSelector-post: Location (cslCurrentUser eller cslLocalMachine), StoreName ('MY', det personliga arkivet, som standard), en SHA-1 Thumbprint, och en AllowUI-flagga. Fingeravtrycket normaliseras internt, så bindestreck eller mellanslag kopierade direkt från gränssnittet i Certifikathanteraren tas bort innan jämförelsen körs

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 spelar större roll än det verkar, eftersom den mappas direkt till CRYPT_ACQUIRE_SILENT_FLAG, och Windows respekterar det bokstavligt: om det matchade certifikatets privata nyckel ligger på ett smartkort eller en token som kräver en PIN-prompt som Windows inte redan har cachat, misslyckas CryptAcquireCertificatePrivateKey i stället för att visa en dialogruta från vad som kan vara en tjänsteprocess. Det felet är högljutt, ett EHPDFCMSError du ser omedelbart, men det är lätt att feltolka som "certifikat hittades inte" när den verkliga orsaken är en token som väntar på en PIN-kod som ingen kommer att skriva in

Varför är CNG och CAPI oense om byteordning?

Vilken backend som svarar är ingen gissning: CryptAcquireCertificatePrivateKey rapporterar det direkt via en KeySpec-utparameter, och det enda värdet är vad HotPDF:s signerare grenar på. En nyckel från en CNG Key Storage Provider kommer tillbaka med KeySpec satt till sentinelvärdet CERT_NCRYPT_KEY_SPEC ($FFFFFFFF); allt annat är en traditionell CryptoAPI CSP-nyckel. De flesta personliga certifikat utfärdade eller importerade på en aktuell Windows-installation löser sig till CNG även om ett äldre CSP-skal fortfarande finns kvar för kompatibilitet, vilket är varför HotPDF begär CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG tillsammans med CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG innan den tittar på vilket värde som kom tillbaka

De två backendarna anropar inte bara olika funktioner, NCryptSignHash mot en CNG-nyckel, CryptSignHashA mot en CSP-nyckel; de lämnar tillbaka den råa RSA-signaturen i motsatt byteordning. CNG:s utdata matchar redan vad PKCS#1 förväntar sig: en big-endian oktettsträng, mest signifikanta byte först, precis vad RFC 8017:s I2OSP-konvertering producerar och vad en CMS SignerInfo (RFC 5652) behöver i sitt signaturfält enligt ISO 32000-1 §12.8.3. CryptoAPI:s CryptSignHash returnerar däremot signaturen i little-endian, en dokumenterad egenhet som går tillbaka till hur klassiska CSP:er representerade stora tal internt. Hoppar man över omvändningen på CAPI-vägen hamnar varje byte i signaturen på fel plats; RSA-matematiken är fortfarande korrekt, men oktettsträngen en verifierare läser är inte den som PKCS#1 definierar

// 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;

Hur är det med en egen signerings-callback?

Den som kringgår HotPDF:s inbyggda signerare för certifikatarkivet ärver samma byteordningsregel. HPDFCMSSignPDFStreamWithExternalSigner tar en THPDFCMSSignDigestCallback, en closure av typen reference to function(const SignedAttributesSHA256: TBytes): TBytes, för signering via en HSM, en mellanvarustack för smartkort, eller vad som helst annat som inte är ett certifikat som Windows-arkivet kan ge dig ett nyckelhandtag för. Oavsett vilken backend som ligger bakom den callbacken måste bytesen den returnerar hamna i big-endian-ordning innan HotPDF vecklar in dem 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);

Värt att vara tydlig med en gräns här: HotPDF:s två inbyggda signeringsvägar, CNG via NCryptSignHash med PKCS#1-utfyllnad och CAPI via CryptSignHashA, riktar sig båda mot RSA-nycklar som signerar en 32-byte SHA-256-hashsumma. Ingen av dem förhandlar fram ett ECDSA-signaturformat. Ett certifikat vars privata nyckel är EC-baserad behöver en egenskriven signerare mot HPDFCMSSignPDFStreamWithExternalSigner, som kodar ECDSA-signaturen på det sätt CMS förväntar sig i stället för att anta en RSA-bytesträng med fast längd, så förvänta dig inte att den inbyggda signeraren för certifikatarkivet ska göra rätt för en token som utfärdats med ett EC-certifikat

Varför misslyckas C++Builder att länka CertOpenStore?

Därför att RAD Studios standardimportbibliotek för C++Builder, import32.lib, inte exporterar CertOpenStore, eller fem av dess grannar: CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore, och CryptAcquireCertificatePrivateKey. Delphi-byggen ser aldrig detta, eftersom dcc32/dcc64 löser en statisk external 'crypt32.dll'-import direkt in i PE-importtabellen. C++Builder är annorlunda: Delphi-kompilatorn genererar en OMF-.obj-fil för paketbygget, ilink32 länkar den, och vid den punkten är samma external-deklaration bara en olöst symbol som väntar på ett importbibliotek på kommandoraden. Att peka länkaren mot Windows SDK:s psdk-katalog, där det fullständiga crypt32.lib faktiskt exporterar alla sex symbolerna, löser inte problemet heller: ilink32 länkar bara de importbibliotek som faktiskt anges på dess kommandorad, import32.lib cp32mt.lib som standard, och att lägga till en sökväg gör inte att den drar in något extra därifrån. Att köra tdump mot import32.lib bekräftar luckan direkt, noll träffar för CertOpenStore, mot sex rena träffar i SDK:ns crypt32.lib

HotPDF löser detta på samma sätt som den redan hanterar certifikatuppräkning på andra ställen i biblioteket: i stället för att be länkaren om dessa symboler laddar den dem vid körning. En intern THPDFCryptoProcs-post håller ett handtag till crypt32.dll, ett handtag till advapi32.dll, och elva funktionspekarfält; LoadCryptoProcs laddar båda DLL:erna och löser upp varje ingångspunkt med GetProcAddress exakt en gång, i början av HPDFSignPDFStreamWithSystemCertificate, och kastar omedelbart EHPDFCMSError om något saknas i stället för att misslyckas senare med en åtkomstöverträdelse djupt inne i signeringsflödet

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;

Laddningen sker en gång per anrop i stället för lat inuti varje hjälpfunktion, eftersom closuren som väljer mellan CNG och CAPI fångar den laddade funktionstabellen efter värde och måste hållas vid liv genom hela signeringsflödet, inklusive återanropet till HPDFCMSSignPDFStreamWithExternalSigner; båda DLL-handtagen frigörs i det yttersta finally-blocket när signeringen är klar eller kastar ett undantag. Inget av detta rör den publika ytan: HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate, och THPDFCertificateStoreSelector behåller exakt samma signaturer som tidigare, så att ta del av åtgärden innebär en ombyggnad för befintliga anropare, ingen kodändring

Vad täcker detta inte

Att få byteordningen och C++Builder-länkningen rätt ger en CMS SignerInfo som en validerare kan tolka och en signatur den kan kontrollera aritmetiskt; det säger inget om huruvida den valideraren bör lita på certifikatet bakom den, eftersom kedjebygge, återkallelsekontroll och tidsstämpelpolicy är separata angelägenheter som läggs ovanpå via CMS-alternativen, inte något byteordningskorrekthet ger på köpet. Två städdetaljer väger lika tungt som kryptografin: PCCERT_CONTEXT som returneras av certifikatsökningen måste frigöras med CertFreeCertificateContext innan arkivet stängs, och ett förvärvat CNG- eller CSP-nyckelhandtag, när API:et rapporterar att anroparen äger det, måste frigöras via samma backends egna anrop, aldrig det andras. Om svValid-resultatet du får tillbaka efter allt detta visar sig vara snävare än du väntade dig, går artikeln om att verifiera PDF-signaturer igenom exakt vad den flaggan lovar och inte lovar. Eftersom certifikatet stannar kvar i Windows vård hela tiden här kringgår signering från certifikatarkivet en hel attackyta: det finns ingen PKCS#12-fil att tolka och ingen ASN.1 att gå igenom själv, vilket är det problem som HotPDF:s härdning av PKCS#12 och ASN.1 adresserar för PFX-filsigneringsvägen i stället

Signering från certifikatarkivet, PFX-signering, och externa signerings-callbacks är tre dörrar in i samma CMS/PKCS#7-pipeline inuti HotPDF PDF-komponenten för Delphi och C++Builder, och att välja rätt dörr handlar mest om vem som får hålla i den privata nyckeln: din process, en PFX-fil, eller Windows självt