Technischer Artikel

Selbstsignierte Testzertifikate in Delphi mit CryptoAPI

PDFlibPas' Funktion PLCreateSelfSignedCertificate baut ein selbstsigniertes RSA/SHA-256-Zertifikat und exportiert es, privaten Schlüssel eingeschlossen, direkt in eine passwortgeschützte PFX-Datei, unter Verwendung ausschließlich der Win32-CryptoAPI, die auf jeder Windows-Maschine bereits installiert ist. Kein externes Werkzeug, keine Zertifizierungsstelle, kein manueller makecert- oder OpenSSL-Schritt: ein Funktionsaufruf, ein Zertifikat, gut genug, um einen Signier-Test anzutreiben

Das Szenario, das diese Funktion lohnenswert macht, ist fast immer eine CI-Pipeline. Ein Signier-Smoke-Test braucht eine echte PFX mit einem echten privaten Schlüssel dahinter, und eine ins Repository einzuchecken ist ihr eigenes Sicherheitsproblem, da ein committeter privater Schlüssel ein geleakter privater Schlüssel ist, ab dem Moment, in dem dieser Commit landet. Ein Ausführen von makecert.exe oder eines OpenSSL-Aufrufs aus einem Build-Skript funktioniert auch, aber dann hängt die Pipeline von einem Werkzeug ab, das installiert, im PATH gefunden und über jeden Build-Agenten hinweg versionskonsistent gehalten werden muss. Das Zertifikat innerhalb desselben Prozesses zu erzeugen, der den Test ausführt, mit denselben Win32-CryptoAPI-Aufrufen, die Windows bereits mitliefert, entfernt diese Abhängigkeit vollständig

Was erzeugt PLCreateSelfSignedCertificate tatsächlich?

PLCreateSelfSignedCertificate erzeugt eine passwortgeschützte PFX-Datei, die ein selbstsigniertes RSA-Zertifikat und dessen privaten Schlüssel hält, signiert mit sha256RSA, angetrieben von fünf Parametern: SubjectName, PFXFileName, PFXPassword, ValidDays und KeyBits, und sie gibt ein schlichtes boolesches Erfolgs-Flag zurück. SubjectName akzeptiert einen vollständigen X.500-String wie 'CN=Alice, O=Example', und ein bloßer Name ohne =-Zeichen darin bekommt automatisch CN= vorangestellt. ValidDays unter 1 fällt auf 365 zurück, und KeyBits außerhalb des Bereichs 1024 bis 16384 fällt auf 2048 zurück. PDFlibPas liefert diese Funktion seit v3.224.0 aus, erreichbar nicht nur von der Delphi-Unit, sondern auch über die DLL- und ActiveX-Oberflächen, und ihr eigener Doc-Kommentar ist unverblümt darüber, wo sie aufhört, nützlich zu sein: Jeder gängige Viewer markiert ein selbstsigniertes Zertifikat als nicht vertrauenswürdig, sofern nicht jemand es ausdrücklich installiert, also behandeln Sie, was sie erzeugt, als Zertifikat zum Durchspielen eines Codepfads, nicht als Signatur, der irgendjemand außerhalb Ihres Teams vertrauen sollte

var
  Success: Boolean;
begin
  Success := PLCreateSelfSignedCertificate(
    'CN=PDFlibPas CI Test, O=Example Corp',
    'ci-test-signer.pfx',
    'a-strong-throwaway-password',
    365,     // ValidDays
    2048);   // KeyBits
  if not Success then
    raise Exception.Create('Self-signed certificate generation failed');
end;

Warum kodiert CryptGenKey die Schlüssellänge im Flags-Parameter?

CryptGenKey packt zwei unverwandte Einstellungen in einen einzigen dwFlags-Parameter. Das niedrige Wort trägt Verhaltens-Flags, CRYPT_EXPORTABLE darunter, während das hohe Wort, für einen RSA-Schlüsselaustausch-Schlüssel, die angeforderte Schlüssellänge in Bits trägt. 2048 zu übergeben, als wäre es nur ein weiteres Flag, landet im niedrigen Wort statt, wo es zu keinem von CryptoAPI definierten Verhaltens-Flag passt, sodass der Aufruf einen Schlüssel mit welcher Standardlänge auch immer der Provider zurückfällt erzeugt, statt der Länge, um die der Aufrufer dachte gebeten zu haben. Einen tatsächlichen 2048-Bit-RSA-Schlüssel zu bekommen bedeutet, die Zahl zuerst in das hohe Wort zu verschieben

