Ein Zahlungsdienstleister schickt seine Rückmeldungen verschlüsselt. Verwendet hat er den öffentlichen Schlüssel, den man ihm einmal übergeben hat. Der Gegenpart dazu liegt auf dem eigenen Server, und er ist der einzige Weg zurück zum Klartext.
Im Bild verschließt der eine Schlüssel, der andere öffnet. Diese Rollenverteilung ist der Kern des Themas, und sie wird häufiger verwechselt als alles andere.
Was PHP openssl_private_decrypt() macht
PHP openssl_private_decrypt() nimmt verschlüsselte Daten entgegen und stellt daraus mit dem privaten Teil eines RSA-Schlüsselpaars den Klartext wieder her. Verschlüsselt wurde vorher mit dem öffentlichen Teil, den man bedenkenlos herausgeben kann. Das Ergebnis kommt nicht als Rückgabewert, sondern über den zweiten Parameter.
<?php
$paar = openssl_pkey_new(['private_key_bits' => 2048]);
openssl_pkey_export($paar, $privat);
$oeffentlich = openssl_pkey_get_details($paar)['key'];
openssl_public_encrypt('Rechnungsnummer 4711', $geheim, $oeffentlich);
if (openssl_private_decrypt($geheim, $klartext, $privat)) {
echo $klartext; // Rechnungsnummer 4711
}
Wer zum ersten Mal damit arbeitet, sucht das Ergebnis meist im Rückgabewert und findet dort nur true oder false. Der entschlüsselte Text landet in $klartext, weil der Parameter als Referenz übergeben wird. Wie ein Schlüsselpaar überhaupt entsteht, zeigt PHP openssl_pkey_new() zum Erzeugen von RSA- und EC-Schlüsseln. Hier liegt das Paar bereits vor.
Die Signatur im Original
Die englische Beschreibung nennt den Punkt, an dem sich diese Funktion von ihren drei Geschwistern unterscheidet:
openssl_private_decrypt(
string $data,
string &$decrypted_data,
$private_key,
int $padding = OPENSSL_PKCS1_PADDING
): bool
/* data - the sealed data to decrypt, raw binary, not base64
decrypted_data - reference parameter, receives the decrypted data
private_key - a key resource, a PEM string or a file:// path
padding - must be the same padding the sender used
return value - true on success, false on failure. The decrypted
data is never returned directly, it is written to
the second argument instead. */
Zwei Angaben daraus verursachen die meisten Fehlschläge. Erstens erwartet die Funktion Rohbytes, kein Base64. Zweitens muss das Padding auf beiden Seiten identisch sein, sonst scheitert die Entschlüsselung selbst dann, wenn der Schlüssel stimmt.
Welcher Schlüssel wofür
OpenSSL stellt vier Funktionen bereit, die sich paarweise ergänzen. Zwei davon dienen der Geheimhaltung, die anderen beiden der Herkunftsprüfung. Wer sie verwechselt, baut ein Verfahren, das etwas anderes leistet als beabsichtigt.
| Funktion | Schlüssel | Zweck |
openssl_public_encrypt | öffentlich | verschließen, damit nur der Empfänger liest |
openssl_private_decrypt | privat | öffnen, was für einen bestimmt war |
openssl_private_encrypt | privat | Herkunft belegen, keine Geheimhaltung |
openssl_public_decrypt | öffentlich | prüfen, wer es verschlüsselt hat |
Die untere Hälfte der Tabelle sieht nach einer Signatur aus, ist aber keine. Wer Nachrichten wirklich signieren will, nimmt openssl_sign() und openssl_verify(). Diese Funktionen hängen einen Hashwert an das Verfahren und prüfen ihn mit.
flowchart TD
A[Klartext beim Absender] --> B
B[openssl_public_encrypt] --> C
C[Uebertragung] --> D
D[openssl_private_decrypt] --> E{Ergebnis}
E -->|true| F[Klartext im 2. Parameter]
E -->|false| G[openssl_error_string pruefen]
Der rechte Ast ist der Weg, den dieser Text später noch genauer betrachtet, denn ein Fehlschlag meldet sich von selbst nicht.
Das Padding richtig wählen
RSA verschlüsselt niemals den nackten Text. Vorher wird er nach einem festen Verfahren aufgefüllt, und dieses Verfahren muss der Empfänger kennen. PHP openssl_private_decrypt() nimmt dafür einen vierten Parameter entgegen, der standardmäßig auf PKCS1 steht.
| Konstante | Aufschlag | Einordnung |
OPENSSL_PKCS1_PADDING | 11 Byte | Voreinstellung, weit verbreitet im Bestand |
OPENSSL_PKCS1_OAEP_PADDING | 42 Byte | erste Wahl für neue Verfahren |
OPENSSL_SSLV23_PADDING | 11 Byte | historisch, nur für alte Gegenstellen |
OPENSSL_NO_PADDING | 0 Byte | Eingabe muss exakt die Schlüssellänge haben |
<?php
/* Beide Seiten muessen dasselbe Padding verwenden.
Ein einseitiger Wechsel bricht die Verbindung sofort. */
openssl_public_encrypt($text, $geheim, $oeffentlich, OPENSSL_PKCS1_OAEP_PADDING);
openssl_private_decrypt($geheim, $klar, $privat, OPENSSL_PKCS1_OAEP_PADDING);
Für neue Verfahren ist OAEP die bessere Wahl, weil PKCS1 in der Version 1.5 seit langem als angreifbar gilt. Wer eine bestehende Anbindung umstellen will, muss das mit der Gegenstelle abstimmen. Ein Wechsel auf einer Seite allein macht die Verbindung unbrauchbar.
Wie viel passt in einen RSA-Block
RSA ist kein Verfahren für lange Texte. Es verschlüsselt genau einen Block, und dessen Größe entspricht der Schlüssellänge abzüglich des Padding-Aufschlags. Bei 2048 Bit sind das 256 Byte, davon bleiben mit PKCS1 noch 245 Byte Nutzlast und mit OAEP noch 214 Byte. PHP openssl_private_decrypt() gibt darauf keinen Hinweis, es liefert schlicht nichts.
| Schlüssellänge | Blockgröße | mit PKCS1 | mit OAEP |
| 1024 Bit | 128 Byte | 117 Byte | 86 Byte |
| 2048 Bit | 256 Byte | 245 Byte | 214 Byte |
| 4096 Bit | 512 Byte | 501 Byte | 470 Byte |
<?php
$laenge = openssl_pkey_get_details($paar)['bits'] / 8; // 256
$nutzbar = $laenge - 42; // 214 bei OAEP
$kurz = str_repeat('A', 200);
$lang = str_repeat('A', 300);
var_dump(openssl_public_encrypt($kurz, $a, $oeffentlich, OPENSSL_PKCS1_OAEP_PADDING));
var_dump(openssl_public_encrypt($lang, $b, $oeffentlich, OPENSSL_PKCS1_OAEP_PADDING));
// bool(true)
// bool(false)
Der zweite Aufruf scheitert stumm. Wer diese Grenze nicht kennt, sucht den Fehler an der falschen Stelle, meist beim Schlüssel. Die Rechnung ist dabei die einfache Richtung: Wenn schon das Verschlüsseln fehlschlägt, kommt PHP openssl_private_decrypt() gar nicht erst zum Zuge. Kritischer wird es, wenn ein Fremdsystem stillschweigend in mehrere Blöcke aufteilt und die Gegenseite nur den ersten davon öffnet.
Große Daten: das hybride Verfahren
Der Ausweg ist derselbe, den auch TLS geht. Die eigentlichen Daten werden symmetrisch verschlüsselt, und nur der dabei verwendete Schlüssel reist per RSA mit. Das ist schnell, hat keine Längengrenze und behält die Vorteile des Schlüsselpaars.
<?php
/* Absender: Sitzungsschluessel erzeugen, Nutzdaten damit
verschluesseln, nur den Schluessel per RSA transportieren. */
$sitzung = random_bytes(32);
$iv = random_bytes(16);
$nutz = openssl_encrypt($grosserText, 'aes-256-cbc', $sitzung, OPENSSL_RAW_DATA, $iv);
openssl_public_encrypt($sitzung, $paket, $oeffentlich, OPENSSL_PKCS1_OAEP_PADDING);
/* Empfaenger: erst den Sitzungsschluessel oeffnen, dann die Nutzdaten. */
openssl_private_decrypt($paket, $sitzung2, $privat, OPENSSL_PKCS1_OAEP_PADDING);
$text = openssl_decrypt($nutz, 'aes-256-cbc', $sitzung2, OPENSSL_RAW_DATA, $iv);
Die symmetrische Hälfte dieses Verfahrens behandelt PHP openssl_encrypt() zum sicheren Verschlüsseln von Daten ausführlich, samt Wahl des Verfahrens und Umgang mit dem Initialisierungsvektor. Für PHP openssl_private_decrypt() ändert sich in diesem Aufbau nichts: Es öffnet weiterhin genau einen kurzen Block, nämlich den Sitzungsschlüssel. Der Initialisierungsvektor darf offen mitreisen, er ist kein Geheimnis.
Wenn PHP openssl_private_decrypt() false liefert
Ein Fehlschlag erzeugt keine Warnung und keinen Eintrag im Fehlerprotokoll. OpenSSL legt seine Meldungen in eine eigene Warteschlange, die aktiv geleert werden muss.
<?php
if (!openssl_private_decrypt($geheim, $klar, $privat)) {
while ($meldung = openssl_error_string()) {
error_log('OpenSSL: ' . $meldung);
}
}
Drei Ursachen decken die allermeisten Fälle ab. Erstens kommen die Daten als Base64 an und wurden nicht mit base64_decode() zurückgewandelt. Das passiert regelmäßig bei Anbindungen an Systeme in anderen Sprachen, weil Base64 dort oft schon zum Transportformat gehört. Zweitens verwenden Absender und Empfänger unterschiedliches Padding. Drittens gehört der private Schlüssel nicht zu dem öffentlichen, mit dem verschlüsselt wurde.
<?php
/* Typischer Fall: die Gegenstelle liefert Base64.
PHP openssl_private_decrypt erwartet aber Rohbytes. */
$roh = base64_decode($_POST['payload'], true);
if ($roh === false) {
throw new RuntimeException('Kein gueltiges Base64 empfangen');
}
openssl_private_decrypt($roh, $klar, $privat, OPENSSL_PKCS1_OAEP_PADDING);
Der zweite Parameter von base64_decode() schaltet die strenge Prüfung ein. Ohne ihn liefert die Funktion bei fehlerhaften Eingaben irgendetwas zurück, und der eigentliche Fehler taucht erst eine Ebene später auf.
Schlüssel mit Passphrase laden
Liegt der private Schlüssel passwortgeschützt als Datei vor, muss er vor der Verwendung geöffnet werden. PHP openssl_private_decrypt() nimmt zwar auch einen PEM-String entgegen, mit einer Passphrase kommt es aber nicht allein zurecht.
<?php
$schluessel = openssl_pkey_get_private(
'file:///etc/ssl/private/dienst.pem',
getenv('SCHLUESSEL_PASSWORT')
);
if ($schluessel === false) {
throw new RuntimeException('Privater Schluessel nicht lesbar');
}
openssl_private_decrypt($geheim, $klar, $schluessel);
Die Prüfung auf false ist wichtig, weil ein falsches Passwort keinen Fehler wirft. Ohne sie läuft der nächste Aufruf mit einem ungültigen Schlüssel weiter und scheitert an einer Stelle, die mit der Ursache nichts zu tun hat.
Fazit
PHP openssl_private_decrypt() ist die Empfängerseite einer asymmetrischen Übertragung. Verschlüsselt wird mit dem öffentlichen Schlüssel, geöffnet mit dem privaten, und alles andere ist eine andere Aufgabe. Wer sich diese Vierertabelle einmal einprägt, spart sich die häufigste Fehlersuche.
Drei Dinge bleiben in der Praxis hängen: Das Ergebnis steht im zweiten Parameter, das Padding muss auf beiden Seiten gleich sein, und ein einzelner RSA-Block fasst nur wenige hundert Byte. Für alles Größere gibt es das hybride Verfahren. Und wenn PHP openssl_private_decrypt() einmal kommentarlos false meldet, liefert openssl_error_string() in einer Schleife die Antwort, die im Fehlerprotokoll fehlt.