rfc:openssl_get_channel_binding

PHP RFC: openssl_get_channel_binding()

Introduction

This RFC proposes adding openssl_get_channel_binding(), a function in the OpenSSL extension that returns the TLS channel binding data for a connected TLS stream, together with the Openssl\ChannelBindingType enum that selects one of the three supported binding types.

Channel binding ties an application-layer authentication exchange to the underlying TLS connection. This prevents an attacker who can relay a TLS connection from relaying the authentication messages as well. SASL uses channel binding in the SCRAM-SHA-*-PLUS mechanisms defined by RFC 5802 and RFC 5801. PHP userland libraries already implement SCRAM, but they cannot implement the -PLUS variants because PHP exposes no way to obtain the binding material from a TLS stream.

<?php
 
$binding = openssl_get_channel_binding(
    $stream,
    Openssl\ChannelBindingType::TlsExporter,
);
 
// SCRAM transports the channel binding data as its base64-encoded "cb" property.
$cb = $binding === null ? null : base64_encode($binding);

The missing API affects userland libraries and applications such as PEAR Net_SMTP / Auth_SASL / Auth_SASL2, Roundcube, SnappyMail, SquirrelMail, Horde, and others that currently support only the non-PLUS SCRAM mechanisms. The original request is php-src issue #16766.

Proposal

Add the following function to ext/openssl, and the following enum to the existing Openssl namespace:

<?php
 
namespace Openssl {
    enum ChannelBindingType: string {
        case TlsUnique = 'tls-unique';
        case TlsServerEndPoint = 'tls-server-end-point';
        case TlsExporter = 'tls-exporter';
    }
}
 
namespace {
    /**
     * @param resource $stream
     */
    function openssl_get_channel_binding(
        $stream,
        Openssl\ChannelBindingType $channel_binding_type
    ): ?string {}
}

Parameters

  • $stream is a connected stream that uses an OpenSSL transport, such as ssl://, tls://, or a version-specific transport such as tlsv1.2://. The TLS handshake must have completed. The function does not accept a stream context, because a context can create multiple connections and the channel binding is a property of one specific connection.
  • $channel_binding_type selects the binding type. It is required; there is no default, because the correct type is determined by the application protocol and by the SASL mechanism being used.

The function may be called on either the client or the server stream. It does not perform a handshake itself.

Return value

The function returns the raw, binary channel binding data as a string. Callers that need the SASL cb property must base64-encode it. The function returns null when the requested type is not applicable to the connection. Currently this happens only for tls-unique over TLS 1.3, where that binding type is not defined.

Difference from the current pull request

The implementation in pull request #24035 currently accepts a string as the second parameter and raises ValueError for an unknown value. This RFC proposes an enum instead. The pull request will be updated to the signature above before the vote.

Supported channel binding types

Type Standard Value and applicability
tls-unique RFC 5929 section 3 The Finished message content of the relevant handshake. Defined for TLS 1.2 and earlier, but not for TLS 1.3. Returns null on TLS 1.3.
tls-server-end-point RFC 5929 section 4.1 A digest of the server certificate as it appears in the Certificate message. Applicable whenever a certificate is available.
tls-exporter RFC 9266 section 4, RFC 5705 32 octets from the TLS exporter, using the label EXPORTER-Channel-Binding and an empty context. Defined for TLS 1.3 and also available on earlier TLS versions when the exporter is available.

Semantics

tls-unique

The value is the Finished message content of the most recent handshake, not the TLS record containing it. In a full handshake the client sends the first Finished message; in a resumed handshake the server sends the first one. The implementation selects between the local and peer Finished message using the same rule as CPython's ssl.SSLSocket.get_channel_binding(), so both endpoints derive the same value.

The value can change after a TLS renegotiation. Applications using tls-unique must not renegotiate during authentication, as required by RFC 5929 section 3.1. TLS 1.3 does not use renegotiation and does not define tls-unique.

tls-server-end-point

The digest algorithm is taken from the certificate's own signature algorithm. If that algorithm is MD5 or SHA-1, or cannot be determined, SHA-256 is used instead, as required by RFC 5929 section 4.1. For a SHA-256-signed certificate the result is identical to openssl_x509_fingerprint($cert, “sha256”, true).

On the client side the peer certificate is used. On the server side the local certificate is used. If no certificate is available, the function throws an exception.

tls-exporter

The value is produced with OpenSSL's SSL_export_keying_material() using a 32-octet output, the label EXPORTER-Channel-Binding, and an empty context. This is the TLS 1.3 channel binding type from RFC 9266; it can also be computed on TLS 1.2 when OpenSSL supports the exporter for the connection.

Errors and exceptions

Condition Exception
$stream is not a stream resource TypeError
$channel_binding_type is not an Openssl\ChannelBindingType TypeError. A string such as “tls-unique” is not accepted.
$stream is a stream, but it has no active OpenSSL TLS transport Openssl\OpensslException with the message Stream does not have transport encryption enabled
An OpenSSL call fails while computing the value Openssl\OpensslException with the message Failed to get channel binding data: <OpenSSL error>

Because the second parameter is an enum, the function itself no longer has an unknown-type error. Code that obtains the name from configuration or from a protocol message can map it with Openssl\ChannelBindingType::from(), which raises ValueError for an unknown name, or with tryFrom() for a non-throwing variant. The backing values are the exact IANA names and, like those names, from() is case-sensitive.

Why an enum instead of a string

This is a closed set: PHP implements exactly three computations, and each corresponds to an RFC-defined type. A string parameter suggests that arbitrary IANA names, or names implemented by another TLS library, can be passed, which is not the case. The enum makes the supported set explicit.

  • A typo or a case mismatch becomes a type error at the call site instead of a runtime ValueError inside the function.
  • IDEs, static analysers, and language servers can offer completion and check the argument type.
  • The implementation no longer needs a hand-written string parser and its associated error message.
  • The backing values preserve interoperability: $type->value is the IANA name, so it can be forwarded to SASL or other protocol code unchanged.
  • Recent PHP APIs use enums for comparable closed sets, for example ReflectionProperty::hasHook(PropertyHookType $type), Dom\Element::insertAdjacentElement(AdjacentPosition $where, ...), and Io\Poll\Event.

The enum is the only accepted type; this RFC does not propose Openssl\ChannelBindingType|string. A union would preserve the typo problem and add a second code path. It would only be justified for backward compatibility, and this function has no prior released API.

The IANA registry also contains tls-unique-for-telnet. That type is specific to Telnet and is not used by SASL, so this RFC intentionally does not expose it. It can be added as a new enum case if a concrete use case appears.

Prior art

CPython's ssl.SSLSocket.get_channel_binding() returns the binding data as bytes, or None when no data is available (for example, tls-unique over TLS 1.3). It currently implements only tls-unique, selected by its IANA name string. This RFC keeps the same names as the enum backing values, but makes the parameter type-safe and adds the other two TLS types.

OpenSSL exposes the underlying primitives through SSL_get_finished(), SSL_get_peer_finished(), SSL_export_keying_material(), and its certificate digest functions. PHP does not expose these through the stream API, so applications currently need FFI or a custom extension to obtain the data.

Examples

Obtaining the binding data for SASL

<?php
 
$context = stream_context_create([
    'ssl' => [
        'verify_peer' => true,
        'verify_peer_name' => true,
    ],
]);
 
$stream = stream_socket_client(
    'tls://example.com:465',
    $errno,
    $errstr,
    30,
    STREAM_CLIENT_CONNECT,
    $context,
);
 
if ($stream === false) {
    throw new RuntimeException("Connection failed: $errstr");
}
 
$binding = openssl_get_channel_binding(
    $stream,
    Openssl\ChannelBindingType::TlsUnique,
);
 
if ($binding === null) {
    // tls-unique is not defined for TLS 1.3; use tls-exporter instead.
    $binding = openssl_get_channel_binding(
        $stream,
        Openssl\ChannelBindingType::TlsExporter,
    );
}
 
$cb = base64_encode($binding);

Inspecting all supported types

<?php
 
foreach (Openssl\ChannelBindingType::cases() as $type) {
    $binding = openssl_get_channel_binding($stream, $type);
 
    printf(
        "%s: %s\n",
        $type->value,
        $binding === null ? 'not applicable' : bin2hex($binding),
    );
}

Mapping a configured name to the enum

<?php
 
$configured = 'tls-server-end-point';
 
try {
    $type = Openssl\ChannelBindingType::from($configured);
} catch (ValueError $e) {
    throw new InvalidArgumentException(
        "Unsupported channel binding type: $configured",
        previous: $e,
    );
}
 
$binding = openssl_get_channel_binding($stream, $type);

Security considerations

Channel binding data is not a secret: RFC 5929 section 10 explicitly allows authentication mechanisms to disclose it. The value is nevertheless security-relevant, because it is what prevents a relay attack. Applications must use the same type that the negotiated SASL mechanism expects and must not treat a missing binding as an authenticated connection.

tls-exporter is the appropriate choice for TLS 1.3. tls-unique is only meaningful for TLS 1.2 and earlier. tls-server-end-point is useful when the application needs a value that does not change after renegotiation and when a server certificate is available.

Backward Incompatible Changes

  • The global function name openssl_get_channel_binding is new. Userland code that declares a function with the same name without a function_exists() guard would fail with “Cannot redeclare function openssl_get_channel_binding()”. No existing conflicts are known.
  • The class name Openssl\ChannelBindingType is new. The Openssl namespace is already used by ext/openssl for Openssl\OpensslException, Openssl\Psk, and Openssl\Session. Userland code that declares the same fully qualified class name would conflict when ext/openssl is loaded. No existing conflicts are known.
  • No existing functions, classes, constants, or INI settings are changed.

Proposed PHP Version(s)

Next minor version (PHP 8.7). This is a new feature and is not eligible for backporting to a stable branch under the release policy.

RFC Impact

To the Ecosystem

IDEs, language servers, and static analysers need to add the new function and enum to their stubs. Userland SASL libraries can use function_exists('openssl_get_channel_binding') to detect support and Openssl\ChannelBindingType::from() to map a negotiated or configured binding type. The function cannot be fully polyfilled in userland because the binding data is only available from the TLS implementation.

To Existing Extensions

Only ext/openssl is affected. No existing extension APIs or data structures change. The function is available only when ext/openssl is compiled and loaded.

To SAPIs

None. The CLI, the built-in web server, embedded PHP, and other SAPIs behave the same.

Open Issues

None at this time.

Future Scope

  • Support for tls-unique-for-telnet, if a concrete use case appears. It is not part of this proposal because it is Telnet-specific.
  • Support for TLS connections created by other extensions or by a future common TLS abstraction. This RFC intentionally implements the function in ext/openssl, because the current stream API does not expose the required TLS state in a backend-independent way.
  • Additional IANA channel binding types, if they are registered and applicable to PHP streams. New values can be added as enum cases.

Voting Choices

Primary Vote requiring a 2/3 majority to accept the RFC:

Add openssl_get_channel_binding() and Openssl\ChannelBindingType as outlined in the RFC?
Real name Yes No Abstain
Final result: 0 0 0
This poll has been closed.

Patches and Tests

The proof-of-concept implementation is pull request #24035. It contains PHPT tests for TLS 1.2 and TLS 1.3, including checks that the client and server derive the same tls-unique and tls-exporter values, checks of tls-server-end-point against the certificate fingerprint, and tests for the error conditions. The PR will be updated for the enum parameter.

The implementation has also been tested manually against Prosody and PostgreSQL.

Implementation

After the RFC is implemented, this section should contain:

  1. the version(s) it was merged into
  2. a link to the git commit(s)
  3. a link to the PHP manual entry for the feature

References

Rejected Features

  • String parameter. The current pull request accepts one of the strings “tls-unique”, “tls-server-end-point”, or “tls-exporter”. This RFC uses an enum instead, for the reasons described under “Why an enum instead of a string”.
  • Openssl\ChannelBindingType|string. A union would accept both forms, but it would reintroduce the possibility of a typo and require a second parsing path. It is only needed for backward compatibility, which does not apply to a new API.
  • Integer constants. Global constants such as OPENSSL_CHANNEL_BINDING_TLS_UNIQUE would be less discoverable and would not give the parameter a precise type.
  • Accepting a stream context. The original issue suggested accepting either a stream or a stream context. A context can create multiple connections, so it does not identify one channel. The function therefore requires a connected stream.
  • Returning an exception for an inapplicable type. tls-unique over TLS 1.3 is a normal protocol combination, not an error. Returning null lets callers select another type without catching an exception.
  • Supporting tls-unique-for-telnet. It is registered by IANA but is specific to Telnet and is not used by SASL.
  • Naming the function stream_get_channel_binding(). The implementation requires ext/openssl internals and cannot work for arbitrary streams, so the OpenSSL prefix is more accurate. This also follows the naming feedback on the pull request.
  • The spelling tls-server-endpoint. The IANA name is tls-server-end-point.

Changelog

  • 0.1 (2026-10-27): Initial draft.
rfc/openssl_get_channel_binding.txt · Last modified: by sjoerd