====== PHP RFC: TLS Session Resumption Support for Streams ======
* Version: 0.3
* Date: 2025-10-23
* Author: Jakub Zelenka, bukka@php.net
* Status: Under Discussion
* Implementation: https://github.com/php/php-src/pull/20296
===== Introduction =====
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:
* Save and restore client sessions across PHP requests
* Implement custom server-side session storage (Redis, Memcached, databases)
* Control session cache behavior and use internal session cache (due to not re-using SSL_CTX)
This proposal exposes OpenSSL's session management APIs through the existing stream context
options, providing fine-grained control while maintaining backward compatibility.
[
'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
?>
===== Proposal =====
==== OpenSSLException class ====
This RFC introduces a new ''OpenSSLException'' class as the base exception for the OpenSSL
extension. This follows the [[https://github.com/php/policies/blob/main/coding-standards-and-naming.rst#throwables|Throwable policy]]
which requires extensions to use extension-specific exception classes rather than the base
''\Exception''.
=== Class Definition ===
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|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 parsed
* ''OpenSSLSession::export()'' - when the session encoding fails (DER or PEM) or internal BIO allocation fails
* ''OpenSSLSession::__serialize()'' - when the session PEM encoding fails
* ''OpenSSLSession::__unserialize()'' - when the serialization data is invalid or the session cannot be restored
==== OpenSSLSession class ====
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.
=== Class Definition ===
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 {}
}
=== Construction ===
''OpenSSLSession'' cannot be directly instantiated. Instances are obtained through:
* The ''session_new_cb'' callback (receives an ''OpenSSLSession'' for a newly established session)
* ''OpenSSLSession::import()'' (restores a session from previously exported data)
* PHP's ''unserialize()'' (restores a session serialized with ''serialize()'')
=== Export/Import Formats ===
Two encoding formats are supported via the existing ''OPENSSL_ENCODING_DER'' and ''OPENSSL_ENCODING_PEM''
constants:
* **PEM** (default): Base64-encoded text format, human-readable and suitable for configuration files, text-based storage, and debugging
* **DER**: Compact binary format suitable for storage in databases, Redis, or binary-safe caches
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);
?>
=== Introspection ===
The class provides read-only access to session metadata for logging, debugging, and
cache management:
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";
}
};
?>
==== New Stream Context Options ====
This proposal adds the following SSL stream context options:
=== Client-Only Options ===
* **session_data** (?OpenSSLSession) - Instance of ''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.
* **session_new_cb** (callable) - Callback invoked when a new session is established.
* Signature: ''function(resource $stream, OpenSSLSession $session): void''
* ''$stream'': The SSL stream resource
* ''$session'': The ''OpenSSLSession'' object representing the new session
=== Server-Only Options ===
* **session_cache** (bool, default: true) - Enable or disable internal session caching on the server.
* **session_cache_size** (int, default: 20480) - Maximum number of sessions in the internal cache. Only applies when using OpenSSL's internal cache.
* **session_timeout** (int, default: 300) - Session lifetime in seconds.
* **session_id_context** (string) - Server identifier for session binding. Required when ''session_new_cb'' is set with peer verification enabled, and for the internal session cache with peer verification.
* **session_new_cb** (callable) - Callback invoked when a new session is created. Same signature as client version.
* **session_get_cb** (callable) - Callback to retrieve a session from external storage. Requires ''session_new_cb''.
* Signature: ''function(resource $stream, string $sessionId): ?OpenSSLSession''
* Must return an ''OpenSSLSession'' instance or null if not found.
* **session_remove_cb** (callable, optional) - Callback when a session is removed from cache.
* Signature: ''function(resource $stream, string $sessionId): void''
* **num_tickets** (int, default: 2, TLS 1.3 only) - Number of session tickets to issue after handshake.
* Set to 0 to disable all session resumption
* Higher values (e.g., 4) support connection parallelization
* **no_ticket** (bool) - Disable session ticket mechanism (TLS 1.2 Session Tickets / TLS 1.3 NewSessionTicket).
==== Behavior ====
=== Client Behavior ===
When a client stream is created:
- If ''session_data'' is provided as an ''OpenSSLSession'' instance, attempt to resume with that session
- If ''session_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 received
- If ''session_stream'' is provided (existing option), the SSL context and session are copied from the provided stream
- When ''session_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.
=== Server Behavior ===
When a server stream is created:
**Without External Cache** (no ''session_get_cb''):
* Uses OpenSSL's internal session cache
* ''session_cache'' controls whether caching is enabled
* ''session_cache_size'' and ''session_timeout'' configure the internal cache
* ''session_new_cb'' can be provided for notifications without external storage
**With External Cache** (''session_get_cb'' provided):
* Disables OpenSSL's internal cache (uses ''SSL_SESS_CACHE_NO_INTERNAL'')
* ''session_new_cb'' becomes **required** (ValueError if missing)
* Session tickets are disabled (''SSL_OP_NO_TICKET'' is set); attempting to explicitly enable tickets raises ValueError
* ''session_remove_cb'' is optional
**Session 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 used
* It binds sessions to a specific server context, preventing session reuse across different virtual hosts or applications
==== Error Handling ====
The following validation errors are raised:
* ''TypeError'' if ''session_new_cb'', ''session_get_cb'', or ''session_remove_cb'' is not a valid callable
* ''TypeError'' if ''session_data'' is not an ''OpenSSLSession'' instance
* ''TypeError'' if ''session_get_cb'' returns a value that is not ''OpenSSLSession'' or null
* ''ValueError'' if ''session_get_cb'' is provided without ''session_new_cb''
* ''ValueError'' if ''session_id_context'' is missing when required
* ''ValueError'' if ''session_cache_size'' or ''session_timeout'' is not positive
* ''ValueError'' if ''no_ticket'' is set to false when ''session_get_cb'' is set
* ''OpenSSLException'' if ''OpenSSLSession::import()'' fails to decode or parse the provided data
* ''OpenSSLException'' if ''OpenSSLSession::export()'' fails to encode the session (DER or PEM) or fails to allocate internal resources
* ''OpenSSLException'' if ''OpenSSLSession::__serialize()'' fails to encode the session
* ''OpenSSLException'' if ''OpenSSLSession::__unserialize()'' receives invalid data or fails to restore the session
* ''E_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)
==== Examples ====
=== Client: Simple Session Caching ===
[
'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')
);
?>
=== Client: Cross-Request Persistence ===
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);
?>
=== Server: Internal Cache (Default Behavior) ===
[
'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
}
?>
=== Server: Control Ticket Issuance (TLS 1.3) ===
[
'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);
?>
=== Server: External Cache (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
}
?>
=== Server: Disable Session Resumption ===
[
'local_cert' => '/path/to/cert.pem',
'session_cache' => false, // No session resumption
]
]);
$server = stream_socket_server('tls://0.0.0.0:8443', context: $context);
?>
===== Session mode differences =====
When session is enabled there are some differences in behaviour that is good to keep in mind.
* Applications that rely on every connection being a full handshake (rare) might observe different timing characteristics
* The SSL_CTX is shared which means that the server certificates are loaded earlier and are not reloaded on subsequent acceptence (this is only if session is used however)
===== Backward Incompatible Changes =====
This RFC introduces **no backward incompatible changes**
===== Proposed PHP Version(s) =====
Next PHP 8.5
===== RFC Impact =====
==== To SAPIs ====
No impact. The changes are isolated to the OpenSSL stream wrapper implementation in ext openssl.
==== To Existing Extensions ====
No impact to other extensions. Changes are confined to ext openssl's stream transport implementation.
==== To the Ecosystem ====
**Positive impacts:**
* **HTTP Clients**: Libraries like Guzzle, Symfony HttpClient can optimize performance with session reuse
* **Async Frameworks**: ReactPHP, Amp, Swoole can benefit from persistent session caching across connections
* **Microservices**: Service-to-service communication can see significant latency reduction
**Documentation needs:**
* PHP manual updates for stream context options
* Examples showing session resumption patterns
* Security considerations (forward secrecy implications)
===== Open Issues =====
None. All design questions have been resolved.
===== Future Scope =====
This RFC lays the groundwork for future TLS 1.3 enhancements:
* **0-RTT Early Data**: Building on session resumption to enable zero round-trip time data transmission
* **PSK (Pre-Shared Key)**: External PSK support for certificate-free authentication
* **Post-Handshake Authentication**: TLS 1.3 client certificate requests after initial handshake
* **Ticket Key Rotation**: Callbacks for server-side session ticket key management
These features are intentionally excluded from this RFC to maintain focus and allow for iterative development.
===== Voting Choices =====
==== Primary Vote ====
This is a simple yes/no vote requiring a 2/3 majority.
* Yes
* No
* Abstain
==== Secondary Vote: Exception Class Naming ====
This RFC introduces an extension-specific exception class following the
[[https://github.com/php/policies/blob/main/coding-standards-and-naming.rst#throwables|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.
* OpenSSLException (consistent with existing OpenSSL classes)
* Openssl\OpensslException (namespaced per Throwable policy recommendation)
* Abstain
==== Secondary Vote: Session Class Naming ====
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.
* OpenSSLSession (consistent with existing OpenSSL classes)
* Openssl\Session (namespaced per latest naming conventions)
* Abstain
===== Patches and Tests =====
Implementation PR: https://github.com/php/php-src/pull/20296
===== Implementation =====
After the project is implemented, this section should contain:
- the version it was merged into
- a link to the git commit(s)
- a link to the PHP manual entry for the feature
- a link to the language specification section (if any)
===== References =====
* [[https://www.rfc-editor.org/rfc/rfc8446.html#section-2.2|TLS 1.3 Resumption and PSK (RFC 8446)]]
* [[https://www.rfc-editor.org/rfc/rfc5246.html#section-7.3|TLS 1.2 Session Resumption (RFC 5246)]]
* [[https://www.openssl.org/docs/man3.0/man3/SSL_CTX_sess_set_new_cb.html|OpenSSL Session Callbacks Documentation]]
* [[https://www.openssl.org/docs/man3.0/man3/SSL_set_session.html|OpenSSL Session Management API]]
* [[https://github.com/php/policies/blob/main/coding-standards-and-naming.rst#throwables|PHP Throwable Policy]]
===== Changelog =====
* 0.3 2026-03-04: Introduced OpenSSLException class, added secondary votes for exception and session class naming
* 0.2 2026-02-13: Updated callback signatures to use OpenSSLSession, session_data and session_get_cb accept only OpenSSLSession and other changes
* 0.1 2025-12-22: Initial RFC published