diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43e3b8db..c3945788 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,7 @@ jobs: name: PHPUnit (PHP ${{ matrix.php }} on ${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: - ubuntu-22.04 diff --git a/README.md b/README.md index 577a2589..77f7804d 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ handle multiple concurrent connections without blocking. * [ConnectionInterface](#connectioninterface) * [getRemoteAddress()](#getremoteaddress) * [getLocalAddress()](#getlocaladdress) + * [OpportunisticTlsConnectionInterface](#opportunistictlsconnectioninterface) + * [enableEncryption()](#enableencryption) * [Server usage](#server-usage) * [ServerInterface](#serverinterface) * [connection event](#connection-event) @@ -193,6 +195,62 @@ If your system has multiple interfaces (e.g. a WAN and a LAN interface), you can use this method to find out which interface was actually used for this connection. +### OpportunisticTlsConnectionInterface + +The `OpportunisticTlsConnectionInterface` extends the [`ConnectionInterface`](#connectioninterface) and adds the ability of +enabling the TLS encryption on the connection when desired. + +#### enableEncryption + +When negotiated with the server when to start encrypting traffic using TLS you enable it by calling +`enableEncryption()` which returns a promise that resolve with a `OpportunisticTlsConnectionInterface` connection but now all +traffic back and forth will be encrypted. + +In the following example we ask the server if they want to encrypt the connection, and when it responds with `yes` we +enable the encryption: + +```php +$connector = new React\Socket\Connector(); +$connector->connect('opportunistic+tls://example.com:5432/')->then(function (React\Socket\OpportunisticTlsConnectionInterface $startTlsConnection) { + $connection->write('let\'s encrypt?'); + + return React\Promise\Stream\first($connection)->then(function ($data) use ($connection) { + if ($data === 'yes') { + return $connection->enableEncryption(); + } + + return $stream; + }); +})->then(function (React\Socket\ConnectionInterface $connection) { + $connection->write('Hello!'); +}); +``` + +The `enableEncryption` function resolves with itself. As such you can't see the data encrypted when you hook into the +events before enabling, as shown below: + +```php +$connector = new React\Socket\Connector(); +$connector->connect('opportunistic+tls://example.com:5432/')->then(function (React\Socket\OpportunisticTlsConnectionInterface $startTlsConnection) { + $connection->on('data', function ($data) { + echo 'Raw: ', $data, PHP_EOL; + }); + + return $connection->enableEncryption(); +})->then(function (React\Socket\ConnectionInterface $connection) { + $connection->on('data', function ($data) { + echo 'TLS: ', $data, PHP_EOL; + }); +}); +``` + +When the other side send `Hello World!` over the encrypted connection, the output will be the following: + +``` +Raw: Hello World! +TLS: Hello World! +``` + ## Server usage ### ServerInterface @@ -253,10 +311,10 @@ If the address can not be determined or is unknown at this time (such as after the socket has been closed), it MAY return a `NULL` value instead. Otherwise, it will return the full address (URI) as a string value, such -as `tcp://127.0.0.1:8080`, `tcp://[::1]:80`, `tls://127.0.0.1:443` -`unix://example.sock` or `unix:///path/to/example.sock`. -Note that individual URI components are application specific and depend -on the underlying transport protocol. +as `tcp://127.0.0.1:8080`, `tcp://[::1]:80`, `tls://127.0.0.1:443`, +`unix://example.sock`, `unix:///path/to/example.sock`, or +`opportunistic+tls://127.0.0.1:443`. Note that individual URI components +are application specific and depend on the underlying transport protocol. If this is a TCP/IP based server and you only want the local port, you may use something like this: @@ -478,6 +536,22 @@ $socket = new React\Socket\SocketServer('tls://127.0.0.1:8000', array( )); ``` +To start a server with opportunistic TLS support use `opportunistic+tls://` as the scheme instead of `tls://`: + +```php +$socket = new React\Socket\SocketServer('opportunistic+tls://127.0.0.1:8000', array( + 'tls' => array( + 'local_cert' => 'server.pem', + 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_SERVER + ) +)); +$server->on('connection', static function (OpportunisticTlsConnectionInterface $connection) use ($server) { + return $connection->enableEncryption(); +}); +``` + +See also the [examples](examples). + > Note that available [TLS context options](https://www.php.net/manual/en/context.ssl.php), their defaults and effects of changing these may vary depending on your system and/or PHP version. @@ -697,6 +771,21 @@ here in order to use the [default loop](https://github.com/reactphp/event-loop#l This value SHOULD NOT be given unless you're sure you want to explicitly use a given event loop instance. +Opportunistic TLS is supported by the secure server by passing true in as 4th constructor +parameter. This, when a client connects, emits a +[`OpportunisticTlsConnectionInterface`](#opportunistictlsconnectioninterface) instead +of the default [`ConnectionInterface`](#connectioninterface) and won't be TLS encrypted +from the start so you can enable the TLS encryption on the connection after negotiating +with the client. + +```php +$server = new React\Socket\TcpServer(8000); +$server = new React\Socket\SecureServer($server, null, array( + 'local_cert' => 'server.pem', + 'passphrase' => 'secret' +), true); +``` + > Advanced usage: Despite allowing any `ServerInterface` as first parameter, you SHOULD pass a `TcpServer` instance as first parameter, unless you know what you're doing. @@ -1389,6 +1478,20 @@ $secureConnector = new React\Socket\SecureConnector($dnsConnector, null, array( )); ``` +Opportunistic TLS is supported by the secure connector by using `opportunistic-tls://` as scheme instead of `tls://`. This, when +connected, returns a [`OpportunisticTlsConnectionInterface`](#opportunistictlsconnectioninterface) instead of the default +[`ConnectionInterface`](#connectioninterface) and won't be TLS encrypted from the start so you can enable the TLS +encryption on the connection after negotiating with the server. + +```php +$secureConnector = new React\Socket\SecureConnector($dnsConnector); +$secureConnector->connect('opportunistic-tls://example.com:5432')->then(function (OpportunisticTlsConnectionInterface $connection) { + return $connection->enableEncryption(); +})->then(function (OpportunisticTlsConnectionInterface $connection) { + $connection->write('Encrypted hi!'); +}); +``` + > Advanced usage: Internally, the `SecureConnector` relies on setting up the required *context options* on the underlying stream resource. It should therefor be used with a `TcpConnector` somewhere in the connector diff --git a/examples/31-opportunistic-tls.php b/examples/31-opportunistic-tls.php new file mode 100644 index 00000000..5420e09e --- /dev/null +++ b/examples/31-opportunistic-tls.php @@ -0,0 +1,68 @@ + array( + 'local_cert' => __DIR__ . '/localhost.pem', + ) +)); +$server->on('connection', static function (OpportunisticTlsConnectionInterface $connection) use ($server) { + $server->close(); + + $connection->on('data', function ($data) { + echo 'From Client: ', $data, PHP_EOL; + }); + React\Promise\Stream\first($connection)->then(function ($data) use ($connection) { + if ($data === 'Let\'s encrypt?') { + $connection->write('yes'); + return $connection->enableEncryption(); + } + + return $connection; + })->then(static function (ConnectionInterface $connection) { + $connection->write('Encryption enabled!'); + })->done(); +}); + +$client = new Connector(array( + 'tls' => array( + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, + ), +)); +$client->connect($server->getAddress())->then(static function (OpportunisticTlsConnectionInterface $connection) { + $connection->on('data', function ($data) { + echo 'From Server: ', $data, PHP_EOL; + }); + $connection->write('Let\'s encrypt?'); + + return React\Promise\Stream\first($connection)->then(function ($data) use ($connection) { + if ($data === 'yes') { + return $connection->enableEncryption(); + } + + return $connection; + }); +})->then(function (ConnectionInterface $connection) { + $connection->write('Encryption enabled!'); + Loop::addTimer(1, static function () use ($connection) { + $connection->end('Cool! Bye!'); + }); +})->done(); diff --git a/src/Connector.php b/src/Connector.php index 93477bd7..ec90f362 100644 --- a/src/Connector.php +++ b/src/Connector.php @@ -75,6 +75,7 @@ public function __construct($context = array(), $loop = null) 'dns' => true, 'timeout' => true, 'happy_eyeballs' => true, + 'opportunistic+tls' => true, ); if ($context['timeout'] === true) { @@ -150,6 +151,9 @@ public function __construct($context = array(), $loop = null) } $this->connectors['tls'] = $context['tls']; + if ($context['opportunistic+tls'] !== false) { + $this->connectors['opportunistic+tls'] = $this->connectors['tls']; + } } if ($context['unix'] !== false) { diff --git a/src/OpportunisticTlsConnection.php b/src/OpportunisticTlsConnection.php new file mode 100644 index 00000000..b1ee2eb6 --- /dev/null +++ b/src/OpportunisticTlsConnection.php @@ -0,0 +1,109 @@ +connection = $connection; + $this->streamEncryption = $streamEncryption; + $this->uri = $uri; + + Util::forwardEvents($connection, $this, array('data', 'end', 'error', 'close')); + } + + public function getRemoteAddress() + { + return $this->connection->getRemoteAddress(); + } + + public function getLocalAddress() + { + return $this->connection->getLocalAddress(); + } + + public function isReadable() + { + return $this->connection->isReadable(); + } + + public function pause() + { + $this->connection->pause(); + } + + public function resume() + { + $this->connection->resume(); + } + + public function pipe(WritableStreamInterface $dest, array $options = array()) + { + return $this->connection->pipe($dest, $options); + } + + public function close() + { + $this->connection->close(); + } + + public function enableEncryption() + { + $that = $this; + $connection = $this->connection; + $uri = $this->uri; + + return $this->streamEncryption->enable($connection)->then(function () use ($that) { + return $that; + }, function ($error) use ($connection, $uri) { + // establishing encryption failed => close invalid connection and return error + $connection->close(); + + throw new \RuntimeException( + 'Connection to ' . $uri . ' failed during TLS handshake: ' . $error->getMessage(), + $error->getCode() + ); + }); + } + + public function isWritable() + { + return $this->connection->isWritable(); + } + + public function write($data) + { + return $this->connection->write($data); + } + + public function end($data = null) + { + $this->connection->end($data); + } +} diff --git a/src/OpportunisticTlsConnectionInterface.php b/src/OpportunisticTlsConnectionInterface.php new file mode 100644 index 00000000..230edf40 --- /dev/null +++ b/src/OpportunisticTlsConnectionInterface.php @@ -0,0 +1,18 @@ + + */ + public function enableEncryption(); +} diff --git a/src/SecureConnector.php b/src/SecureConnector.php index a5087ca0..f025ff00 100644 --- a/src/SecureConnector.php +++ b/src/SecureConnector.php @@ -33,7 +33,7 @@ public function connect($uri) } $parts = \parse_url($uri); - if (!$parts || !isset($parts['scheme']) || $parts['scheme'] !== 'tls') { + if (!$parts || !isset($parts['scheme']) || ($parts['scheme'] !== 'tls' && $parts['scheme'] !== 'opportunistic+tls')) { return Promise\reject(new \InvalidArgumentException( 'Given URI "' . $uri . '" is invalid (EINVAL)', \defined('SOCKET_EINVAL') ? \SOCKET_EINVAL : 22 @@ -42,11 +42,12 @@ public function connect($uri) $context = $this->context; $encryption = $this->streamEncryption; + $opportunisticTls = $parts['scheme'] === 'opportunistic+tls'; $connected = false; /** @var \React\Promise\PromiseInterface $promise */ $promise = $this->connector->connect( - \str_replace('tls://', '', $uri) - )->then(function (ConnectionInterface $connection) use ($context, $encryption, $uri, &$promise, &$connected) { + \str_replace(array('opportunistic+tls://', 'tls://'), '', $uri) + )->then(function (ConnectionInterface $connection) use ($context, $encryption, $opportunisticTls, $uri, &$promise, &$connected) { // (unencrypted) TCP/IP connection succeeded $connected = true; @@ -60,6 +61,10 @@ public function connect($uri) \stream_context_set_option($connection->stream, 'ssl', $name, $value); } + if ($opportunisticTls === true) { + return new OpportunisticTlsConnection($connection, $encryption, $uri); + } + // try to enable encryption return $promise = $encryption->enable($connection)->then(null, function ($error) use ($connection, $uri) { // establishing encryption failed => close invalid connection and return error diff --git a/src/SecureServer.php b/src/SecureServer.php index d0525c94..9b92cd61 100644 --- a/src/SecureServer.php +++ b/src/SecureServer.php @@ -57,6 +57,7 @@ final class SecureServer extends EventEmitter implements ServerInterface private $tcp; private $encryption; private $context; + private $opportunisticTls = false; /** * Creates a secure TLS server and starts waiting for incoming connections @@ -122,12 +123,14 @@ final class SecureServer extends EventEmitter implements ServerInterface * @see TcpServer * @link https://www.php.net/manual/en/context.ssl.php for TLS context options */ - public function __construct(ServerInterface $tcp, LoopInterface $loop = null, array $context = array()) + public function __construct(ServerInterface $tcp, LoopInterface $loop = null, array $context = array(), $opportunisticTls = false) { if (!\function_exists('stream_socket_enable_crypto')) { throw new \BadMethodCallException('Encryption not supported on your platform (HHVM < 3.8?)'); // @codeCoverageIgnore } + $this->opportunisticTls = $opportunisticTls; + // default to empty passphrase to suppress blocking passphrase prompt $context += array( 'passphrase' => '' @@ -153,6 +156,10 @@ public function getAddress() return null; } + if ($this->opportunisticTls) { + $address = 'opportunistic+' . $address; + } + return \str_replace('tcp://' , 'tls://', $address); } @@ -188,6 +195,12 @@ public function handleConnection(ConnectionInterface $connection) $remote = $connection->getRemoteAddress(); $that = $this; + if ($this->opportunisticTls === true) { + $connection = new OpportunisticTlsConnection($connection, $this->encryption, $remote); + $that->emit('connection', array($connection)); + return ; + } + $this->encryption->enable($connection)->then( function ($conn) use ($that) { $that->emit('connection', array($conn)); diff --git a/src/SocketServer.php b/src/SocketServer.php index 2fd43c4c..8faad160 100644 --- a/src/SocketServer.php +++ b/src/SocketServer.php @@ -58,11 +58,14 @@ public function __construct($uri, array $context = array(), LoopInterface $loop ); } - $server = new TcpServer(str_replace('tls://', '', $uri), $loop, $context['tcp']); + $server = new TcpServer(str_replace(array('opportunistic+tls://', 'tls://'), '', $uri), $loop, $context['tcp']); if ($scheme === 'tls') { $server = new SecureServer($server, $loop, $context['tls']); } + if ($scheme === 'opportunistic+tls') { + $server = new SecureServer($server, $loop, $context['tls'], true); + } } $this->server = $server; diff --git a/tests/FunctionalOpportunisticTLSTest.php b/tests/FunctionalOpportunisticTLSTest.php new file mode 100644 index 00000000..abbb575a --- /dev/null +++ b/tests/FunctionalOpportunisticTLSTest.php @@ -0,0 +1,121 @@ +markTestSkipped('Not supported on legacy HHVM'); + } + } + + public function testNegotiatedLSSSuccessful() + { + $expectCallableNever = $this->expectCallableNever(); + $messagesExpected = array( + 'client' => array( + 'Let\'s encrypt?', + 'Encryption enabled!', + 'Cool! Bye!', + ), + 'server' => array( + 'yes', + 'Encryption enabled!', + ), + ); + $messages = array( + 'client' => array(), + 'server' => array(), + ); + $server = new SocketServer('opportunistic+tls://127.0.0.1:0', array( + 'tls' => array( + 'local_cert' => dirname(__DIR__) . '/examples/localhost.pem', + ) + )); + $server->on('connection', function (OpportunisticTlsConnectionInterface $connection) use ($expectCallableNever, $server, &$messages) { + $server->close(); + + $connection->on('data', function ($data) use (&$messages) { + $messages['client'][] = $data; + }); + Stream\first($connection)->then(function ($data) use ($connection) { + if ($data === 'Let\'s encrypt?') { + $connection->write('yes'); + return $connection->enableEncryption(); + } + + return $connection; + })->then(function (ConnectionInterface $connection) { + $connection->write('Encryption enabled!'); + })->then(null, $expectCallableNever); + }); + + $client = new Connector(array( + 'tls' => array( + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, + ), + )); + $client->connect($server->getAddress())->then(function (OpportunisticTlsConnectionInterface $connection) use (&$messages) { + $connection->on('data', function ($data) use (&$messages) { + $messages['server'][] = $data; + }); + $connection->write('Let\'s encrypt?'); + + return Stream\first($connection)->then(function ($data) use ($connection) { + if ($data === 'yes') { + return $connection->enableEncryption(); + } + + return $connection; + }); + })->then(function (ConnectionInterface $connection) { + $connection->write('Encryption enabled!'); + Loop::addTimer(1, function () use ($connection) { + $connection->end('Cool! Bye!'); + }); + })->then(null, $expectCallableNever); + + Loop::run(); + + self::assertSame($messagesExpected, $messages); + } + + public function testNegotiatedTLSUnsuccessful() + { + $this->setExpectedException('RuntimeException'); + + $server = new SocketServer('opportunistic+tls://127.0.0.1:0', array( + 'tls' => array( + 'local_cert' => dirname(__DIR__) . '/examples/localhost.pem', + ) + )); + $server->on('connection', function (ConnectionInterface $connection) use ($server) { + $server->close(); + $connection->write('Hi!'); + $connection->enableEncryption(); + }); + + $client = new Connector(); + \React\Async\await($client->connect($server->getAddress())->then(function (OpportunisticTlsConnectionInterface $connection) use (&$messages) { + $connection->write('Hi!'); + return $connection->enableEncryption(); + })); + } +} diff --git a/tests/OpportunisticTlsConnectionTest.php b/tests/OpportunisticTlsConnectionTest.php new file mode 100644 index 00000000..17c9e0f2 --- /dev/null +++ b/tests/OpportunisticTlsConnectionTest.php @@ -0,0 +1,74 @@ +getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->getMock(); + $underlyingConnection->expects($this->once())->method('getRemoteAddress')->willReturn('[::1]:13'); + + $connection = new OpportunisticTlsConnection($underlyingConnection, new StreamEncryption(Loop::get(), false), ''); + $this->assertSame('[::1]:13', $connection->getRemoteAddress()); + } + + public function testGetLocalAddressWillForwardCallToUnderlyingConnection() + { + $underlyingConnection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->getMock(); + $underlyingConnection->expects($this->once())->method('getLocalAddress')->willReturn('[::1]:13'); + + $connection = new OpportunisticTlsConnection($underlyingConnection, new StreamEncryption(Loop::get(), false), ''); + $this->assertSame('[::1]:13', $connection->getLocalAddress()); + } + + public function testPauseWillForwardCallToUnderlyingConnection() + { + $underlyingConnection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->getMock(); + $underlyingConnection->expects($this->once())->method('pause'); + + $connection = new OpportunisticTlsConnection($underlyingConnection, new StreamEncryption(Loop::get(), false), ''); + $connection->pause(); + } + + public function testResumeWillForwardCallToUnderlyingConnection() + { + $underlyingConnection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->getMock(); + $underlyingConnection->expects($this->once())->method('resume'); + + $connection = new OpportunisticTlsConnection($underlyingConnection, new StreamEncryption(Loop::get(), false), ''); + $connection->resume(); + } + + public function testPipeWillForwardCallToUnderlyingConnection() + { + $underlyingConnection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->getMock(); + $underlyingConnection->expects($this->once())->method('pipe'); + + $connection = new OpportunisticTlsConnection($underlyingConnection, new StreamEncryption(Loop::get(), false), ''); + $connection->pipe($underlyingConnection); + } + + public function testCloseWillForwardCallToUnderlyingConnection() + { + $underlyingConnection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->getMock(); + $underlyingConnection->expects($this->once())->method('close'); + + $connection = new OpportunisticTlsConnection($underlyingConnection, new StreamEncryption(Loop::get(), false), ''); + $connection->close(); + } + + public function testIsWritableWillForwardCallToUnderlyingConnection() + { + $underlyingConnection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->getMock(); + $underlyingConnection->expects($this->once())->method('isWritable')->willReturn(true); + + $connection = new OpportunisticTlsConnection($underlyingConnection, new StreamEncryption(Loop::get(), false), ''); + $this->assertTrue($connection->isWritable()); + } +}