Technisch artikel

HotPDF certificaatopslag PDF-ondertekening: CNG versus CAPI-bytevolgorde

HotPDF ondertekent een PDF tegen een certificaat dat al in de Windows-certificaatopslag staat door de digest aan Windows zelf door te geven, en Windows voltooit dat verzoek via een van twee backends voor private keys: CNG, die de RSA-handtekening big-endian teruggeeft, of de legacy CryptoAPI CSP, die deze little-endian teruggeeft. Verwar de twee en de CMS-handtekening die HotPDF inbedt, is byte-omgekeerd voor welke backend ook daadwerkelijk antwoordde, zodat een conforme validator de handtekening als ongeldig meldt, ook al zijn de documentbytes nooit aangeraakt

Achter die ene zin schuilen twee ongerelateerde problemen, en HotPDF's ondertekenaar voor systeemcertificaten moet beide oplossen voordat er überhaupt iets wordt ondertekend. De bytevolgorde-mismatch is stil: de ondertekeningsaanroep geeft nog steeds True terug, de PDF gaat nog steeds open, en de fout komt pas aan het licht wanneer een viewer de CMS-structuur doorloopt en deze afwijst. Het tweede probleem is luid en specifiek voor C++Builder: een half dozijn crypt32-functies weigert te linken, omdat de importbibliotheek die RAD Studio meelevert deze niet exporteert. Geen van beide problemen doet zich voor als u alleen ooit ondertekent met een PFX-bestand, wat verklaart waarom het vaak ontwikkelaars treft die overstappen van PFX-gebaseerde ondertekening in één aanroep naar een certificaat dat een IT-afdeling al in het profiel van de gebruiker heeft geïnstalleerd

Een certificaat uit de opslag selecteren

HotPDF ontsluit dit pad als HPDFSignPDFStreamWithSystemCertificate en HPDFSignPDFFileWithSystemCertificate, beide aangestuurd door een THPDFCertificateStoreSelector-record: Location (cslCurrentUser of cslLocalMachine), StoreName (standaard 'MY', de persoonlijke opslag), een SHA-1 Thumbprint, en een AllowUI-vlag. De thumbprint wordt intern genormaliseerd, zodat koppeltekens of spaties die rechtstreeks uit de Certificate Manager-UI zijn gekopieerd, vóór de vergelijking worden verwijderd

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 is belangrijker dan het lijkt, omdat het rechtstreeks naar CRYPT_ACQUIRE_SILENT_FLAG wordt gemapt, en Windows houdt zich daar letterlijk aan: als de private key van het gevonden certificaat op een smartcard of token staat dat een pincode-prompt vereist die Windows nog niet heeft gecached, mislukt CryptAcquireCertificatePrivateKey in plaats van een dialoogvenster te tonen vanuit wat mogelijk een serviceproces is. Die fout is luid, een EHPDFCMSError die u onmiddellijk ziet, maar wordt gemakkelijk verkeerd geïnterpreteerd als "certificaat niet gevonden" terwijl de werkelijke oorzaak een token is dat wacht op een pincode die niemand gaat invoeren

Waarom zijn CNG en CAPI het niet eens over de bytevolgorde?

Welke backend antwoordt is geen gok: CryptAcquireCertificatePrivateKey meldt dit rechtstreeks via een KeySpec-uitvoerparameter, en die ene waarde is waarop HotPDF's ondertekenaar vertakt. Een sleutel van een CNG Key Storage Provider komt terug met KeySpec ingesteld op de sentinelwaarde CERT_NCRYPT_KEY_SPEC ($FFFFFFFF); alles anders is een traditionele CryptoAPI CSP-sleutel. De meeste persoonlijke certificaten die op een actuele Windows-installatie zijn uitgegeven of geïmporteerd, worden opgelost naar CNG, ook al bestaat er nog steeds een legacy CSP-shim voor compatibiliteit, wat verklaart waarom HotPDF CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG samen met CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG opvraagt voordat het kijkt welke waarde is teruggekomen

De twee backends roepen niet alleen verschillende functies aan, NCryptSignHash voor een CNG-sleutel, CryptSignHashA voor een CSP-sleutel; ze geven de ruwe RSA-handtekening ook terug in tegenovergestelde bytevolgorde. De uitvoer van CNG komt al overeen met wat PKCS#1 verwacht: een big-endian octet-string, meest significante byte eerst, precies wat de I2OSP-conversie van RFC 8017 produceert en wat een CMS SignerInfo (RFC 5652) nodig heeft in het handtekeningveld onder ISO 32000-1 §12.8.3. CryptoAPI's CryptSignHash daarentegen geeft de handtekening little-endian terug, een gedocumenteerde eigenaardigheid die teruggaat op hoe klassieke CSP's grote getallen intern representeerden. Sla de omkering op het CAPI-pad over en elke byte in de handtekening staat op de verkeerde plaats; de RSA-wiskunde klopt nog steeds, maar de octet-string die een verificateur leest, is niet degene die PKCS#1 definieert

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

Hoe zit het met een aangepaste ondertekenaar-callback?

Iedereen die HotPDF's ingebouwde certificaatopslag-ondertekenaar omzeilt, erft dezelfde bytevolgorde-regel. HPDFCMSSignPDFStreamWithExternalSigner neemt een THPDFCMSSignDigestCallback, een closure van het type reference to function(const SignedAttributesSHA256: TBytes): TBytes, voor ondertekening via een HSM, een smartcard-middlewarestack, of iets anders dat geen certificaat is waarvoor de Windows-opslag u een sleutelhandle kan geven. Welke backend zich ook achter die callback bevindt, de bytes die deze teruggeeft moeten in big-endian volgorde staan voordat HotPDF ze in de CMS-structuur vouwt

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