// Key length lives in the upper 16 bits of the CryptGenKey flags;
// the low word carries behavior flags such as CRYPT_EXPORTABLE.
if not CryptGenKey(hProv, AT_KEYEXCHANGE,
    (Cardinal(KeyBits) shl 16) or CRYPT_EXPORTABLE, hKey) then
  Exit;

Was passiert, wenn Sie CRYPT_EXPORTABLE vergessen?

Lassen Sie CRYPT_EXPORTABLE aus demselben Flags-Wert weg, gelingt CryptGenKey trotzdem, aber es markiert den erzeugten privaten Schlüssel auf CSP-Ebene als nicht exportierbar. Alles stromabwärts meldet ebenfalls weiterhin Erfolg: CertCreateSelfSignCertificate gibt einen gültigen Zertifikatskontext zurück, und PFXExportCertStoreEx, selbst mit EXPORT_PRIVATE_KEYS aufgerufen, gelingt trotzdem und schreibt eine PFX-Datei, die sich öffnet, parst und vollkommen gewöhnlich aussieht. Was sie nicht enthält, ist der private Schlüssel, weil der CSP sich weigerte, ihn aus dem Schlüsselcontainer herauszulassen, und PFXExportCertStoreEx diese Weigerung nie als Grund behandelt, den gesamten Export scheitern zu lassen

Der Fehlschlag zeigt sich erst später, und ganz woanders: Ein Signier-Aufruf öffnet diese PFX, findet ein Zertifikat ohne angehängten privaten Schlüssel, und meldet exakt den Fehler, den man von einer beschädigten oder falschen PFX bekäme, nicht von einem fehlenden Flag drei Ebenen stromaufwärts. Wer nur von der Signierseite aus debuggt, kann einen ganzen Nachmittag mit der falschen Datei verbrennen, bevor er erkennt, dass der tatsächliche Bug ein einziges fehlendes Bit zur Schlüsselerzeugungszeit ist, in einem völlig anderen Funktionsaufruf, möglicherweise in einem völlig anderen Build-Skript

Warum muss ProvType zwischen CryptAcquireContextW und dem Zertifikat übereinstimmen?

ProvType muss übereinstimmen, weil CertCreateSelfSignCertificate den privaten Schlüssel des neuen Zertifikats über einen CRYPT_KEY_PROV_INFO-Datensatz auflöst, und ein Feld in diesem Datensatz, ProvType, genau denselben CSP-Typ-Wert benennen muss, der an CryptAcquireContextW übergeben wurde, als der Schlüsselcontainer geöffnet wurde, PROV_RSA_AES, numerisch 24, in PDFlibPas' Implementierung. Setzt man ProvType auf null, oder auf irgendeine andere Provider-Konstante als die, zu der der Container tatsächlich gehört, kann das Zertifikat trotzdem erzeugt werden, aber sein protokollierter Link zurück zum privaten Schlüssel löst sich nicht mehr auf den Container auf, der ihn hält, was sich später als Signier- oder Exportfehler zeigt, der nichts mit dem tatsächlichen kryptografischen Inhalt des Zertifikats zu tun hat

// The provider type used to open the key container must match the
// provider type recorded in the certificate's key-provider info.
CryptAcquireContextW(hProv, PWideChar(Container), nil,
  PROV_RSA_AES, CRYPT_NEWKEYSET);
// ... generate the key, build the subject name blob, then:
KeyProvInfo.ProvType := PROV_RSA_AES;   // same constant, both call sites

Zusammensetzen: von GUID-Container zu passwortgeschützter PFX

