Der Kunde schickt eine Datei mit der Endung .pfx, das Passwort kommt in einer zweiten Mail. Darin steckt alles, was die Anfrage an sein Portal braucht, nur kann PHP mit dieser Datei erst einmal nichts anfangen. Sie ist binär, sie ist verschlüsselt, und sie trägt drei verschiedene Dinge auf einmal.
Aus der Hülle fallen drei Teile heraus, und genau dafür gibt es PHP openssl_pkcs12_read(). Dieses Tutorial zerlegt den Container. Was danach mit dem Zertifikat und dem Schlüssel geschieht, steht in den Tutorials zum Auslesen von Zertifikaten und zum Entschlüsseln.
Was in einem p12-Container steckt
PKCS#12 ist eine Verpackung und kein Zertifikatsformat. In der Hülle liegen ein Zertifikat, der dazu passende private Schlüssel und meistens noch die Zwischenzertifikate der ausstellenden Stelle, also die Zertifikatskette. Ein Passwort verschlüsselt das Ganze, damit der private Schlüssel den Weg per Mail oder USB-Stick unbeschadet hinter sich bringt.
Die Endungen .p12 und .pfx meinen dasselbe Format. Das pfx stammt aus der Microsoft-Welt, und daher kommen auch die meisten dieser Dateien: Wer im Windows-Zertifikatsspeicher ein Zertifikat samt privatem Schlüssel exportiert, bekommt genau so einen Container. PHP openssl_pkcs12_read() schaut nicht auf die Endung, sondern auf den Inhalt, und liest deshalb beide.
Erzeugt wird an dieser Stelle nichts. Ein frisches Schlüsselpaar entsteht mit openssl_pkey_new() für RSA- und EC-Schlüssel, hier wird ein vorhandener Schlüssel nur aus seiner Verpackung geholt.
Syntax: die Signatur im Original
Zwei Angaben in der englischen Beschreibung von PHP openssl_pkcs12_read() entscheiden darüber, ob der erste Versuch gelingt:
openssl_pkcs12_read(
string $pkcs12,
array &$certificates,
string $passphrase
): bool
/* pkcs12 - the contents of the certificate
store, not its file name
certificates - filled with the parsed data on
success, untouched on failure
passphrase - the password used to unlock the
certificate store
return value - true on success, false on failure */
Der erste Parameter ist der Inhalt der Datei und nicht ihr Pfad. Dieser Irrtum kostet zuverlässig eine halbe Stunde, weil der Fehler danach beim Passwort gesucht wird. Das Ergebnis landet im zweiten Parameter, der per Referenz gefüllt wird, und PHP openssl_pkcs12_read() selbst gibt nur true oder false zurück.
Mit PHP openssl_pkcs12_read() zu drei PEM-Blöcken
Der Ablauf hat genau eine Verzweigung, und die hängt am Passwort.
flowchart TD
A[p12-Datei einlesen] --> B{Passwort ok?}
B -- nein --> F[false, Fehler auslesen]
B -- ja --> C[cert: Zertifikat]
B -- ja --> D[pkey: privater Schluessel]
B -- ja --> E[extracerts: Kette]
Binär einlesen, ein Array bereitstellen, Passwort mitgeben, Rückgabewert prüfen. Viel mehr ist es nicht, und der letzte Schritt ist der, den alle vergessen.
<?php
$roh = file_get_contents('/srv/zert/kunde.p12');
$teile = [];
$pass = getenv('P12_PASS') ?: '';
$ok = openssl_pkcs12_read($roh, $teile, $pass);
if ($ok === false) {
exit('Container nicht lesbar');
}
print_r(array_keys($teile));
/* Array ( [0] => cert
[1] => pkey
[2] => extracerts ) */
Gelesen wird die Datei mit file_get_contents(), und zwar unverändert. Jede Verarbeitung, die den Inhalt als Text behandelt und dabei Zeilenenden umschreibt, macht aus dem Container Datensalat. Im Array stehen nach einem erfolgreichen Aufruf drei Schlüssel:
| Schlüssel | Inhalt | Beginnt mit |
cert | das Zertifikat als PEM-Block | -----BEGIN CERTIFICATE----- |
pkey | der private Schlüssel | -----BEGIN PRIVATE KEY----- |
extracerts | ein Array mit den Zwischenzertifikaten | fehlt ganz, wenn der Container keine Kette trägt |
Die ersten beiden sind immer vorhanden, sobald PHP openssl_pkcs12_read() true gemeldet hat. extracerts ist der Wackelkandidat und will mit isset() geprüft werden. Bei false bleibt das übergebene Array unverändert, ein anschließender Zugriff auf $teile['cert'] greift also ins Leere.
Das Passwort: leer ist nicht dasselbe wie keines
Für PHP openssl_pkcs12_read() ist ein leerer String ein gesetztes, aber leeres Passwort. Ein Container ganz ohne Passwortschutz ist etwas anderes, und beide Varianten laufen einem im Alltag über den Weg. Scheitert der erste Versuch, ist der zweite deshalb kein Ratespiel: erst das Passwort aus der Mail, dann der leere String, und danach die Frage, ob die Datei vollständig angekommen ist.
Im Quelltext hat das Passwort nichts verloren. Eine Umgebungsvariable oder eine Konfigurationsdatei außerhalb des Web-Verzeichnisses ist allemal besser als eine Konstante, die irgendwann im Repository landet. Der Container kann denselben Weg nehmen, dann liegt er nirgends auf der Platte herum:
<?php
$roh = base64_decode(getenv('CLIENT_P12'), true);
if ($roh === false) {
exit('P12 aus der Umgebung ist unbrauchbar');
}
$teile = [];
$pass = getenv('P12_PASS') ?: '';
$ok = openssl_pkcs12_read($roh, $teile, $pass);
Praxis: Client-Zertifikat für eine cURL-Anfrage
Eine Gegenstelle, die ein Client-Zertifikat verlangt, führt geradewegs hierher. cURL will dafür Dateipfade sehen: CURLOPT_SSLCERT und CURLOPT_SSLKEY nehmen keinen PEM-Block als String entgegen. Die beiden Blöcke müssen also in Dateien, und zwar in solche, die hinterher wieder verschwinden.
<?php
$certDatei = tempnam(sys_get_temp_dir(), 'crt');
$keyDatei = tempnam(sys_get_temp_dir(), 'key');
chmod($certDatei, 0600);
chmod($keyDatei, 0600);
file_put_contents($certDatei, $teile['cert']);
file_put_contents($keyDatei, $teile['pkey']);
$ch = curl_init('https://portal.example.com/api');
curl_setopt($ch, CURLOPT_SSLCERT, $certDatei);
curl_setopt($ch, CURLOPT_SSLKEY, $keyDatei);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
try {
$antwort = curl_exec($ch);
} finally {
curl_close($ch);
unlink($certDatei);
unlink($keyDatei);
}
Drei Kleinigkeiten daran sind keine. Die temporären Dateien bekommen Rechte 0600, bevor irgendein Inhalt hineingeht. Gelöscht wird im finally-Zweig, damit auch eine abgebrochene Anfrage nichts liegen lässt. Und ein privater Schlüssel, der nach dem Lauf noch unter /tmp liegt, ist ein Befund im Sicherheitsaudit und keine Lässlichkeit.
Verlangt die Gegenstelle die vollständige Kette, wandert der dritte Eintrag aus PHP openssl_pkcs12_read() hinter das Zertifikat in dieselbe Datei. CURLOPT_SSLCERT nimmt mehrere PEM-Blöcke hintereinander entgegen und schickt sie zusammen. CURLOPT_CAINFO wäre hier die falsche Option, denn damit prüft cURL das Zertifikat der Gegenstelle und schickt selbst gar nichts. Der folgende Block tritt an die Stelle des einfachen file_put_contents() von oben und läuft damit vor curl_exec():
<?php
$pem = $teile['cert'];
if (isset($teile['extracerts'])) {
$kette = implode(PHP_EOL, $teile['extracerts']);
$pem .= PHP_EOL . $kette;
}
file_put_contents($certDatei, $pem);
Ablaufdatum und Inhaber aus dem Zertifikat lesen
Was PHP openssl_pkcs12_read() unter cert ablegt, ist genau der PEM-Block, mit dem openssl_x509_parse() anfängt. Das Ergebnis ist ein Array mit Aussteller, Inhaber und Gültigkeitszeitraum. validTo_time_t darin ist eine Unix-Zeit und damit sofort rechenbar.
<?php
$info = openssl_x509_parse($teile['cert']);
$ablauf = $info['validTo_time_t'];
$tage = (int) floor(($ablauf - time()) / 86400);
printf(
'%s bis %s, noch %d Tage',
$info['subject']['CN'],
date('d.m.Y', $ablauf),
$tage
);
Damit steht eine Warnung, die dreißig Tage vor Ablauf anspringt, statt am Montagmorgen im Protokoll der Gegenstelle. Welche Felder das Array sonst noch trägt, steht im Tutorial zum Auslesen von TLS-Zertifikaten mit openssl_x509_parse(). Den Block aus pkey nimmt umgekehrt jede Funktion entgegen, die einen privaten Schlüssel braucht, etwa beim Entschlüsseln von RSA-Daten.
Alte Container unter OpenSSL 3 und der Legacy-Provider
Ein Container, der vor einigen Jahren erzeugt wurde, ist oft mit RC2 oder 40-Bit-RC4 verschlüsselt. Seit OpenSSL 3 gelten diese Verfahren als abgekündigt und stecken im Legacy-Provider, der standardmäßig nicht geladen wird. PHP openssl_pkcs12_read() meldet dann false, obwohl das Passwort stimmt und die Datei in Ordnung ist.
Das hängt an der OpenSSL-Version des Servers und nicht an der PHP-Version. Derselbe Code kann auf zwei Maschinen mit gleicher PHP-Version verschieden ausgehen, und genau das macht die Suche so zäh. Welche Version im Spiel ist, sagt PHP selbst:
<?php
echo OPENSSL_VERSION_TEXT, PHP_EOL;
/* OpenSSL 3.5.4 30 Sep 2025 */
Steht dort eine 3 am Anfang, ist der Legacy-Provider die erste Spur. Ihn aktiviert ein Eintrag in der openssl.cnf:
openssl_conf = openssl_init
[openssl_init]
providers = provider_sect
[provider_sect]
default = default_sect
legacy = legacy_sect
[default_sect]
activate = 1
[legacy_sect]
activate = 1
Der zweite Ausweg erzeugt den Container einmalig neu, mit Verfahren von heute:
openssl pkcs12 -legacy -in alt.p12 -out tmp.pem
openssl pkcs12 -export -in tmp.pem -out neu.p12
rm tmp.pem
Der zweite Weg ist der bessere, weil er das Problem beseitigt, statt es zu umgehen. Der Legacy-Provider hält nur einen Container am Leben, dessen Verschlüsselung niemand mehr ernst nimmt. Die Zwischendatei trägt den Schlüssel im Klartext, deshalb steht das rm in derselben Sitzung und nicht auf der Liste für später.
Auf einem Server mit Shell ist dieser Kommandozeilenweg auch sonst brauchbar. Auf Shared Hosting gibt es keine Shell, und dort hat der Weg über PHP openssl_pkcs12_read() den zusätzlichen Vorteil, dass niemals ein entschlüsselter Schlüssel auf der Platte liegt.
Wenn PHP openssl_pkcs12_read() false zurückgibt
PHP selbst schweigt in diesem Fall. Es gibt keine Warnung, keine Zeile im Fehlerprotokoll, nur ein false. Der Grund steht in der Fehlerwarteschlange von OpenSSL, und die muss abgeholt werden:
<?php
$ok = openssl_pkcs12_read($roh, $teile, $pass);
if ($ok === false) {
while ($fehler = openssl_error_string()) {
error_log('p12: ' . $fehler);
}
}
OpenSSL stapelt mehrere Meldungen, und erst die vollständig geleerte Warteschlange ergibt ein brauchbares Bild. Vier Ursachen decken die allermeisten Fälle ab:
| Ursache | Woran sie zu erkennen ist | Abhilfe |
| falsches Passwort | mac verify failure in der Warteschlange | Passwort und leeren String nacheinander probieren |
| Dateiname statt Inhalt | asn1 encoding routines::not enough data | file_get_contents() davorsetzen |
| altes Verschlüsselungsverfahren | Container ist alt, Server läuft auf OpenSSL 3 | Container neu erzeugen, notfalls Legacy-Provider |
| beschädigte Datei | Dateigröße weicht vom Original ab | binär neu übertragen, nicht per Copy-Paste |
Fazit
PHP openssl_pkcs12_read() macht aus einer undurchsichtigen Binärdatei drei Textblöcke, mit denen der Rest von PHP etwas anfangen kann. Der erste Parameter ist der Dateiinhalt, das Ergebnis kommt per Referenz, und der Rückgabewert ist die einzige Stelle, an der ein Fehlschlag sichtbar wird.
extracerts ist optional und will geprüft sein, bevor jemand darauf zugreift. Und ein false ist keine Sackgasse: Eine Schleife über openssl_error_string() nennt den Grund, und in den meisten Fällen heißt der entweder falsches Passwort oder Container älter als der Server.