Het is de moeite waard hier een grens expliciet te maken: HotPDF's twee ingebouwde ondertekeningspaden, CNG via NCryptSignHash met PKCS#1-padding en CAPI via CryptSignHashA, richten zich beide op RSA-sleutels die een 32-byte SHA-256-digest ondertekenen. Geen van beide onderhandelt over een ECDSA-handtekeningformaat. Een certificaat waarvan de private key op EC gebaseerd is, heeft een zelfgeschreven ondertekenaar nodig tegen HPDFCMSSignPDFStreamWithExternalSigner, die de ECDSA-handtekening codeert zoals CMS verwacht in plaats van uit te gaan van een RSA-bytestring met vaste lengte, dus verwacht niet dat de ingebouwde certificaatopslag-ondertekenaar het juiste doet voor een token dat is voorzien van een EC-certificaat

Waarom mislukt het linken van CertOpenStore in C++Builder?

Omdat RAD Studio's standaard C++Builder-importbibliotheek, import32.lib, CertOpenStore niet exporteert, noch vijf van zijn buren: CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore, en CryptAcquireCertificatePrivateKey. Delphi-builds zien dit nooit, omdat dcc32/dcc64 een statische external 'crypt32.dll'-import rechtstreeks naar de PE-importtabel oplossen. C++Builder is anders: de Delphi-compiler genereert een OMF .obj-bestand voor de packagebuild, ilink32 linkt dit, en op dat moment is dezelfde external-declaratie slechts een onopgelost symbool dat wacht op een importbibliotheek op de opdrachtregel. De linker naar de psdk-directory van de Windows SDK wijzen, waar de volledige crypt32.lib alle zes symbolen wel exporteert, lost dit ook niet op: ilink32 linkt alleen de importbibliotheken die daadwerkelijk op de opdrachtregel zijn genoemd, standaard import32.lib cp32mt.lib, en het toevoegen van een zoekpad zorgt er niet voor dat er iets extra's uit dat pad wordt meegenomen. tdump uitvoeren tegen import32.lib bevestigt de leemte rechtstreeks, nul treffers voor CertOpenStore, tegenover zes duidelijke treffers in de crypt32.lib van de SDK

HotPDF lost dit op dezelfde manier op waarop het elders in de bibliotheek al certificaatopsomming afhandelt: in plaats van de linker om deze symbolen te vragen, laadt het ze tijdens runtime. Een intern THPDFCryptoProcs-record draagt een crypt32.dll-handle, een advapi32.dll-handle, en elf functiepointervelden; LoadCryptoProcs laadt beide DLL's en lost elk toegangspunt precies één keer op met GetProcAddress, aan het begin van HPDFSignPDFStreamWithSystemCertificate, waarbij onmiddellijk een EHPDFCMSError wordt opgeworpen als er iets ontbreekt, in plaats van later te falen met een toegangsschending diep in de ondertekeningsflow

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;

Het laden gebeurt eenmaal per aanroep in plaats van lui binnen elke helper, omdat de closure die kiest tussen CNG en CAPI de geladen functietabel bij waarde vastlegt en gedurende de hele ondertekeningsflow in leven moet blijven, inclusief de callback naar HPDFCMSSignPDFStreamWithExternalSigner; beide DLL-handles worden vrijgegeven in het buitenste finally-blok zodra het ondertekenen eindigt of een uitzondering opwerpt. Niets hiervan raakt het publieke oppervlak: HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate, en THPDFCertificateStoreSelector behouden precies dezelfde signaturen als voorheen, dus het oppikken van de fix is voor bestaande aanroepers een rebuild, geen codewijziging

Wat dit niet dekt

De bytevolgorde en de C++Builder-link correct krijgen levert een CMS SignerInfo op die een validator kan parseren en een handtekening die deze rekenkundig kan controleren; het zegt niets over of die validator het certificaat erachter zou moeten vertrouwen, aangezien ketenopbouw, intrekkingscontrole en tijdstempelbeleid afzonderlijke aangelegenheden zijn die via de CMS-opties bovenop worden gelegd, niet iets wat bytevolgorde-correctheid gratis oplevert. Twee huishoudelijke details zijn net zo belangrijk als de cryptografie: de PCCERT_CONTEXT die door de certificaatopzoeking wordt teruggegeven, moet worden vrijgegeven met CertFreeCertificateContext voordat de opslag wordt gesloten, en een verkregen CNG- of CSP-sleutelhandle moet, wanneer de API meldt dat de aanroeper de eigenaar is, worden vrijgegeven via de eigen aanroep van de bijbehorende backend, nooit via die van de andere. Als het svValid-resultaat dat u na dit alles terugkrijgt beperkter blijkt dan verwacht, legt het artikel over het verifiëren van PDF-handtekeningen precies uit wat die vlag wel en niet belooft. Omdat het certificaat hier de hele tijd bij Windows in bewaring blijft, omzeilt ondertekening vanuit de certificaatopslag een heel aanvalsoppervlak: er is geen PKCS#12-bestand te parseren en geen ASN.1 om zelf door te lopen, wat het probleem is dat HotPDF's PKCS#12- en ASN.1-hardening in plaats daarvan aanpakt voor het PFX-bestand-ondertekeningspad

Ondertekening vanuit de certificaatopslag, PFX-ondertekening en externe-ondertekenaar-callbacks zijn drie deuren naar dezelfde CMS/PKCS#7-pijplijn binnen de HotPDF PDF-component voor Delphi en C++Builder, en de juiste kiezen komt vooral neer op wie de private key mag vasthouden: uw proces, een PFX-bestand, of Windows zelf