Teknisk artikkel

HotPDF-signering av PDF fra sertifikatlageret: CNG vs. CAPI byte-rekkefølge

HotPDF signerer en PDF mot et sertifikat som allerede ligger i Windows-sertifikatlageret, ved å overlevere hash-summen (digest) til Windows selv, og Windows fullfører forespørselen gjennom en av to private nøkkel-backender: CNG, som returnerer RSA-signaturen big-endian, eller den eldre CryptoAPI CSP, som returnerer den little-endian. Blander man de to, blir CMS-signaturen som HotPDF bygger inn byte-reversert for den backenden som faktisk svarte, slik at en konform validator rapporterer signaturen som ugyldig selv om selve dokumentbytene aldri ble rørt

To urelaterte problemer skjuler seg bak den ene setningen, og HotPDFs signerer for systemsertifikater må løse begge før den i det hele tatt signerer noe. Byte-rekkefølge-forvirringen er stille: signeringskallet returnerer fortsatt True, PDF-en åpnes fortsatt, og feilen dukker først opp når en fremviser går gjennom CMS-strukturen og avviser den. Det andre problemet er høylytt og spesifikt for C++Builder: et halvt dusin crypt32-funksjoner nekter å lenkes, fordi importbiblioteket RAD Studio leverer med, ikke eksporterer dem. Ingen av problemene finnes hvis man kun noensinne signerer med en PFX-fil, noe som er grunnen til at dette gjerne fanger utviklere som går fra PFX-basert ett-kalls-signering til et sertifikat en IT-avdeling allerede har installert i brukerens profil

Å velge et sertifikat fra lageret

HotPDF eksponerer denne veien som HPDFSignPDFStreamWithSystemCertificate og HPDFSignPDFFileWithSystemCertificate, begge styrt av en THPDFCertificateStoreSelector-record: Location (cslCurrentUser eller cslLocalMachine), StoreName ('MY', det personlige lageret, som standard), et SHA-1-Thumbprint, og et AllowUI-flagg. Thumbprinten normaliseres internt, slik at bindestreker eller mellomrom kopiert rett ut av Sertifikatbehandling-grensesnittet fjernes før sammenligningen kjøres

HotPDF åpner det personlige sertifikatlageret fra en THPDFCertificateStoreSelector-post, og KeySpec ut-parameteren forgrener signering mellom NCryptSignHash og CryptSignHashA
Velgeren åpner det personlige lageret, matcher thumbprinten, og lar KeySpec-utparameteren forgrene mellom CNG-signerer og den eldre CryptoAPI-signerereren
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 betyr mer enn det ser ut til, fordi det kartlegges direkte til CRYPT_ACQUIRE_SILENT_FLAG, og Windows følger det bokstavelig: hvis privatnøkkelen til det matchede sertifikatet ligger på et smartkort eller en token som krever en PIN-forespørsel Windows ikke allerede har lagret i hurtigbuffer, feiler CryptAcquireCertificatePrivateKey i stedet for å sprette opp en dialogboks fra det som kanskje er en tjenesteprosess. Den feilen er høylytt, en EHPDFCMSError man ser umiddelbart, men den er lett å mistolke som «sertifikat ikke funnet» når den egentlige årsaken er en token som venter på en PIN ingen kommer til å taste inn

Hvorfor er CNG og CAPI uenige om byte-rekkefølgen?

Hvilken backend som svarer, er ikke gjetning: CryptAcquireCertificatePrivateKey rapporterer det direkte gjennom en KeySpec-ut-parameter, og det er nettopp denne verdien HotPDFs signerer forgrener seg på. En CNG Key Storage Provider-nøkkel kommer tilbake med KeySpec satt til sentinelverdien CERT_NCRYPT_KEY_SPEC ($FFFFFFFF); alt annet er en tradisjonell CryptoAPI CSP-nøkkel. De fleste personlige sertifikater utstedt eller importert på en gjeldende Windows-installasjon løses til CNG, selv om en eldre CSP-shim fortsatt finnes for kompatibilitet, og det er derfor HotPDF ber om CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG sammen med CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG før den ser på hvilken verdi som kom tilbake

