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

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

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

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 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');
  // ... use CertOpenStore, then FreeLibrary(Crypt32Handle) when signing returns
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 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