Sechzig Domains, und das Ablaufdatum der Zertifikate steht nirgends zentral. Bisher erfährt man vom Ablauf, wenn der Browser der Kunden warnt. Ein kleines Skript kann diese Information selbst holen, täglich prüfen und rechtzeitig Bescheid geben.
Im Bild wird aus dem geschlossenen Zertifikat eine Liste einzelner Felder. Genau das ist die Aufgabe der Funktion.
Was PHP openssl_x509_parse() macht
PHP openssl_x509_parse() zerlegt ein X.509-Zertifikat in ein verschachteltes Array. Als Eingabe akzeptiert die Funktion einen PEM-String, einen Dateiverweis in der Form file://pfad oder ein OpenSSLCertificate-Objekt. Zurück kommt entweder das Array oder false.
<?php
$pem = file_get_contents('/etc/ssl/certs/beispiel.pem');
$info = openssl_x509_parse($pem);
if ($info === false) {
exit('Kein gueltiges Zertifikat');
}
echo $info['subject']['CN']; // beispiel.de
echo $info['issuer']['O']; // Let's Encrypt
echo date('d.m.Y', $info['validTo_time_t']);
Ein Zertifikat ist übrigens kein Schlüssel, sondern eine Bescheinigung: Es sagt aus, wem ein öffentlicher Schlüssel gehört und wie lange das gilt. Wie die Schlüssel selbst entstehen, zeigt das Tutorial zu PHP openssl_pkey_new() und dem Erzeugen von Schlüsselpaaren.
Die Signatur im Original
Zum Parsen eines Zertifikats genügen zwei Parameter, von denen der zweite die Form der Schlüsselnamen bestimmt:
openssl_x509_parse(OpenSSLCertificate|string $certificate,
bool $short_names = true): array|false
/* certificate - an x509 certificate object or a PEM encoded string
short_names - use short names like CN instead of commonName
return - an array with the fields of the x509 certificate,
or false on failure
The function only parses the certificate. It does not verify the
signature and does not check the validity dates.
Available since PHP 4.0.6, unchanged in its behaviour since then.
See the manual for the full list of returned fields. */
Der letzte Satz benennt die Grenze der Funktion: Das Parsen sagt nichts darüber aus, ob ein Zertifikat gültig ist.
Das Zertifikat einer Domain holen
Meist liegt gar keine PEM-Datei vor, die man an PHP openssl_x509_parse() übergeben könnte, sondern nur ein Domainname. Der Weg dorthin führt über einen Stream-Kontext mit der Option capture_peer_cert. PHP baut die TLS-Verbindung auf und legt das Zertifikat der Gegenstelle in den Kontextparametern ab.
<?php
function zertifikatHolen(string $domain, int $port = 443): array|false
{
$kontext = stream_context_create([
'ssl' => [
'capture_peer_cert' => true,
'verify_peer' => false, /* auch abgelaufene lesen */
'verify_peer_name' => false,
],
]);
$client = @stream_socket_client(
'ssl://' . $domain . ':' . $port,
$fehlerNr,
$fehlerText,
10, /* Zeitlimit in Sekunden */
STREAM_CLIENT_CONNECT,
$kontext
);
if ($client === false) {
return false;
}
$params = stream_context_get_params($client);
fclose($client);
return openssl_x509_parse($params['options']['ssl']['peer_certificate']);
}
$info = zertifikatHolen('beispiel.de');
Die beiden Schalter verify_peer und verify_peer_name stehen hier bewusst auf false. Ein Überwachungsskript soll gerade auch dann noch Daten liefern, wenn das Zertifikat bereits abgelaufen oder auf den falschen Namen ausgestellt ist. Für normale Verbindungen bleiben beide Schalter selbstverständlich aktiv.
Was im Rückgabearray steht
Das Array, das PHP openssl_x509_parse() liefert, ist umfangreich. Für den Alltag reichen wenige Felder, die folgende Übersicht nennt sie in der Reihenfolge ihrer praktischen Bedeutung.
| Feld | Bedeutung | Beispielwert |
subject | Array mit den Angaben zum Inhaber, darin CN | beispiel.de |
issuer | Array mit den Angaben zum Aussteller | R3 |
validFrom_time_t | Beginn der Gültigkeit als Zeitstempel | 1786000000 |
validTo_time_t | Ende der Gültigkeit als Zeitstempel | 1793776000 |
extensions | Erweiterungen, darin subjectAltName | DNS:beispiel.de |
serialNumberHex | Seriennummer in Hexadezimalform | 04A3F1... |
signatureTypeSN | Kurzname des Signaturverfahrens | RSA-SHA256 |
Der zweite Parameter der Funktion steuert die Schreibweise der Schlüssel. In der Vorgabe true heißen sie CN, O und OU, mit false erscheinen die Langformen commonName und organizationName. Wer die falschen Schlüssel sucht, findet nichts und hält das Zertifikat für unvollständig.
Die beiden Datumsformate
Für jeden Zeitpunkt liefert PHP openssl_x509_parse() zwei Felder. validTo enthält die ursprüngliche ASN.1-Zeichenkette, validTo_time_t denselben Zeitpunkt als Unix-Zeitstempel. Für jede Rechnung wird die zweite Variante genommen.
<?php
echo $info['validTo']; // 260815120000Z
echo $info['validTo_time_t']; // 1786881600
/* falsch, liefert false oder Unsinn */
var_dump(strtotime($info['validTo']));
/* richtig */
$ende = (new DateTimeImmutable('@' . $info['validTo_time_t']))
->setTimezone(new DateTimeZone('Europe/Berlin'));
echo $ende->format('d.m.Y H:i');
Die Zeichenkette sieht wie ein Datum aus und ist keines im Sinne von PHP. Sie folgt dem ASN.1-Format mit zweistelligem Jahr und angehängtem Z für UTC. Der Zeitstempel bezieht sich ebenfalls auf UTC, deshalb gehört bei der Ausgabe die gewünschte Zeitzone gesetzt.
Für welche Namen gilt das Zertifikat?
Bei modernen Zertifikaten ist der CN im subject nur noch Beiwerk. Maßgeblich ist die Liste in extensions['subjectAltName'], und die kommt als eine einzige kommaseparierte Zeichenkette. Wer nur den CN prüft, übersieht bei Wildcard- und Mehrfachzertifikaten die Hälfte.
<?php
function hostnamen(array $info): array
{
$roh = $info['extensions']['subjectAltName'] ?? '';
$namen = [];
foreach (explode(',', $roh) as $eintrag) {
$eintrag = trim($eintrag);
if (str_starts_with($eintrag, 'DNS:')) {
$namen[] = substr($eintrag, 4);
}
}
return $namen;
}
print_r(hostnamen($info));
/* Array ( [0] => beispiel.de [1] => www.beispiel.de [2] => *.api.beispiel.de ) */
Für die Prüfung eines konkreten Hostnamens genügt danach ein Vergleich, der auch Platzhalter berücksichtigt. Ein Eintrag mit führendem Sternchen deckt genau eine Ebene ab, *.beispiel.de gilt also für shop.beispiel.de, nicht für a.b.beispiel.de.
Ein Skript zur Ablaufwarnung
Damit sind alle Teile zusammen, die PHP openssl_x509_parse() für den Betrieb brauchbar machen. Das folgende Skript läuft als täglicher Cronjob über eine Liste von Domains und meldet alles, was in weniger als 14 Tagen abläuft.
<?php
const SCHWELLE_TAGE = 14;
$domains = ['beispiel.de', 'shop.beispiel.de', 'api.beispiel.de'];
$warnungen = [];
foreach ($domains as $domain) {
$info = zertifikatHolen($domain);
if ($info === false) {
$warnungen[] = "$domain: Zertifikat nicht abrufbar";
continue;
}
$resttage = intdiv($info['validTo_time_t'] - time(), 86400);
if ($resttage < 0) {
$warnungen[] = "$domain: seit " . abs($resttage) . " Tagen abgelaufen";
} elseif ($resttage <= SCHWELLE_TAGE) {
$warnungen[] = "$domain: laeuft in $resttage Tagen ab ("
. $info['issuer']['CN'] . ')';
}
}
if ($warnungen !== []) {
mail('technik@beispiel.de', 'Zertifikate laufen ab',
implode(PHP_EOL, $warnungen));
}
Das Zeitlimit im Verbindungsaufbau ist bei einem Cronjob wichtiger, als es aussieht. Ohne diesen Wert bleibt das Skript an einer nicht erreichbaren Domain hängen, und die restlichen werden nie geprüft.
Der folgende Ablauf zeigt die Kette von der Domain bis zur verschickten Warnung.
flowchart TD
A[Domain] --> B[stream_socket_client ssl]
B --> C[peer_certificate]
C --> D[openssl_x509_parse]
D --> E[validTo_time_t]
E --> F{Restlaufzeit unter Schwelle}
F -->|ja| G[Warnung senden]
F -->|nein| H[nur protokollieren]
Jeder Schritt kann fehlschlagen, deshalb prüft das Skript nach dem Abruf und nach dem Zerlegen jeweils auf false.
Die Zertifikatskette auslesen
Neben dem eigentlichen Zertifikat liefert der Server üblicherweise ein oder zwei Zwischenzertifikate mit. Fehlen sie, funktioniert die Verbindung in manchen Browsern und in anderen nicht. Mit capture_peer_cert_chain lässt sich prüfen, was wirklich ausgeliefert wird.
<?php
$kontext = stream_context_create([
'ssl' => ['capture_peer_cert_chain' => true, 'verify_peer' => false],
]);
$client = stream_socket_client('ssl://beispiel.de:443', $n, $t, 10,
STREAM_CLIENT_CONNECT, $kontext);
$kette = stream_context_get_params($client)['options']['ssl']['peer_certificate_chain'];
foreach ($kette as $stufe => $zert) {
$daten = openssl_x509_parse($zert);
printf("%d: %s (ausgestellt von %s)\n",
$stufe, $daten['subject']['CN'] ?? '?', $daten['issuer']['CN'] ?? '?');
}
Wer Zertifikate von Partnern entgegennimmt, arbeitet mit derselben Funktion. Ein Client-Zertifikat liegt bei entsprechender Serverkonfiguration als PEM in $_SERVER['SSL_CLIENT_CERT'] und geht direkt an PHP openssl_x509_parse().
<?php
const ZUGELASSENE_AUSSTELLER = ['Partner-CA', 'Interne CA'];
$pem = $_SERVER['SSL_CLIENT_CERT'] ?? '';
$info = $pem !== '' ? openssl_x509_parse($pem) : false;
if ($info === false) {
http_response_code(403);
exit('Kein Client-Zertifikat');
}
$aussteller = $info['issuer']['CN'] ?? '';
$gueltig = $info['validTo_time_t'] > time();
if (!$gueltig || !in_array($aussteller, ZUGELASSENE_AUSSTELLER, true)) {
http_response_code(403);
exit('Zertifikat nicht zugelassen oder abgelaufen');
}
echo 'Zugriff fuer ' . ($info['subject']['CN'] ?? 'unbekannt');
Geprüft werden zwei Dinge getrennt: die Laufzeit und der Aussteller. Ein gültiges Zertifikat einer fremden Stelle ist für diese Anwendung genauso wertlos wie ein abgelaufenes der eigenen.
Wenn PHP openssl_x509_parse() false liefert
Ein blankes false sagt nichts über die Ursache. Zwei Prüfungen decken die häufigsten Fälle ab: Ist die Eingabe überhaupt PEM, und was meldet OpenSSL?
<?php
function zertifikatLesen(string $pem): array
{
if (!str_starts_with(trim($pem), '-----BEGIN CERTIFICATE-----')) {
throw new RuntimeException('Eingabe ist kein PEM-Zertifikat');
}
$info = openssl_x509_parse($pem);
if ($info === false) {
$meldungen = [];
while (($fehler = openssl_error_string()) !== false) {
$meldungen[] = $fehler;
}
throw new RuntimeException(implode(' | ', $meldungen));
}
return $info;
}
Die Schleife um openssl_error_string() ist nötig, weil OpenSSL mehrere Meldungen stapelt und ein einzelner Aufruf nur die älteste liefert. Wer weitere Informationen über eine fremde Adresse braucht, findet im Tutorial zu PHP get_headers() und dem Abrufen von HTTP-Headern die passende Ergänzung.
Fazit
PHP openssl_x509_parse() beantwortet drei betriebliche Fragen: Wie lange gilt das Zertifikat, wer hat es ausgestellt und für welche Namen ist es gültig. Die Antworten stehen in validTo_time_t, in issuer und in extensions['subjectAltName'].
Der wichtigste Teil ist nicht die Funktion selbst, sondern der Weg zum Zertifikat. Erst der Stream-Kontext mit capture_peer_cert macht aus einer Domainliste eine Überwachung, die meldet, bevor der Browser es tut.