De to backendene kaller ikke bare forskjellige funksjoner, NCryptSignHash mot en CNG-nøkkel, CryptSignHashA mot en CSP-nøkkel; de returnerer også den rå RSA-signaturen i motsatt byte-rekkefølge. CNGs utdata matcher allerede det PKCS#1 forventer: en big-endian oktettstreng, mest signifikante byte først, akkurat det RFC 8017s I2OSP-konvertering produserer, og det en CMS SignerInfo (RFC 5652) trenger i sitt signaturfelt under ISO 32000-1 §12.8.3. CryptoAPIs CryptSignHash returnerer derimot signaturen little-endian, en dokumentert særegenhet som stammer fra hvordan klassiske CSP-er representerte store tall internt. Hopper man over reverseringen på CAPI-veien, havner hver byte i signaturen på feil plass; RSA-matematikken er fortsatt korrekt, men oktettstrengen en verifikator leser, er ikke den PKCS#1 definerer

HotPDF: NCryptSignHash returnerer RSA-signaturen big-endian i tråd med RFC 8017, mens CryptSignHashA returnerer den little-endian og trenger en byte-bytte på stedet før den går inn i CMS SignerInfo
NCryptSignHash gir allerede tilbake PKCS#1 big-endian-oktetter, mens CryptSignHashA returnerer dem snudd, så CAPI-bufferen trenger én in-place-reversering før den lander i CMS SignerInfo
// CryptSignHashA returnerer RSA-signaturen med minst signifikante byte først
// CMS/PKCS#7 (ISO 32000-1 Section 12.8.3) trenger mest signifikante byte først
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;

Hva med en egendefinert signerings-callback?

Alle som omgår HotPDFs innebygde sertifikatlager-signerer, arver den samme byte-rekkefølge-regelen. HPDFCMSSignPDFStreamWithExternalSigner tar en THPDFCMSSignDigestCallback, en closure av typen reference to function(const SignedAttributesSHA256: TBytes): TBytes, for signering via en HSM, en smartkort-mellomvarestakk, eller hva som helst annet som ikke er et sertifikat Windows-lageret kan gi deg et nøkkel-handle for. Uansett hvilken backend som ligger bak den callbacken, må bytene den returnerer, havne i big-endian-rekkefølge før HotPDF folder dem inn 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 verdt å være eksplisitt om en avgrensning her: HotPDFs to innebygde signeringsveier, CNG via NCryptSignHash med PKCS#1-utfylling og CAPI via CryptSignHashA, retter seg begge mot RSA-nøkler som signerer en 32-byte SHA-256-hash. Ingen av dem forhandler frem et ECDSA-signaturformat. Et sertifikat hvis private nøkkel er EC-basert, trenger en signerer man skriver selv mot HPDFCMSSignPDFStreamWithExternalSigner, som koder ECDSA-signaturen slik CMS forventer det, i stedet for å anta en RSA-bytestreng med fast lengde, så ikke forvent at den innebygde sertifikatlager-signereren gjør det riktige for en token utstyrt med et EC-sertifikat

Hvorfor klarer ikke C++Builder å lenke CertOpenStore?

Fordi RAD Studios standard C++Builder-importbibliotek, import32.lib, ikke eksporterer CertOpenStore, eller fem av naboene dens: CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore, og CryptAcquireCertificatePrivateKey. Delphi-bygg ser aldri dette, fordi dcc32/dcc64 løser en statisk external 'crypt32.dll'-import direkte inn i PE-importtabellen. C++Builder er annerledes: Delphi-kompilatoren genererer en OMF-.obj-fil for pakkebygget, ilink32 lenker den, og på det tidspunktet er den samme external-deklarasjonen bare et uløst symbol som venter på et importbibliotek på kommandolinjen. Å peke lenkeren mot Windows SDK-ets psdk-katalog, der det fulle crypt32.lib faktisk eksporterer alle seks symbolene, løser heller ikke problemet: ilink32 lenker kun de importbibliotekene som faktisk er navngitt på kommandolinjen, import32.lib cp32mt.lib som standard, og å legge til en søkesti gjør ikke at den trekker inn noe ekstra derfra. Å kjøre tdump mot import32.lib bekrefter gapet direkte, null treff for CertOpenStore, mot seks rene treff i SDK-ets crypt32.lib