Die Aufrufkette innerhalb von PLCreateSelfSignedCertificate folgt einer geraden Linie: einen frischen Schlüsselcontainer öffnen, benannt nach einer neu erzeugten GUID, damit gleichzeitige CI-Läufe nie über Container-Namen kollidieren, das RSA-Schlüsselpaar darin erzeugen mit den beiden oben behandelten Flags, SubjectName über CertStrToNameW in ein X.500-Namens-Blob kodieren, und CertCreateSelfSignCertificate mit einem aus ValidDays berechneten Gültigkeitsfenster aufrufen, übergeben als schlichte SYSTEMTIME-förmige Struktur. Der entstehende Zertifikatskontext geht in einen In-Memory-Zertifikatsspeicher, geöffnet mit CertOpenStore und CERT_STORE_PROV_MEMORY, rein damit PFXExportCertStoreEx einen Speicher zum Exportieren hat, da diese API gegen ein Speicher-Handle arbeitet statt gegen einen bloßen Zertifikatskontext

// Each call opens a throwaway container named after a fresh GUID:
CryptAcquireContextW(hProv, PWideChar(Container), nil,
  PROV_RSA_AES, CRYPT_NEWKEYSET);
// ... generate the key, self-sign the certificate, export the PFX ...
// then delete the container once the PFX holds its own copy of the key:
CryptAcquireContextW(hProv, PWideChar(Container), nil,
  PROV_RSA_AES, CRYPT_DELETEKEYSET);

PFXExportCertStoreEx selbst folgt der gewöhnlichen Win32-Zwei-Durchgänge-Konvention: einmal mit einem Puffer der Länge null aufrufen, um zu erfahren, wie viele Bytes die PFX braucht, so viel allozieren, dann erneut aufrufen, um den Puffer zu füllen. Sobald die Bytes auf der Festplatte sind, löscht PDFlibPas den Wegwerf-Schlüsselcontainer mit CRYPT_DELETEKEYSET, statt ihn zurückzulassen, weil die PFX bereits ihre eigene Kopie jedes Bytes des Schlüsselmaterials trägt, das der Container hielt. Diese Bereinigung auszulassen und jeder Aufruf von PLCreateSelfSignedCertificate lässt einen verwaisten, GUID-benannten Schlüsselcontainer im Profil des aufrufenden Benutzers zurück, genau die Art von Leck, das sich bei einem CI-Agenten, der diese Funktion bei jedem Build ausführt, über Monate ansammelt, bevor es jemand bemerkt

Ist ein selbstsigniertes Zertifikat sicher für Produktions-Signierung?

Nein: Ein selbstsigniertes Zertifikat ist sicher zum Durchspielen eines Signier-Codepfads und unsicher für eine Signatur, der irgendjemand außerhalb des Teams vertrauen soll, weil nichts es zurück zu einer Root verkettet, der die Software eines Vertrauenden bereits vertraut. Der natürliche nächste Schritt für eine solche PFX ist ein tatsächlicher Signier-Aufruf, behandelt in dem Aufbau einer Konformitäts- und Signier-Werkbank in Delphi mit PDFlibPas, wo eine so gebaute PFX die Signier-Hälfte einer Pipeline antreibt, die auch PDF/A-Preflight und ByteRange-Audits ausführt. Signieren ist jedoch nur die halbe Miete rund um ein Zertifikat, und die andere Hälfte ist genau dort, wo ein selbstsignierter Leaf scheitern soll: PAdES-Signieren und -Validierung in Delphi mit PDFlibPas behandelt die Vertrauensketten-Prüfungen, die ein Konformitätsvalidator ausführt, und ein Validator, der die Kette zurück zu einer vertrauenswürdigen Root durchläuft, hat keinen Grund, einem Zertifikat zu vertrauen, das diese Funktion vor fünf Minuten aus dem Nichts erfunden hat

PLCreateSelfSignedCertificate ist eine Funktion unter den Zertifikats- und Signier-APIs in der PDFlibPas-PDF-Bibliothek für Delphi und C++Builder, und sie existiert genau für die hier beschriebene Lücke: ein Signier-Test, der einen echten Schlüsselpaar dahinter braucht und nichts Externes, um eines zu erzeugen