Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/ChannelBinding/ChannelBindingType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

/**
* Copyright 2013-2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*
* @author Ralf Lang <[email protected]>
* @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=<type>`), 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';
}
80 changes: 80 additions & 0 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -45,7 +48,10 @@
/** @var resource|null */
protected $stream = null;

/** @var OpenSSLCertificate|resource|null Captured negotiated peer certificate. */
protected mixed $peerCertificate = null;

private ?EventDispatcherInterface $dispatcher;

Check failure on line 54 in src/Client.php

View workflow job for this annotation

GitHub Actions / CI

PHPStan level 5

Property Horde\Socket\Client\Client::$dispatcher has unknown class Psr\EventDispatcher\EventDispatcherInterface as its type. [class.notFound]

Check failure on line 54 in src/Client.php

View workflow job for this annotation

GitHub Actions / CI

PHPStan level 5

Property Horde\Socket\Client\Client::$dispatcher has unknown class Psr\EventDispatcher\EventDispatcherInterface as its type. [class.notFound]

Check failure on line 54 in src/Client.php

View workflow job for this annotation

GitHub Actions / CI

PHPStan level 5

Property Horde\Socket\Client\Client::$dispatcher has unknown class Psr\EventDispatcher\EventDispatcherInterface as its type. [class.notFound]

protected ConnectionConfig $config;

Expand All @@ -55,7 +61,7 @@
*/
public function __construct(
ConnectionConfig $config,
?EventDispatcherInterface $dispatcher = null,

Check failure on line 64 in src/Client.php

View workflow job for this annotation

GitHub Actions / CI

PHPStan level 5

Parameter $dispatcher of method Horde\Socket\Client\Client::__construct() has invalid type Psr\EventDispatcher\EventDispatcherInterface. [class.notFound]

Check failure on line 64 in src/Client.php

View workflow job for this annotation

GitHub Actions / CI

PHPStan level 5

Parameter $dispatcher of method Horde\Socket\Client\Client::__construct() has invalid type Psr\EventDispatcher\EventDispatcherInterface. [class.notFound]

Check failure on line 64 in src/Client.php

View workflow job for this annotation

GitHub Actions / CI

PHPStan level 5

Parameter $dispatcher of method Horde\Socket\Client\Client::__construct() has invalid type Psr\EventDispatcher\EventDispatcherInterface. [class.notFound]
) {
$this->config = $config;
$this->dispatcher = $dispatcher;
Expand Down Expand Up @@ -105,6 +111,7 @@

if ($result === true) {
$this->secure = true;
$this->capturePeerCertificate();
$this->emit(new TlsNegotiated(
'TLS negotiated',
['host' => $this->config->host, 'port' => $this->config->port],
Expand Down Expand Up @@ -134,6 +141,7 @@
$this->connected = false;
$this->secure = false;
$this->stream = null;
$this->peerCertificate = null;

$this->emit(new ConnectionClosed(
'Connection closed',
Expand All @@ -147,6 +155,46 @@
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();
Expand Down Expand Up @@ -248,6 +296,7 @@
'ssl' => [
'verify_peer' => $this->config->verifyPeer,
'verify_peer_name' => $this->config->verifyPeerName,
'capture_peer_cert' => true,
],
],
$this->config->context,
Expand Down Expand Up @@ -297,6 +346,10 @@
default => false,
};

if ($this->secure) {
$this->capturePeerCertificate();
}

$this->emit(new ConnectionEstablished(
'Connection established',
[
Expand Down Expand Up @@ -346,8 +399,35 @@
}
}

/**
* 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<string, mixed> $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);

Check failure on line 431 in src/Client.php

View workflow job for this annotation

GitHub Actions / CI

PHPStan level 5

Call to method dispatch() on an unknown class Psr\EventDispatcher\EventDispatcherInterface. [class.notFound]

Check failure on line 431 in src/Client.php

View workflow job for this annotation

GitHub Actions / CI

PHPStan level 5

Call to method dispatch() on an unknown class Psr\EventDispatcher\EventDispatcherInterface. [class.notFound]
}
}
23 changes: 23 additions & 0 deletions src/ClientInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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.
*
Expand Down
24 changes: 24 additions & 0 deletions src/Exception/ChannelBindingException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

/**
* Copyright 2013-2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*
* @author Ralf Lang <[email protected]>
* @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 {}
Loading
Loading