Über Port 465 geht der Versand raus, über Port 587 nicht, und im Skript steht beide Male derselbe Code. Der Unterschied liegt nicht in PHP, sondern in der Bauart der beiden Ports. PHP stream_socket_enable_crypto() ist die Funktion für den zweiten Fall: Sie schaltet eine offene Klartextverbindung nachträglich auf TLS um. Wer auf einem Port landet, der ab der ersten Zeile verschlüsselt spricht, gibt die Adresse mit tls:// an und ist fertig; das steht im Tutorial zu stream_socket_client(). Hier geht es um die Protokolle, die im Klartext beginnen und erst auf Zuruf umschalten.
Zwischen der Klartextleitung und der abgeschirmten Strecke liegt ein einziger Aufruf, und der will richtig vorbereitet sein.
Wann PHP stream_socket_enable_crypto() gebraucht wird
Es gibt zwei Bauweisen, und die Portnummer allein sagt noch nichts. Bei der einen spricht ein Dienst von der ersten Zeile an verschlüsselt. Bei der anderen beginnt er im Klartext und kennt ein Kommando, mit dem beide Seiten umschalten. Der Fachbegriff dafür lautet opportunistisches TLS, und nur in diesem zweiten Fall kommt PHP stream_socket_enable_crypto() ins Spiel.
| Dienst | Port | Kommando | Bauweise |
| SMTP | 587 | STARTTLS | Klartextbeginn, Umschalten nötig |
| SMTP | 465 | keines | sofort verschlüsselt, tls:// |
| IMAP | 143 | a001 STARTTLS | Klartextbeginn, Umschalten nötig |
| IMAP | 993 | keines | sofort verschlüsselt, tls:// |
| FTP | 21 | AUTH TLS | Klartextbeginn auf dem Steuerkanal |
Die letzte Spalte ist die eigentliche Auskunft. Eine Portnummer ohne sie sagt nicht, ob ein Hochstufen ansteht.
Die Signatur von PHP stream_socket_enable_crypto(): Parameter und Rückgabewert
Vier Parameter, davon zwei mit Vorgabewert, und ein Rückgabetyp, der schon in der Signatur verrät, dass hier mehr als ja und nein herauskommen kann:
stream_socket_enable_crypto(
resource $stream,
bool $enable,
?int $crypto_method = null,
?resource $session_stream = null
): int|bool
/* stream - open socket resource
enable - true turns encryption on
crypto_method - one of the STREAM_CRYPTO_
METHOD_* constants, required
whenever enable is true
session_stream - reuse a TLS session
return - true on success,
false on failure,
0 while the handshake is still
running on a non blocking stream */
Der Typ int|bool steht damit schwarz auf weiß da. Der dritte Parameter sieht nach einem freiwilligen Zusatz aus, ist beim Einschalten aber Pflicht: Lässt man ihn weg, bricht PHP stream_socket_enable_crypto() unter 8.4.8 mit einem ValueError ab, der den Parameter beim Namen nennt. Eine Ausnahme gibt es: Steht im Verbindungskontext die Option crypto_method, greift PHP auf sie zurück. Gegen einen lokalen Testserver gemessen lieferte derselbe Aufruf ohne dritten Parameter dann eine 0 statt einer Ausnahme. Genau daran scheitern ältere Beispiele: Wer aus ihnen nur die Aufrufzeile übernimmt, lässt die Option zurück und bekommt den ValueError.
<?php
try {
stream_socket_enable_crypto($s, true);
} catch (ValueError $e) {
echo $e->getMessage();
}
/* stream_socket_enable_crypto():
Argument #3 ($crypto_method) must be
specified when enabling encryption */
Das STARTTLS-Muster bei SMTP auf Port 587
Der Ablauf folgt der SMTP-Spezifikation: verbinden, die Begrüßung mit Code 220 lesen, EHLO senden, die Fähigkeitsliste lesen, STARTTLS senden, die Zusage mit Code 220 abwarten, mit PHP stream_socket_enable_crypto() hochstufen, und dann EHLO noch einmal senden.
flowchart TD
A[TCP auf Port 587] --> B[220 Begrüßung]
B --> C[EHLO senden]
C --> D[STARTTLS senden]
D --> E{220 erhalten?}
E -->|nein| F[Abbruch]
E -->|ja| G[crypto einschalten]
G --> H{Rückgabe wahr?}
H -->|nein| F
H -->|ja| I[EHLO erneut]
Zwei kleine Hilfsfunktionen nehmen dem Hauptablauf die Antwortverarbeitung ab. SMTP antwortet mehrzeilig, und die letzte Zeile erkennt man daran, dass hinter dem dreistelligen Code ein Leerzeichen steht und kein Bindestrich.
<?php
function lies($s): string
{
$alles = '';
while (($zeile = fgets($s, 1024)) !== false) {
$alles .= $zeile;
/* letzte Zeile: Code, dann Leerzeichen */
if (preg_match('/^\d{3} /', $zeile)) {
break;
}
}
return $alles;
}
function erwarte($s, string $code): string
{
$text = lies($s);
if (!str_starts_with($text, $code)) {
throw new RuntimeException(trim($text));
}
return $text;
}
Damit wird der Hauptablauf kurz genug, um ihn am Stück zu lesen. Der Aufruf steht darin genau an einer Stelle, nämlich zwischen der Zusage des Servers und der wiederholten Begrüßung.
<?php
$s = stream_socket_client(
'tcp://smtp.example.com:587',
$nr,
$text,
10
);
if ($s === false) {
throw new RuntimeException($text);
}
erwarte($s, '220');
fwrite($s, "EHLO php-space.info\r\n");
erwarte($s, '250');
fwrite($s, "STARTTLS\r\n");
erwarte($s, '220');
$ok = stream_socket_enable_crypto(
$s,
true,
STREAM_CRYPTO_METHOD_TLS_CLIENT
);
if ($ok !== true) {
fclose($s);
throw new RuntimeException('TLS gescheitert');
}
fwrite($s, "EHLO php-space.info\r\n");
erwarte($s, '250');
Wer eine fertige Bibliothek einsetzen darf, braucht diesen Ablauf nicht selbst zu schreiben. PHPMailer erledigt ihn hinter der Einstellung SMTPSecure. Dieser Text ist für alle, die keine Fremdbibliothek einbinden dürfen oder wissen wollen, was dort passiert.
Warum die Begrüßung nach dem Umschalten wiederholt wird
Das zweite EHLO sieht nach einer Doppelung aus, und genau deshalb fällt es beim Abschreiben oft weg. Der Grund ist sachlich: Die Fähigkeitsliste vor dem Umschalten lief im Klartext über die Leitung und ist damit nicht vertrauenswürdig. Dazu kommt ein handfester Grund, der sich nachmessen lässt. Gegen einen echten Postausgangsserver geprüft, enthielt die erste Liste kein einziges Anmeldeverfahren; nach dem Hochstufen meldete derselbe Server 250-AUTH LOGIN PLAIN XOAUTH2. Wer die alte Liste weiterverwendet, findet also gar kein Verfahren, mit dem er sich anmelden könnte.
Ein Beispiel ohne diesen Schritt ist tückischer als eines, das gar nicht läuft. Bei einem Teil der Server geht es trotzdem gut, und der Fehler zeigt sich erst beim Wechsel des Anbieters. Nach jedem erfolgreichen Hochstufen gehört die Begrüßung deshalb wiederholt.
Dasselbe Muster bei IMAP und bei FTP
Die Folge bleibt dieselbe, nur die Kommandos wechseln, und der Aufruf von PHP stream_socket_enable_crypto() steht wieder an derselben Stelle. IMAP nummeriert jedes Kommando durch, weshalb dort a001 STARTTLS steht statt eines nackten Schlüsselworts. FTP verwendet AUTH TLS auf dem Steuerkanal. Beide Abläufe stammen aus den Spezifikationen und nicht aus einer eigenen Messung gegen echte Server.
<?php
$tls = STREAM_CRYPTO_METHOD_TLS_CLIENT;
/* IMAP auf Port 143 */
fgets($s, 1024); /* * OK ... */
fwrite($s, "a001 STARTTLS\r\n");
fgets($s, 1024); /* a001 OK ... */
$ok = stream_socket_enable_crypto($s, true, $tls);
/* FTP auf Port 21, Steuerkanal */
fgets($s, 1024); /* 220 ... */
fwrite($s, "AUTH TLS\r\n");
fgets($s, 1024); /* 234 ... */
$ok = stream_socket_enable_crypto($s, true, $tls);
Bei FTP kommt eine Besonderheit dazu: Steuerkanal und Datenkanal sind zwei getrennte Verbindungen. Nach AUTH TLS ist erst der Steuerkanal geschützt. Der Datenkanal folgt über PBSZ und PROT P und wird einzeln hochgestuft.
Die Krypto-Methoden und ihre Zahlenwerte
Die Konstanten sind Bitmasken und lassen sich mit dem Oder-Zeichen zusammensetzen. Die folgenden Werte stammen aus einem Lauf unter PHP 8.4.8 mit OpenSSL 3.0.16:
<?php
echo STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT; /* 33 */
echo STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT; /* 65 */
echo STREAM_CRYPTO_METHOD_SSLv23_CLIENT; /* 57 */
echo STREAM_CRYPTO_METHOD_TLS_CLIENT; /* 121 */
echo STREAM_CRYPTO_METHOD_ANY_CLIENT; /* 127 */
$ab12 = STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT
| STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT;
/* 33 | 65 = 97 */
Hier steckt die unauffälligste Falle des ganzen Themas. SSLv23_CLIENT klingt nach „alles, was geht“, schließt TLS 1.3 aber nicht ein. Gegen denselben Server gemessen, handelte diese Konstante TLSv1.2 aus, während TLS_CLIENT auf TLSv1.3 kam. Wer den alten Namen aus einem Forenbeitrag übernimmt, verschenkt stillschweigend die aktuelle Protokollversion. Eine Mindestversion schreibt man als Summe zweier Konstanten, so wie oben.
Was nach dem Hochstufen in den Stream-Metadaten steht
Was tatsächlich ausgehandelt wurde, muss man nicht raten. Nach einem erfolgreichen PHP stream_socket_enable_crypto() trägt der Stream die ausgehandelten Werte in seinen Metadaten:
<?php
$m = stream_get_meta_data($s)['crypto'];
echo $m['protocol']; /* TLSv1.3 */
echo $m['cipher_name']; /* TLS_AES_256_GCM_SHA384 */
echo $m['cipher_bits']; /* 256 */
Damit lässt sich die eigene Versionsvorgabe prüfen, statt sie zu glauben. Steht dort TLSv1.2, obwohl TLS_CLIENT übergeben wurde, hat die Gegenstelle nichts Neueres angeboten. Solange der Stream im Klartext läuft, gibt es den Schlüssel crypto gar nicht, und ein direkter Zugriff darauf endet in einer Warnung.
Drei Rückgabewerte statt zwei
Auf einem blockierenden Stream liefert PHP stream_socket_enable_crypto() bool(true), wenn das Aushandeln gelungen ist, und bool(false), wenn es abgelehnt wurde. Der dritte Wert taucht nur auf, wenn der Stream vorher mit stream_set_blocking($s, false) umgestellt wurde. Gemessen lieferte der erste Aufruf dann int(0) und der zweite bool(true).
Die Null heißt also: Das Aushandeln läuft noch, bitte gleich noch einmal aufrufen. Wer mit == oder mit if (!$r) prüft, macht aus diesem Zwischenzustand einen Fehler und wirft eine funktionierende Verbindung weg. Der strenge Vergleich mit === ist hier keine Stilfrage.
<?php
stream_set_blocking($s, false);
$tls = STREAM_CRYPTO_METHOD_TLS_CLIENT;
$frist = microtime(true) + 10.0;
do {
$r = stream_socket_enable_crypto($s, true, $tls);
if ($r === true) {
break;
}
if ($r === false) {
throw new RuntimeException('Handshake weg');
}
usleep(20000);
} while (microtime(true) < $frist);
Die Zertifikatsprüfung gehört vor das Hochstufen
Geprüft wird im Moment des Umschaltens, nicht beim Verbinden. Das ist der Grund, warum die Einstellungen spätestens vor dem Aufruf feststehen müssen. Oft liest man, sie müssten schon im Verbindungskontext stehen. Gemessen stimmt das nicht: Ein stream_context_set_option() auf dem bereits offenen Stream wirkte, und derselbe Ablauf ohne diese Zeile scheiterte. Die Regel lautet also vor dem Hochstufen und nicht beim Verbinden.
Für den Normalfall bleibt es beim Verbindungskontext, weil dort alles an einer Stelle steht. Vier Einstellungen wertet PHP stream_socket_enable_crypto() dabei aus:
<?php
$ctx = stream_context_create([
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
'peer_name' => 'smtp.example.com',
'cafile' => '/etc/ssl/eigen.pem',
],
]);
$s = stream_socket_client(
'tcp://203.0.113.10:587',
$nr,
$text,
10,
STREAM_CLIENT_CONNECT,
$ctx
);
Was in einem Zertifikat überhaupt drinsteht und wie man Aussteller, Namen und Laufzeit selbst nachliest, zeigt das Tutorial zu openssl_x509_parse(). Das hilft besonders dann, wenn der Namensabgleich scheitert und man nicht weiß, auf welchen Namen das Zertifikat der Gegenseite eigentlich ausgestellt ist.
SSL operation failed with code 1 und die OpenSSL Error messages
Seit PHP 5.6 ist verify_peer eingeschaltet. Trifft die Prüfung auf ein selbst ausgestelltes Zertifikat ohne hinterlegtes Ausstellerzertifikat, sieht das so aus:
<?php
$ok = stream_socket_enable_crypto($s, true, $tls);
var_dump($ok);
/* Warning: stream_socket_enable_crypto():
SSL operation failed with code 1.
OpenSSL Error messages:
error:0A000086:SSL routines::
certificate verify failed
bool(false) */
Hinter diesem einen Text stecken vier Ursachen. Erstens ein Zertifikat, dessen Aussteller dem System unbekannt ist; dagegen hilft cafile mit dem passenden Ausstellerzertifikat. Zweitens ein Name, der nicht passt; dagegen hilft peer_name, und das ist der Normalfall, sobald man über eine IP-Adresse verbindet. Drittens eine unvollständige Zertifikatskette auf der Gegenseite, ein Fehler des Servers, den PHP stream_socket_enable_crypto() zu Recht nicht durchwinkt. Viertens ein Dienst, der der Gegenstelle vorgeschaltet ist und die Verbindung selbst beendet, etwa ein Schutzdienst oder ein Firmen-Proxy. Dann stammt das Zertifikat von ihm und nicht vom eigentlichen Server: Der Aussteller kann dem System unbekannt sein, und der Name im Zertifikat gehört dem Dienst. Hier hilft peer_name mit dem tatsächlich ausgestellten Namen, dazu das Ausstellerzertifikat des Dienstes in cafile.
Was in Forenbeiträgen als Lösung kursiert, nämlich verify_peer auf falsch zu setzen, ist keine. Es schaltet die Prüfung ab, und danach ist die Verbindung zwar verschlüsselt, aber gegenüber einem Angreifer in der Leitung wehrlos. Gemessen kam mit cafile und peer_name bei weiterhin eingeschalteter Prüfung ein sauberes bool(true) zurück. Der Weg ist also da, er kostet nur zwei Zeilen mehr als das Abschalten.
Opportunistisches TLS ist kein Versprechen
Alles vor dem Umschalten läuft im Klartext, auch die Fähigkeitsliste. Ein Angreifer in der Leitung kann STARTTLS daraus streichen, und ein Klient, der das Umschalten nur versucht, redet danach ungeschützt weiter. Das ist ein Downgrade-Angriff, und er funktioniert ohne jedes Zutun des Servers.
Daraus folgt die Gegenmaßnahme. Das Hochstufen wird nicht versucht, sondern verlangt. Schlägt PHP stream_socket_enable_crypto() fehl, wird die Verbindung geschlossen und ein Fehler gemeldet, statt im Klartext weiterzuarbeiten. Genau das leistet das if ($ok !== true) im Beispiel weiter oben, und es ist der wichtigste Teil davon. Wo die Wahl besteht, ist ein Port, der von Anfang an verschlüsselt spricht, die sicherere Bauweise.
Fazit
PHP stream_socket_enable_crypto() ist die zweite Hälfte einer Verbindung, die im Klartext beginnt. Vor der Codefrage steht die Portfrage: Spricht die Gegenstelle ab der ersten Zeile verschlüsselt, braucht es diese Funktion nicht. Beginnt sie im Klartext, gilt das STARTTLS-Muster mitsamt der wiederholten Begrüßung nach dem Umschalten.
Der dritte Parameter ist Pflicht, und STREAM_CRYPTO_METHOD_TLS_CLIENT mit dem Wert 121 erreicht TLS 1.3, wo SSLv23_CLIENT mit 57 bei TLS 1.2 stehen bleibt. Der Rückgabewert ist dreiwertig und will mit === geprüft werden, weil die Null nur bedeutet, dass das Aushandeln noch läuft. Und die Meldung error:0A000086 ruft nach cafile und peer_name, nicht nach einem abgeschalteten verify_peer.