This RFC proposes adding comprehensive TLS session resumption support to PHP's OpenSSL stream implementation. Session resumption is a critical performance optimization that allows TLS clients and servers to skip the expensive full handshake by reusing cryptographic parameters from previous connections.
Currently, PHP's stream wrapper provides limited control over session management. While OpenSSL's client session can be copied using stream_socket_enable_crypto session_stream parameter, developers cannot:
This proposal exposes OpenSSL's session management APIs through the existing stream context options, providing fine-grained control while maintaining backward compatibility.
<?php // Client: Resume previous session for faster reconnection $previousSession = $_SESSION['tls_session'] ?? null; $context = stream_context_create([ 'ssl' => [ 'peer_name' => 'api.example.com', 'session_data' => $previousSession ? OpenSSLSession::import($previousSession) : null, 'session_new_cb' => function($stream, OpenSSLSession $session) { $_SESSION['tls_session'] = $session->export(); } ] ]); $fp = stream_socket_client('tls://api.example.com:443', context: $context); // Second connection will resume, saving ~100ms of handshake time ?>
This RFC introduces a new OpenSSLException class as the base exception for the OpenSSL
extension. This follows the Throwable policy
which requires extensions to use extension-specific exception classes rather than the base
\Exception.
class OpenSSLException extends \Exception {}
The OpenSSLException class name was chosen for consistency with the existing non-namespaced
OpenSSL symbols (OpenSSLCertificate, OpenSSLCertificateSigningRequest,
OpenSSLAsymmetricKey). However, two secondary votes are included in this RFC to allow
the community to decide on the final naming of both the exception class and the session class
(see Voting Choices).
OpenSSLException is used by the OpenSSLSession class methods and may be used by future
OpenSSL extension additions. It is thrown in the following cases:
OpenSSLSession::import() - when the provided data cannot be decoded or parsedOpenSSLSession::export() - when the session encoding fails (DER or PEM) or internal BIO allocation failsOpenSSLSession::__serialize() - when the session PEM encoding failsOpenSSLSession::__unserialize() - when the serialization data is invalid or the session cannot be restored
The proposal introduces a new class representing an SSL session that is used by new stream context
options and callbacks. The class wraps an underlying SSL_SESSION and provides methods for
inspection, serialization, and import/export.
final class OpenSSLSession { /** @var string Binary session ID assigned by the server */ public readonly string $id; /** * Export the session to a portable format. * * @param int $format One of existing OPENSSL_ENCODING_PEM (default) or OPENSSL_ENCODING_DER * @return string Encoded session data * @throws OpenSSLException on export failure */ public function export(int $format = OPENSSL_ENCODING_PEM): string {} /** * Import a session from previously exported data. * * @param string $data Encoded session data (DER or PEM) * @param int $format One of exiting OPENSSL_ENCODING_PEM (default) or OPENSSL_ENCODING_DER * @return OpenSSLSession * @throws OpenSSLException on import failure */ public static function import(string $data, int $format = OPENSSL_ENCODING_PEM): OpenSSLSession {} /** Whether this session can be used for resumption */ public function isResumable(): bool {} /** Session lifetime in seconds as configured by the server */ public function getTimeout(): int {} /** Unix timestamp of when the session was created */ public function getCreatedAt(): int {} /** TLS protocol version string (e.g. "TLSv1.3") or null if unknown */ public function getProtocol(): ?string {} /** Cipher suite name (e.g. "TLS_AES_256_GCM_SHA384") or null */ public function getCipher(): ?string {} /** Whether the session contains a session ticket */ public function hasTicket(): bool {} /** Server-suggested ticket lifetime in seconds, or null if no ticket */ public function getTicketLifetimeHint(): ?int {} /** * @internal Serializes to PEM for use with serialize(). * @throws OpenSSLException on serialization failure */ public function __serialize(): array {} /** * @internal Restores from PEM data. * @throws OpenSSLException on unserialization failure */ public function __unserialize(array $data): void {} }
OpenSSLSession cannot be directly instantiated. Instances are obtained through:
session_new_cb callback (receives an OpenSSLSession for a newly established session)OpenSSLSession::import() (restores a session from previously exported data)unserialize() (restores a session serialized with serialize())
Two encoding formats are supported via the existing OPENSSL_ENCODING_DER and OPENSSL_ENCODING_PEM
constants:
<?php // Export as PEM (default, text-based) $pem = $session->export(); // or $session->export(OPENSSL_ENCODING_PEM) $restored = OpenSSLSession::import($pem); // Export as DER (compact, binary) $der = $session->export(OPENSSL_ENCODING_DER); $restored = OpenSSLSession::import($der, OPENSSL_ENCODING_DER); // PHP serialization also works (uses PEM internally) $serialized = serialize($session); $restored = unserialize($serialized); ?>
The class provides read-only access to session metadata for logging, debugging, and cache management:
<?php // Inside session_new_cb $newCb = function($stream, OpenSSLSession $session) { echo "Session ID: " . bin2hex($session->id) . "\n"; echo "Protocol: " . $session->getProtocol() . "\n"; // "TLSv1.3" echo "Cipher: " . $session->getCipher() . "\n"; // "TLS_AES_256_GCM_SHA384" echo "Resumable: " . ($session->isResumable() ? "yes" : "no") . "\n"; echo "Has ticket: " . ($session->hasTicket() ? "yes" : "no") . "\n"; echo "Timeout: " . $session->getTimeout() . "s\n"; echo "Created: " . date('c', $session->getCreatedAt()) . "\n"; if ($session->hasTicket()) { echo "Ticket lifetime hint: " . $session->getTicketLifetimeHint() . "s\n"; } }; ?>
This proposal adds the following SSL stream context options:
OpenSSLSession from a previous connection to resume. Data is typically obtained from a session_new_cb callback and restored via OpenSSLSession::import(). The null value is ignored.function(resource $stream, OpenSSLSession $session): void$stream: The SSL stream resource$session: The OpenSSLSession object representing the new sessionsession_new_cb is set with peer verification enabled, and for the internal session cache with peer verification.session_new_cb.function(resource $stream, string $sessionId): ?OpenSSLSessionOpenSSLSession instance or null if not found.function(resource $stream, string $sessionId): voidWhen a client stream is created:
session_data is provided as an OpenSSLSession instance, attempt to resume with that sessionsession_new_cb is provided, call it with the stream and an OpenSSLSession object when a new session is established or a new session ticket is receivedsession_stream is provided (existing option), the SSL context and session are copied from the provided streamsession_new_cb or session_data is set, client-side external cache mode is enabled (SSL_SESS_CACHE_CLIENT | SSL_SESS_CACHE_NO_INTERNAL)
Note: Client-side session resumption requires either explicit management via session_data and session_new_cb or setting session_stream which copies the session from the provided stream.
When a server stream is created:
Without External Cache (no session_get_cb):
session_cache controls whether caching is enabledsession_cache_size and session_timeout configure the internal cachesession_new_cb can be provided for notifications without external storage
With External Cache (session_get_cb provided):
SSL_SESS_CACHE_NO_INTERNAL)session_new_cb becomes required (ValueError if missing)SSL_OP_NO_TICKET is set); attempting to explicitly enable tickets raises ValueErrorsession_remove_cb is optionalSession Context ID Requirements:
session_id_context is required when peer verification is enabled (SSL_VERIFY_PEER) and either session_new_cb or the internal session cache is usedThe following validation errors are raised:
TypeError if session_new_cb, session_get_cb, or session_remove_cb is not a valid callableTypeError if session_data is not an OpenSSLSession instanceTypeError if session_get_cb returns a value that is not OpenSSLSession or nullValueError if session_get_cb is provided without session_new_cbValueError if session_id_context is missing when requiredValueError if session_cache_size or session_timeout is not positiveValueError if no_ticket is set to false when session_get_cb is setOpenSSLException if OpenSSLSession::import() fails to decode or parse the provided dataOpenSSLException if OpenSSLSession::export() fails to encode the session (DER or PEM) or fails to allocate internal resourcesOpenSSLException if OpenSSLSession::__serialize() fails to encode the sessionOpenSSLException if OpenSSLSession::__unserialize() receives invalid data or fails to restore the sessionE_WARNING if callbacks are used with persistent streams (not supported)E_WARNING if session_data contains an OpenSSLSession with an invalid or expired session (falls back to full handshake)<?php $sessions = []; function create_client_context(string $host): resource { global $sessions; return stream_context_create([ 'ssl' => [ 'peer_name' => $host, 'session_data' => $sessions[$host] ?? null, 'session_new_cb' => function($stream, OpenSSLSession $session) use ($host) { global $sessions; $sessions[$host] = $session; } ] ]); } // First connection - full handshake $fp1 = stream_socket_client( 'tls://api.example.com:443', $errno, $errstr, 30, STREAM_CLIENT_CONNECT, create_client_context('api.example.com') ); // Second connection - resumed! (much faster) $fp2 = stream_socket_client( 'tls://api.example.com:443', $errno, $errstr, 30, STREAM_CLIENT_CONNECT, create_client_context('api.example.com') ); ?>
<?php // Save session to file/cache for use in subsequent HTTP requests $sessionFile = '/tmp/tls_session_api.pem'; $previousSession = null; if (file_exists($sessionFile)) { try { $previousSession = OpenSSLSession::import(file_get_contents($sessionFile)); if (!$previousSession->isResumable()) { $previousSession = null; } } catch (OpenSSLException $e) { // Corrupted or invalid session data, start fresh $previousSession = null; } } $context = stream_context_create([ 'ssl' => [ 'peer_name' => 'api.example.com', 'session_data' => $previousSession, 'session_new_cb' => function($stream, OpenSSLSession $session) use ($sessionFile) { file_put_contents($sessionFile, $session->export()); } ] ]); $fp = stream_socket_client('tls://api.example.com:443', context: $context); ?>
<?php $context = stream_context_create([ 'ssl' => [ 'local_cert' => '/path/to/cert.pem', 'local_pk' => '/path/to/key.pem', // Session resumption enabled by default 'session_id_context' => 'example.com', 'session_cache' => true, 'session_cache_size' => 1024, 'session_timeout' => 7200, // 2 hours ] ]); $server = stream_socket_server( 'tls://0.0.0.0:8443', $errno, $errstr, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $context ); while ($conn = stream_socket_accept($server)) { // Handle connection // Client session resumption happens automatically } ?>
<?php // Example 1: Issue multiple tickets for connection parallelization $context = stream_context_create([ 'ssl' => [ 'local_cert' => '/path/to/cert.pem', 'num_tickets' => 4, // Issue 4 tickets per connection ] ]); $server = stream_socket_server('tls://0.0.0.0:8443', context: $context); // Example 2: Disable all resumption (maximum security) $context = stream_context_create([ 'ssl' => [ 'local_cert' => '/path/to/cert.pem', 'num_tickets' => 0, // No tickets = no resumption possible ] ]); $server = stream_socket_server('tls://0.0.0.0:8443', context: $context); ?>
<?php $redis = new Redis(); $redis->connect('127.0.0.1', 6379); $context = stream_context_create([ 'ssl' => [ 'local_cert' => '/path/to/cert.pem', 'local_pk' => '/path/to/key.pem', 'session_id_context' => 'myapp', 'session_new_cb' => function($stream, OpenSSLSession $session) use ($redis) { $key = 'tls_session:' . bin2hex($session->id); $redis->setex($key, 7200, $session->export()); }, 'session_get_cb' => function($stream, string $id) use ($redis): ?OpenSSLSession { $key = 'tls_session:' . bin2hex($id); $result = $redis->get($key); if ($result === false) { return null; } try { return OpenSSLSession::import($result); } catch (OpenSSLException $e) { return null; } }, 'session_remove_cb' => function($stream, string $id) use ($redis): void { $key = 'tls_session:' . bin2hex($id); $redis->del($key); }, ] ]); $server = stream_socket_server( 'tls://0.0.0.0:8443', $errno, $errstr, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $context ); // Now sessions are shared across PHP-FPM workers via Redis while ($conn = stream_socket_accept($server)) { // Handle connection } ?>
<?php // For high-security scenarios $context = stream_context_create([ 'ssl' => [ 'local_cert' => '/path/to/cert.pem', 'session_cache' => false, // No session resumption ] ]); $server = stream_socket_server('tls://0.0.0.0:8443', context: $context); ?>
When session is enabled there are some differences in behaviour that is good to keep in mind.
This RFC introduces no backward incompatible changes
Next PHP 8.5
No impact. The changes are isolated to the OpenSSL stream wrapper implementation in ext openssl.
No impact to other extensions. Changes are confined to ext openssl's stream transport implementation.
Positive impacts:
Documentation needs:
None. All design questions have been resolved.
This RFC lays the groundwork for future TLS 1.3 enhancements:
These features are intentionally excluded from this RFC to maintain focus and allow for iterative development.
This is a simple yes/no vote requiring a 2/3 majority.
This RFC introduces an extension-specific exception class following the
Throwable policy.
The primary proposal uses OpenSSLException for consistency with existing non-namespaced OpenSSL
symbols (OpenSSLCertificate, OpenSSLCertificateSigningRequest, OpenSSLAsymmetricKey).
However, the community may prefer a namespaced approach. This vote determines the final naming.
Simple majority vote, only counted if the primary vote passes.
The primary proposal uses OpenSSLSession for consistency with existing non-namespaced OpenSSL
symbols. However, as a new class with a proper method-based API (unlike the existing opaque resource
wrappers), it could be placed in the Openssl namespace following latest naming conventions.
This vote determines the final naming.
Simple majority vote, only counted if the primary vote passes.
Implementation PR: https://github.com/php/php-src/pull/20296
After the project is implemented, this section should contain: