From 97e3502c5199e69e721171cc20d00a12635bb64f Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Fri, 14 Aug 2026 17:31:36 +0200 Subject: [PATCH] feat: Support capturing the TLS peer certificate for Channel Binding --- src/ChannelBinding/ChannelBindingType.php | 54 ++++++++ src/Client.php | 80 +++++++++++ src/ClientInterface.php | 23 +++ src/Exception/ChannelBindingException.php | 24 ++++ test/unit/ChannelBindingTest.php | 162 ++++++++++++++++++++++ 5 files changed, 343 insertions(+) create mode 100644 src/ChannelBinding/ChannelBindingType.php create mode 100644 src/Exception/ChannelBindingException.php create mode 100644 test/unit/ChannelBindingTest.php diff --git a/src/ChannelBinding/ChannelBindingType.php b/src/ChannelBinding/ChannelBindingType.php new file mode 100644 index 0000000..28be834 --- /dev/null +++ b/src/ChannelBinding/ChannelBindingType.php @@ -0,0 +1,54 @@ + + * @copyright 2013-2026 The Horde Project + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Socket_Client + */ + +namespace Horde\Socket\Client\ChannelBinding; + +/** + * TLS channel-binding types (RFC 5929, RFC 9266). + * + * The backing value matches the binding name as it appears on the wire + * (e.g. the SCRAM-*-PLUS GS2 header `p=`), so consumers can map this + * enum 1:1 onto a protocol library's own channel-binding type without a + * hard dependency in either direction. + * + * Only `TlsServerEndPoint` is currently produceable: PHP's stream/openssl + * API exposes the negotiated peer certificate but no API for TLS exporter + * keying material or the Finished message, so `TlsExporter` and + * `TlsUnique` cannot be implemented today (see php/php-src#16766). + */ +enum ChannelBindingType: string +{ + /** + * RFC 5929. Hash of the server's TLS certificate (DER-encoded, hashed + * with the certificate's own signature digest, or SHA-256 if that + * digest is MD5/SHA-1 or unrecognized). Available whenever the peer + * certificate can be captured — works on both TLS 1.2 and 1.3. + */ + case TlsServerEndPoint = 'tls-server-end-point'; + + /** + * RFC 9266. TLS 1.3 exporter-derived binding. Not implementable: PHP + * streams do not expose an SSL_export_keying_material() equivalent. + */ + case TlsExporter = 'tls-exporter'; + + /** + * RFC 5929. Bound to the TLS Finished message. Not implementable: PHP + * streams do not expose the Finished message. Also broken under TLS 1.3 + * and unsafe before RFC 7627 — would not be recommended even if it were. + */ + case TlsUnique = 'tls-unique'; +} diff --git a/src/Client.php b/src/Client.php index 6422385..567e43c 100644 --- a/src/Client.php +++ b/src/Client.php @@ -19,12 +19,15 @@ use InvalidArgumentException; use LogicException; +use OpenSSLCertificate; use Psr\EventDispatcher\EventDispatcherInterface; +use Horde\Socket\Client\ChannelBinding\ChannelBindingType; use Horde\Socket\Client\Event\ConnectionClosed; use Horde\Socket\Client\Event\ConnectionEstablished; use Horde\Socket\Client\Event\ConnectionFailed; use Horde\Socket\Client\Event\TlsFailed; use Horde\Socket\Client\Event\TlsNegotiated; +use Horde\Socket\Client\Exception\ChannelBindingException; use Horde\Socket\Client\Exception\ConnectionException; use Horde\Socket\Client\Exception\StreamException; use Horde\Socket\Client\Exception\TimeoutException; @@ -45,6 +48,9 @@ class Client implements ClientInterface /** @var resource|null */ protected $stream = null; + /** @var OpenSSLCertificate|resource|null Captured negotiated peer certificate. */ + protected mixed $peerCertificate = null; + private ?EventDispatcherInterface $dispatcher; protected ConnectionConfig $config; @@ -105,6 +111,7 @@ public function startTls(): bool if ($result === true) { $this->secure = true; + $this->capturePeerCertificate(); $this->emit(new TlsNegotiated( 'TLS negotiated', ['host' => $this->config->host, 'port' => $this->config->port], @@ -134,6 +141,7 @@ public function close(): void $this->connected = false; $this->secure = false; $this->stream = null; + $this->peerCertificate = null; $this->emit(new ConnectionClosed( 'Connection closed', @@ -147,6 +155,46 @@ public function getStatus(): StreamStatus return StreamStatus::fromMetadata(stream_get_meta_data($this->stream)); } + public function supportsChannelBinding(ChannelBindingType $type): bool + { + return $type === ChannelBindingType::TlsServerEndPoint + && $this->secure + && $this->peerCertificate !== null; + } + + public function channelBindingData(ChannelBindingType $type): string + { + if ($type !== ChannelBindingType::TlsServerEndPoint) { + throw new ChannelBindingException(sprintf( + '%s channel binding is not supported: PHP\'s stream/openssl API' + . ' exposes no equivalent of SSL_export_keying_material() or' + . ' the TLS Finished message (see php/php-src#16766).', + $type->value, + )); + } + + if (!$this->secure || $this->peerCertificate === null) { + throw new ChannelBindingException( + 'No TLS peer certificate available for channel binding.' + . ' The connection must be secure and the server must have' + . ' presented a certificate.', + ); + } + + $parsed = openssl_x509_parse($this->peerCertificate); + if ($parsed === false) { + throw new ChannelBindingException('Unable to parse the peer certificate.'); + } + + $algorithm = $this->fingerprintAlgorithm($parsed); + $hash = openssl_x509_fingerprint($this->peerCertificate, $algorithm, true); + if ($hash === false) { + throw new ChannelBindingException('Unable to compute the certificate fingerprint.'); + } + + return $hash; + } + public function gets(int $size): string { $this->requireStream(); @@ -248,6 +296,7 @@ protected function connect(): void 'ssl' => [ 'verify_peer' => $this->config->verifyPeer, 'verify_peer_name' => $this->config->verifyPeerName, + 'capture_peer_cert' => true, ], ], $this->config->context, @@ -297,6 +346,10 @@ protected function connect(): void default => false, }; + if ($this->secure) { + $this->capturePeerCertificate(); + } + $this->emit(new ConnectionEstablished( 'Connection established', [ @@ -346,6 +399,33 @@ private function requireStream(): void } } + /** + * Capture the negotiated TLS peer certificate for channel binding. + */ + private function capturePeerCertificate(): void + { + $params = stream_context_get_params($this->stream); + $this->peerCertificate = $params['options']['ssl']['peer_certificate'] ?? null; + } + + /** + * The hash algorithm to fingerprint the peer certificate with, per + * RFC 5929 §4.1: use the certificate's own signature digest, unless + * that digest is MD5/SHA-1 (or unrecognized), in which case fall back + * to SHA-256. + * + * @param array $parsed Result of openssl_x509_parse(). + */ + private function fingerprintAlgorithm(array $parsed): string + { + $signatureType = $parsed['signatureTypeSN'] ?? $parsed['signatureTypeLN'] ?? ''; + if (is_string($signatureType) && preg_match('/sha(224|256|384|512)/i', $signatureType, $matches) === 1) { + return 'sha' . $matches[1]; + } + + return 'sha256'; + } + private function emit(object $event): void { $this->dispatcher?->dispatch($event); diff --git a/src/ClientInterface.php b/src/ClientInterface.php index 790d091..17f8fee 100644 --- a/src/ClientInterface.php +++ b/src/ClientInterface.php @@ -17,6 +17,9 @@ namespace Horde\Socket\Client; +use Horde\Socket\Client\ChannelBinding\ChannelBindingType; +use Horde\Socket\Client\Exception\ChannelBindingException; + /** * Interface for a network socket client. */ @@ -26,6 +29,26 @@ public function isConnected(): bool; public function isSecure(): bool; + /** + * Whether the live connection can currently produce the given + * TLS channel-binding type (RFC 5929 / RFC 9266). + */ + public function supportsChannelBinding(ChannelBindingType $type): bool; + + /** + * The TLS channel-binding data for the given type. + * + * Intended to be handed to a SASL library's channel-binding provider + * seam (e.g. `Horde\Sasl\ChannelBinding\ChannelBindingProvider`) for the + * SCRAM-*-PLUS family of mechanisms. + * + * @throws ChannelBindingException If the connection isn't secure, no + * peer certificate was captured, or the + * type cannot be produced by PHP's + * stream/openssl API. + */ + public function channelBindingData(ChannelBindingType $type): string; + /** * Upgrade an existing plaintext connection to TLS. * diff --git a/src/Exception/ChannelBindingException.php b/src/Exception/ChannelBindingException.php new file mode 100644 index 0000000..eee92bb --- /dev/null +++ b/src/Exception/ChannelBindingException.php @@ -0,0 +1,24 @@ + + * @copyright 2013-2026 The Horde Project + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Socket_Client + */ + +namespace Horde\Socket\Client\Exception; + +/** + * Thrown when TLS channel-binding data cannot be produced: the connection + * isn't secure, no peer certificate was captured, or the requested binding + * type is not implementable with PHP's stream/openssl API. + */ +class ChannelBindingException extends SocketException {} diff --git a/test/unit/ChannelBindingTest.php b/test/unit/ChannelBindingTest.php new file mode 100644 index 0000000..fd3f73e --- /dev/null +++ b/test/unit/ChannelBindingTest.php @@ -0,0 +1,162 @@ +markTestSkipped('ext-openssl is required for channel-binding tests.'); + } + + $config = new ConnectionConfig(host: 'localhost', port: 993, secure: SecureMode::Ssl); + $this->client = new TestableModernClient($config); + } + + protected function tearDown(): void + { + if ($this->client->isConnected()) { + $this->client->close(); + } + } + + public function testSupportsChannelBindingFalseWithoutCertificate(): void + { + $this->assertFalse( + $this->client->supportsChannelBinding(ChannelBindingType::TlsServerEndPoint), + ); + } + + public function testChannelBindingDataThrowsWithoutCertificate(): void + { + $this->expectException(ChannelBindingException::class); + $this->client->channelBindingData(ChannelBindingType::TlsServerEndPoint); + } + + public function testChannelBindingDataThrowsWhenNotSecure(): void + { + $config = new ConnectionConfig(host: 'localhost', port: 143); + $client = new TestableModernClient($config); + $this->injectCertificate($client, $this->generateCertificate('sha256')); + + $this->expectException(ChannelBindingException::class); + $client->channelBindingData(ChannelBindingType::TlsServerEndPoint); + } + + public function testTlsExporterThrowsUnsupported(): void + { + $this->injectCertificate($this->client, $this->generateCertificate('sha256')); + + $this->expectException(ChannelBindingException::class); + $this->expectExceptionMessageMatches('/not supported/'); + $this->client->channelBindingData(ChannelBindingType::TlsExporter); + } + + public function testTlsUniqueThrowsUnsupported(): void + { + $this->injectCertificate($this->client, $this->generateCertificate('sha256')); + + $this->expectException(ChannelBindingException::class); + $this->client->channelBindingData(ChannelBindingType::TlsUnique); + } + + public function testSupportsChannelBindingTrueWithSha256Certificate(): void + { + $this->injectCertificate($this->client, $this->generateCertificate('sha256')); + + $this->assertTrue( + $this->client->supportsChannelBinding(ChannelBindingType::TlsServerEndPoint), + ); + } + + public function testChannelBindingDataMatchesSha256Fingerprint(): void + { + $cert = $this->generateCertificate('sha256'); + $this->injectCertificate($this->client, $cert); + + $expected = openssl_x509_fingerprint($cert, 'sha256', true); + $this->assertSame( + $expected, + $this->client->channelBindingData(ChannelBindingType::TlsServerEndPoint), + ); + } + + public function testChannelBindingDataMatchesSha384Fingerprint(): void + { + $cert = $this->generateCertificate('sha384'); + $this->injectCertificate($this->client, $cert); + + $expected = openssl_x509_fingerprint($cert, 'sha384', true); + $this->assertSame( + $expected, + $this->client->channelBindingData(ChannelBindingType::TlsServerEndPoint), + ); + } + + public function testChannelBindingDataFallsBackToSha256ForSha1Signature(): void + { + // RFC 5929 sec. 4.1: MD5/SHA-1 signed certificates fall back to SHA-256. + $cert = $this->generateCertificate('sha1'); + $this->injectCertificate($this->client, $cert); + + $expected = openssl_x509_fingerprint($cert, 'sha256', true); + $this->assertSame( + $expected, + $this->client->channelBindingData(ChannelBindingType::TlsServerEndPoint), + ); + } + + /** + * @return OpenSSLCertificate|resource + */ + private function generateCertificate(string $digestAlgo) + { + $config = [ + 'digest_alg' => $digestAlgo, + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]; + $privateKey = openssl_pkey_new($config); + $csr = openssl_csr_new(['commonName' => 'horde-socket-client-test'], $privateKey, $config); + + return openssl_csr_sign($csr, null, $privateKey, 365, $config); + } + + /** + * @param OpenSSLCertificate|resource $certificate + */ + private function injectCertificate(Client $client, $certificate): void + { + $property = new ReflectionProperty(Client::class, 'peerCertificate'); + $property->setValue($client, $certificate); + } +}