HotPDF: ilink32 rapporterer uoppløste eksterne fordi import32.lib ikke eksporterer seks crypt32-funksjoner, løst ved å laste crypt32.dll ved kjøretid med LoadLibrary og GetProcAddress inn i THPDFCryptoProcs
import32.lib eksporterer ingen av de seks crypt32-inngangspunktene C++Builder trenger, så signereren binder dem ved kjøretid gjennom LoadLibrary og GetProcAddress i stedet for å navngi et importbibliotek

HotPDF løser dette på samme måte som den allerede håndterer sertifikatoppramsing andre steder i biblioteket: i stedet for å be lenkeren om disse symbolene, laster den dem inn under kjøring. En intern THPDFCryptoProcs-record bærer et crypt32.dll-handle, et advapi32.dll-handle, og elleve funksjonspeker-felt; LoadCryptoProcs laster begge DLL-ene og løser hvert entry point med GetProcAddress nøyaktig én gang, ved starten av HPDFSignPDFStreamWithSystemCertificate, og kaster EHPDFCMSError umiddelbart hvis noe mangler, i stedet for å feile senere med en access violation dypt inne i signeringsflyten

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');
  // ... bruk CertOpenStore, og kjør deretter FreeLibrary(Crypt32Handle) når signering returnerer
end;

Innlastingen skjer én gang per kall, i stedet for lat innlasting inne i hver hjelpefunksjon, fordi closuren som velger mellom CNG og CAPI, fanger den innlastede funksjonstabellen etter verdi og må holde seg i live gjennom hele signeringsflyten, inkludert callbacken inn i HPDFCMSSignPDFStreamWithExternalSigner; begge DLL-handlene frigjøres i den ytterste finally-blokken når signeringen enten fullføres eller kaster et unntak. Ingenting av dette rører den offentlige overflaten: HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate, og THPDFCertificateStoreSelector beholder de nøyaktig samme signaturene de hadde før, så å ta i bruk fiksen er en ombygging for eksisterende kallere, ikke en kodeendring

Hva dette ikke dekker

Å få byte-rekkefølgen og C++Builder-lenkingen riktig, gir en CMS SignerInfo en validator kan parse og en signatur den kan sjekke aritmetisk; det sier ingenting om hvorvidt validatoren bør stole på sertifikatet bak den, ettersom kjedebygging, tilbakekallingssjekk og tidsstempelpolicy er separate anliggender som legges på toppen gjennom CMS-opsjonene, ikke noe byte-rekkefølge-korrekthet gir gratis. To ryddedetaljer betyr like mye som selve kryptografien: PCCERT_CONTEXT-en som returneres av sertifikatoppslaget, må frigjøres med CertFreeCertificateContext før lageret lukkes, og et anskaffet CNG- eller CSP-nøkkelhandle, når API-et rapporterer at den kallende koden eier det, må frigjøres gjennom den tilhørende backendens eget kall, aldri den andres. Hvis svValid-resultatet man får tilbake etter alt dette, viser seg å være snevrere enn forventet, forklarer artikkelen om å verifisere PDF-signaturer nøyaktig hva det flagget lover og ikke lover. Fordi sertifikatet forblir i Windows' varetekt hele tiden her, unngår sertifikatlager-signering en hel angrepsflate: det finnes ingen PKCS#12-fil å parse og ingen ASN.1 å gå gjennom selv, noe HotPDFs PKCS#12- og ASN.1-herding derimot tar for seg for PFX-fil-signeringsveien

Sertifikatlager-signering, PFX-signering og eksterne signerings-callbacker er tre dører inn til den samme CMS/PKCS#7-pipelinen inne i HotPDF Delphi PDF-komponenten for Delphi og C++Builder, og å velge riktig dør handler for det meste om hvem som får lov til å inneha den private nøkkelen: din egen prosess, en PFX-fil, eller Windows selv