HotPDF signiert ein PDF gegen ein bereits im Windows-Zertifikatsspeicher liegendes Zertifikat, indem es den Digest an Windows selbst übergibt, und Windows erledigt diese Anfrage über eines von zwei Private-Key-Backends: CNG, das die RSA-Signatur big-endian zurückgibt, oder das ältere CryptoAPI-CSP, das sie little-endian zurückgibt. Werden die beiden verwechselt, ist die von HotPDF eingebettete CMS-Signatur für das jeweils antwortende Backend byteweise vertauscht, sodass ein konformer Validator die Signatur als ungültig meldet, obwohl die Dokumentbytes nie angerührt wurden
Hinter diesem einen Satz verbergen sich zwei voneinander unabhängige Probleme, die HotPDFs Signierer für Systemzertifikate beide lösen muss, bevor überhaupt irgendetwas signiert wird. Der Byte-Reihenfolge-Fehler ist unauffällig: Der Signieraufruf liefert weiterhin True zurück, das PDF öffnet sich weiterhin, und der Fehler zeigt sich erst, wenn ein Viewer die CMS-Struktur durchläuft und sie zurückweist. Das zweite Problem ist laut und spezifisch für C++Builder: ein halbes Dutzend crypt32-Funktionen verweigern das Linken, weil die von RAD Studio mitgelieferte Import-Bibliothek sie nicht exportiert. Keines der beiden Probleme tritt auf, solange nur mit einer PFX-Datei signiert wird, weshalb es typischerweise Entwickler trifft, die vom PFX-basierten Ein-Aufruf-Signieren auf ein Zertifikat umsteigen, das die IT-Abteilung bereits im Benutzerprofil installiert hat
Ein Zertifikat aus dem Speicher auswählen
HotPDF stellt diesen Weg über HPDFSignPDFStreamWithSystemCertificate und HPDFSignPDFFileWithSystemCertificate bereit, beide gesteuert von einem THPDFCertificateStoreSelector-Record: Location (cslCurrentUser oder cslLocalMachine), StoreName (standardmäßig 'MY', der persönliche Speicher), ein SHA-1-Thumbprint und ein AllowUI-Flag. Der Thumbprint wird intern normalisiert, sodass Bindestriche oder Leerzeichen, die direkt aus der Zertifikatverwaltungs-UI kopiert wurden, vor dem Vergleich entfernt werden
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 ist wichtiger, als es aussieht, denn es wird direkt auf CRYPT_ACQUIRE_SILENT_FLAG abgebildet, und Windows nimmt das wörtlich: Liegt der private Schlüssel des gefundenen Zertifikats auf einer Smartcard oder einem Token, das eine PIN-Abfrage benötigt, die Windows noch nicht zwischengespeichert hat, schlägt CryptAcquireCertificatePrivateKey fehl, statt einen Dialog aus einem möglicherweise laufenden Dienstprozess heraus einzublenden. Dieser Fehler ist laut, ein sofort sichtbarer EHPDFCMSError, wird aber leicht als "Zertifikat nicht gefunden" fehlinterpretiert, obwohl die eigentliche Ursache ein Token ist, das auf eine PIN wartet, die niemand eingeben wird
Warum sind sich CNG und CAPI bei der Byte-Reihenfolge uneinig?
Welches Backend antwortet, ist kein Ratespiel: CryptAcquireCertificatePrivateKey meldet es direkt über einen KeySpec-Out-Parameter, und genau dieser eine Wert ist es, nach dem HotPDFs Signierer verzweigt. Ein Schlüssel eines CNG Key Storage Providers kommt mit dem Sentinel-Wert CERT_NCRYPT_KEY_SPEC ($FFFFFFFF) für KeySpec zurück; alles andere ist ein klassischer CryptoAPI-CSP-Schlüssel. Die meisten persönlichen Zertifikate, die auf einer aktuellen Windows-Installation ausgestellt oder importiert werden, lösen sich zu CNG auf, obwohl aus Kompatibilitätsgründen weiterhin ein Legacy-CSP-Shim existiert – weshalb HotPDF CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG zusammen mit CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG anfordert, bevor es überhaupt prüft, welcher Wert zurückkam
Die beiden Backends rufen nicht nur unterschiedliche Funktionen auf – NCryptSignHash gegen einen CNG-Schlüssel, CryptSignHashA gegen einen CSP-Schlüssel –, sie liefern die rohe RSA-Signatur auch in entgegengesetzter Byte-Reihenfolge zurück. Die Ausgabe von CNG entspricht bereits dem, was PKCS#1 erwartet: ein big-endian Oktett-String, höchstwertiges Byte zuerst, genau das, was die I2OSP-Konvertierung nach RFC 8017 erzeugt und was ein CMS-SignerInfo (RFC 5652) in seinem Signaturfeld gemäß ISO 32000-1 §12.8.3 benötigt. CryptoAPIs CryptSignHash hingegen gibt die Signatur little-endian zurück, eine dokumentierte Eigenheit, die darauf zurückgeht, wie klassische CSPs große Zahlen intern darstellten. Wird die Umkehrung auf dem CAPI-Pfad übersprungen, sitzt jedes Byte der Signatur am falschen Platz; die RSA-Mathematik bleibt korrekt, aber der Oktett-String, den ein Prüfer liest, ist nicht der, den PKCS#1 definiert
// 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;
Und was ist mit einem benutzerdefinierten Signer-Callback?
Wer HotPDFs eingebauten Zertifikatsspeicher-Signierer umgeht, übernimmt dieselbe Regel zur Byte-Reihenfolge. HPDFCMSSignPDFStreamWithExternalSigner nimmt einen THPDFCMSSignDigestCallback entgegen, eine Closure vom Typ reference to function(const SignedAttributesSHA256: TBytes): TBytes, zum Signieren über ein HSM, einen Smartcard-Middleware-Stack oder alles andere, das kein Zertifikat ist, für das der Windows-Speicher einen Schlüssel-Handle liefern kann. Unabhängig davon, welches Backend hinter diesem Callback steckt, müssen die zurückgegebenen Bytes in big-endian-Reihenfolge vorliegen, bevor HotPDF sie in die CMS-Struktur einfügt
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);
Eine Grenze sollte hier klar benannt werden: HotPDFs beide eingebaute Signierpfade – CNG über NCryptSignHash mit PKCS#1-Padding und CAPI über CryptSignHashA – zielen beide auf RSA-Schlüssel ab, die einen 32-Byte-SHA-256-Digest signieren. Keiner handelt ein ECDSA-Signaturformat aus. Ein Zertifikat, dessen privater Schlüssel EC-basiert ist, benötigt einen selbst geschriebenen Signierer über HPDFCMSSignPDFStreamWithExternalSigner, der die ECDSA-Signatur so kodiert, wie CMS es erwartet, statt einen RSA-Byte-String fester Länge anzunehmen – erwarten Sie also nicht, dass der eingebaute Zertifikatsspeicher-Signierer bei einem mit einem EC-Zertifikat versehenen Token das Richtige tut
Warum scheitert C++Builder beim Linken von CertOpenStore?
Weil RAD Studios standardmäßige C++Builder-Import-Bibliothek, import32.lib, weder CertOpenStore noch fünf ihrer Nachbarn exportiert: CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore und CryptAcquireCertificatePrivateKey. Delphi-Builds sehen dies nie, weil dcc32/dcc64 einen statischen external 'crypt32.dll'-Import direkt in die PE-Import-Tabelle auflösen. C++Builder ist anders: Der Delphi-Compiler erzeugt für den Package-Build ein OMF-.obj, ilink32 linkt es, und an dieser Stelle ist dieselbe external-Deklaration nur noch ein ungelöstes Symbol, das auf eine Import-Bibliothek in der Kommandozeile wartet. Den Linker auf das psdk-Verzeichnis des Windows SDK zu richten, wo die vollständige crypt32.lib alle sechs Symbole tatsächlich exportiert, behebt das Problem ebenfalls nicht: ilink32 linkt nur die auf seiner Kommandozeile tatsächlich benannten Import-Bibliotheken, standardmäßig import32.lib cp32mt.lib, und das Hinzufügen eines Suchpfads sorgt nicht dafür, dass zusätzlich etwas aus diesem Pfad gezogen wird. Ein Lauf von tdump gegen import32.lib bestätigt die Lücke direkt: null Treffer für CertOpenStore, gegenüber sechs sauberen Treffern in der SDK-crypt32.lib
HotPDF löst dies auf dieselbe Weise, wie es an anderer Stelle in der Bibliothek bereits die Zertifikat-Aufzählung handhabt: statt den Linker nach diesen Symbolen zu fragen, lädt es sie zur Laufzeit. Ein internes THPDFCryptoProcs-Record trägt einen crypt32.dll-Handle, einen advapi32.dll-Handle und elf Funktionszeiger-Felder; LoadCryptoProcs lädt beide DLLs und löst jeden Einstiegspunkt genau einmal mit GetProcAddress auf, am Anfang von HPDFSignPDFStreamWithSystemCertificate, und wirft sofort EHPDFCMSError, falls etwas fehlt, statt später tief im Signierablauf mit einer Zugriffsverletzung zu scheitern
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;
Das Laden erfolgt einmal pro Aufruf statt lazy innerhalb jedes Hilfsprogramms, weil die Closure, die zwischen CNG und CAPI wählt, die geladene Funktionstabelle per Wert erfasst und für den gesamten Signierablauf am Leben bleiben muss, einschließlich des Callbacks in HPDFCMSSignPDFStreamWithExternalSigner; beide DLL-Handles werden im äußersten finally-Block freigegeben, sobald das Signieren abgeschlossen ist oder eine Exception auslöst. Nichts davon berührt die öffentliche Schnittstelle: HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate und THPDFCertificateStoreSelector behalten genau die Signaturen, die sie zuvor hatten, sodass das Übernehmen der Korrektur für bestehende Aufrufer nur ein Neu-Build ist, keine Codeänderung
Was hierdurch nicht abgedeckt wird
Die Byte-Reihenfolge und das C++Builder-Linken korrekt hinzubekommen liefert einen CMS-SignerInfo, den ein Validator parsen und dessen Signatur er arithmetisch prüfen kann; es sagt nichts darüber aus, ob dieser Validator dem dahinterstehenden Zertifikat vertrauen sollte, denn Chain Building, Sperrprüfung und Zeitstempel-Policy sind separate Belange, die über die CMS-Optionen obendrauf geschichtet werden, nicht etwas, das korrekte Byte-Reihenfolge automatisch mitliefert. Zwei Housekeeping-Details sind ebenso wichtig wie die Kryptografie: Der von der Zertifikatssuche zurückgegebene PCCERT_CONTEXT muss mit CertFreeCertificateContext freigegeben werden, bevor der Speicher schließt, und ein erworbener CNG- oder CSP-Schlüssel-Handle muss, wenn die API meldet, dass der Aufrufer Eigentümer ist, über den passenden Aufruf des jeweiligen Backends freigegeben werden, niemals über den des anderen. Falls sich das svValid-Ergebnis, das Sie nach all dem zurückbekommen, als enger erweist als erwartet, legt der Artikel zur Prüfung von PDF-Signaturen genau dar, was dieses Flag verspricht und was nicht. Weil das Zertifikat hier die ganze Zeit in der Obhut von Windows bleibt, umgeht das Signieren aus dem Zertifikatsspeicher eine ganze Angriffsfläche: Es gibt keine PKCS#12-Datei zu parsen und kein ASN.1, das man selbst durchlaufen müsste – das Problem, das HotPDFs PKCS#12- und ASN.1-Härtung stattdessen für den PFX-Datei-Signierpfad adressiert
Signieren aus dem Zertifikatsspeicher, PFX-Signieren und External-Signer-Callbacks sind drei Türen in dieselbe CMS/PKCS#7-Pipeline innerhalb der HotPDF PDF-Komponente für Delphi und C++Builder, und die richtige Wahl hängt vor allem davon ab, wer den privaten Schlüssel halten darf: der eigene Prozess, eine PFX-Datei oder Windows selbst