From 369027551e6622aabe2a60b7dc69bd720d7c24b2 Mon Sep 17 00:00:00 2001 From: azjezz Date: Sun, 24 Mar 2024 09:08:31 +0000 Subject: [PATCH] feat(tcp): support TLS/SSL Signed-off-by: azjezz --- composer.json | 1 + docs/README.md | 1 + docs/component/tcp-tls.md | 21 + docs/component/tcp.md | 2 +- docs/documenter.php | 1 + examples/tcp/basic-http-client.php | 43 +- foo.php | 24 + src/Psl/Internal/Loader.php | 7 + src/Psl/Network/Internal/socket_connect.php | 17 +- src/Psl/TCP/ConnectOptions.php | 77 ++- src/Psl/TCP/TLS/Certificate.php | 47 ++ src/Psl/TCP/TLS/ConnectOptions.php | 518 ++++++++++++++++++ .../TCP/TLS/Exception/ExceptionInterface.php | 11 + .../TLS/Exception/NegotiationException.php | 11 + src/Psl/TCP/TLS/HashingAlgorithm.php | 59 ++ src/Psl/TCP/TLS/SecurityLevel.php | 78 +++ src/Psl/TCP/TLS/Version.php | 62 +++ src/Psl/TCP/connect.php | 164 +++++- 18 files changed, 1111 insertions(+), 33 deletions(-) create mode 100644 docs/component/tcp-tls.md create mode 100644 foo.php create mode 100644 src/Psl/TCP/TLS/Certificate.php create mode 100644 src/Psl/TCP/TLS/ConnectOptions.php create mode 100644 src/Psl/TCP/TLS/Exception/ExceptionInterface.php create mode 100644 src/Psl/TCP/TLS/Exception/NegotiationException.php create mode 100644 src/Psl/TCP/TLS/HashingAlgorithm.php create mode 100644 src/Psl/TCP/TLS/SecurityLevel.php create mode 100644 src/Psl/TCP/TLS/Version.php diff --git a/composer.json b/composer.json index b2381c04..6740c895 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ "ext-mbstring": "*", "ext-sodium": "*", "ext-intl": "*", + "ext-openssl": "*", "revolt/event-loop": "^1.0.1" }, "require-dev": { diff --git a/docs/README.md b/docs/README.md index 2dc967f7..cf2367c5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -52,6 +52,7 @@ - [Psl\Str\Byte](./component/str-byte.md) - [Psl\Str\Grapheme](./component/str-grapheme.md) - [Psl\TCP](./component/tcp.md) +- [Psl\TCP\TLS](./component/tcp-tls.md) - [Psl\Trait](./component/trait.md) - [Psl\Type](./component/type.md) - [Psl\Unix](./component/unix.md) diff --git a/docs/component/tcp-tls.md b/docs/component/tcp-tls.md new file mode 100644 index 00000000..af517c16 --- /dev/null +++ b/docs/component/tcp-tls.md @@ -0,0 +1,21 @@ + + +[*index](./../README.md) + +--- + +### `Psl\TCP\TLS` Component + +#### `Classes` + +- [Certificate](./../../src/Psl/TCP/TLS/Certificate.php#L16) +- [ConnectOptions](./../../src/Psl/TCP/TLS/ConnectOptions.php#L34) +- [HashingAlgorithm](./../../src/Psl/TCP/TLS/HashingAlgorithm.php#L15) +- [SecurityLevel](./../../src/Psl/TCP/TLS/SecurityLevel.php#L18) +- [Version](./../../src/Psl/TCP/TLS/Version.php#L10) + + diff --git a/docs/component/tcp.md b/docs/component/tcp.md index 8c208bab..ef73d325 100644 --- a/docs/component/tcp.md +++ b/docs/component/tcp.md @@ -12,7 +12,7 @@ #### `Functions` -- [connect](./../../src/Psl/TCP/connect.php#L18) +- [connect](./../../src/Psl/TCP/connect.php#L37) #### `Classes` diff --git a/docs/documenter.php b/docs/documenter.php index 1fd92dd4..41df3ee1 100644 --- a/docs/documenter.php +++ b/docs/documenter.php @@ -222,6 +222,7 @@ function get_all_components(): array 'Psl\\Str\\Byte', 'Psl\\Str\\Grapheme', 'Psl\\TCP', + 'Psl\\TCP\\TLS', 'Psl\\Trait', 'Psl\\Type', 'Psl\\Unix', diff --git a/examples/tcp/basic-http-client.php b/examples/tcp/basic-http-client.php index cb877141..06b00115 100644 --- a/examples/tcp/basic-http-client.php +++ b/examples/tcp/basic-http-client.php @@ -6,24 +6,43 @@ use Psl\Async; use Psl\IO; +use Psl\Str; use Psl\TCP; require __DIR__ . '/../../vendor/autoload.php'; -function fetch(string $host, string $path): string +Async\main(static function(): void { + [$headers, $content] = fetch('https://php-standard-library.github.io'); + + $output = IO\error_handle() ?? IO\output_handle(); + + $output->writeAll($headers); + $output->writeAll("\n"); + $output->writeAll($content); +}); + +function fetch(string $url): array { - $client = TCP\connect($host, 80); - $client->writeAll("GET {$path} HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n"); - $response = $client->readAll(); - $client->close(); + $parsed_url = parse_url($url); + $host = $parsed_url['host']; + $port = $parsed_url['port'] ?? ($parsed_url['scheme'] === 'https' ? 443 : 80); + $path = $parsed_url['path'] ?? '/'; - return $response; -} + $options = TCP\ConnectOptions::create(); + if ($parsed_url['scheme'] === 'https') { + $options = $options->withTLSConnectOptions( + TCP\TLS\ConnectOptions::default()->withPeerName($host), + ); + } -Async\main(static function (): int { - $response = fetch('example.com', '/'); + $client = TCP\connect($host, $port, $options); + $client->writeAll("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n"); - IO\write_error_line($response); + $response = $client->readAll(); - return 0; -}); + $position = Str\search($response, "\r\n\r\n"); + $headers = Str\slice($response, 0, $position); + $content = Str\slice($response, $position + 4); + + return [$headers, $content]; +} diff --git a/foo.php b/foo.php new file mode 100644 index 00000000..d3918813 --- /dev/null +++ b/foo.php @@ -0,0 +1,24 @@ +reachedEndOfDataSource())) { + var_dump($x); + $byte = $file->readFixedSize(1); + $bytes += strlen($byte); + + IO\write_line('read %d bytes.', $bytes); + var_dump($file->reachedEndOfDataSource()); + } + + $file->close(); +}); diff --git a/src/Psl/Internal/Loader.php b/src/Psl/Internal/Loader.php index 8c384e2b..22065b86 100644 --- a/src/Psl/Internal/Loader.php +++ b/src/Psl/Internal/Loader.php @@ -612,6 +612,7 @@ final class Loader 'Psl\\Range\\LowerBoundRangeInterface' => 'Psl/Range/LowerBoundRangeInterface.php', 'Psl\\Range\\UpperBoundRangeInterface' => 'Psl/Range/UpperBoundRangeInterface.php', 'Psl\\Default\\DefaultInterface' => 'Psl/Default/DefaultInterface.php', + 'Psl\\TCP\\TLS\\Exception\\ExceptionInterface' => 'Psl/TCP/TLS/Exception/ExceptionInterface.php', ]; public const TRAITS = [ @@ -815,6 +816,12 @@ final class Loader 'Psl\\Range\\ToRange' => 'Psl/Range/ToRange.php', 'Psl\\Range\\BetweenRange' => 'Psl/Range/BetweenRange.php', 'Psl\\Range\\FullRange' => 'Psl/Range/FullRange.php', + 'Psl\\TCP\\TLS\\Exception\\NegotiationException' => 'Psl/TCP/TLS/Exception/NegotiationException.php', + 'Psl\\TCP\\TLS\\Certificate' => 'Psl/TCP/TLS/Certificate.php', + 'Psl\\TCP\\TLS\\ConnectOptions' => 'Psl/TCP/TLS/ConnectOptions.php', + 'Psl\\TCP\\TLS\\HashingAlgorithm' => 'Psl/TCP/TLS/HashingAlgorithm.php', + 'Psl\\TCP\\TLS\\SecurityLevel' => 'Psl/TCP/TLS/SecurityLevel.php', + 'Psl\\TCP\\TLS\\Version' => 'Psl/TCP/TLS/Version.php', ]; public const ENUMS = [ diff --git a/src/Psl/Network/Internal/socket_connect.php b/src/Psl/Network/Internal/socket_connect.php index 50420a27..b3dd7c8c 100644 --- a/src/Psl/Network/Internal/socket_connect.php +++ b/src/Psl/Network/Internal/socket_connect.php @@ -6,12 +6,14 @@ use Psl\Internal; use Psl\Network\Exception; +use Psl\Str; use Revolt\EventLoop; use function fclose; use function is_resource; use function stream_context_create; use function stream_socket_client; +use function stream_socket_get_name; use const STREAM_CLIENT_ASYNC_CONNECT; use const STREAM_CLIENT_CONNECT; @@ -61,8 +63,19 @@ function socket_connect(string $uri, array $context = [], ?float $timeout = null }); try { - /** @var resource */ - return $suspension->suspend(); + /** @var resource $socket */ + $socket = $suspension->suspend(); + + if (stream_socket_get_name($socket, true) === false) { + fclose($socket); + + throw new Exception\RuntimeException(Str\format( + 'Connection to %s refused%s', + $uri, + )); + } + + return $socket; } finally { EventLoop::cancel($write_watcher); EventLoop::cancel($timeout_watcher); diff --git a/src/Psl/TCP/ConnectOptions.php b/src/Psl/TCP/ConnectOptions.php index 98da8263..2537d598 100644 --- a/src/Psl/TCP/ConnectOptions.php +++ b/src/Psl/TCP/ConnectOptions.php @@ -14,16 +14,18 @@ final class ConnectOptions implements DefaultInterface { /** - * Initializes a new instance of {@see ConnectOptions} with the specified settings. + * Constructor is private to enforce immutability. Use static creation methods instead. * - * @param bool $noDelay Determines whether the TCP_NODELAY option is enabled, controlling - * the use of the Nagle algorithm. When true, TCP_NODELAY is enabled, - * and the Nagle algorithm is disabled. + * @param bool $noDelay Indicates whether to disable Nagle's algorithm. When true, packets are sent immediately. + * @param null|array{non-empty-string, null|int} $bindTo Specifies the IP address and optionally the port to bind to. + * Format: [`IP`, `Port`]. `Port` is optional and can be null. * * @pure */ - public function __construct( - public readonly bool $noDelay, + private function __construct( + public readonly bool $noDelay, + public readonly ?array $bindTo, + public readonly ?TLS\ConnectOptions $TLSConnectOptions, ) { } @@ -41,18 +43,12 @@ public function __construct( */ public static function create(bool $noDelay = false): ConnectOptions { - return new self($noDelay); + return new self($noDelay, null, null); } /** * Creates and returns a default instance of {@see ConnectOptions}. * - * The default instance has the TCP_NODELAY option disabled, allowing the Nagle algorithm - * to be used. This method is a convenience wrapper around the `create` method, adhering to - * the {@see DefaultInterface} contract. - * - * @return static A default ConnectOptions instance with noDelay set to false. - * * @pure */ public static function default(): static @@ -61,14 +57,63 @@ public static function default(): static } /** - * Returns a new instance of {@see ConnectOptions} with the noDelay setting modified. + * Returns a new instance with noDelay enabled. * - * @param bool $enabled Specifies the desired state of the TCP_NODELAY option. + * @return ConnectOptions A new instance with noDelay set to true. * * @mutation-free */ public function withNoDelay(bool $enabled = true): ConnectOptions { - return new self($enabled); + return new self($enabled, $this->bindTo, $this->TLSConnectOptions); + } + + /** + * Returns a new instance with the specified IP and optionally port to bind to. + * + * @param non-empty-string $ip The IP address to bind the connection to. + * @param int|null $port The port number to bind the connection to, or null to not specify. + * + * @return ConnectOptions A new instance with the updated bindTo option. + * + * @mutation-free + */ + public function withBindTo(string $ip, ?int $port = null): ConnectOptions + { + return new self($this->noDelay, [$ip, $port], $this->TLSConnectOptions); + } + + /** + * Returns a new instance without any bindTo configuration. + * + * @return ConnectOptions A new instance with bindTo set to null. + * + * @mutation-free + */ + public function withoutBindTo(): ConnectOptions + { + return new self($this->noDelay, null, $this->TLSConnectOptions); + } + + /** + * Returns a new instance with the specified TLS connect options. + * + * @param TLS\ConnectOptions $tls_connect_options The TLS connect options. + * + * @mutation-free + */ + public function withTLSConnectOptions(TLS\ConnectOptions $tls_connect_options): ConnectOptions + { + return new self($this->noDelay, $this->bindTo, $tls_connect_options); + } + + /** + * Returns a new instance without the Tls connect options. + * + * @mutation-free + */ + public function withoutTlsConnectOptions(): ConnectOptions + { + return new self($this->noDelay, $this->bindTo, null); } } diff --git a/src/Psl/TCP/TLS/Certificate.php b/src/Psl/TCP/TLS/Certificate.php new file mode 100644 index 00000000..bb66f0ff --- /dev/null +++ b/src/Psl/TCP/TLS/Certificate.php @@ -0,0 +1,47 @@ +withMinimumVersion(TCP\TLS\Version::Tls12) + * ->withVerifyPeer(true) + * ->withCertificateAuthorityFile('/path/to/cafile.pem'); + * + * ``` + * + * @immutable The class is designed to be immutable, ensuring that an instance once created cannot be modified. + */ +final class ConnectOptions implements DefaultInterface +{ + /** + * Constructs a new instance of the TLS connection options with specified settings. + * + * @param Version $minimumVersion Specifies the minimum TLS version that is acceptable for connections. + * @param string $peerName Specifies the expected name of the peer, used in verifying the peer's certificate. + * @param bool $peerVerification Indicates whether the peer's SSL certificate should be verified. + * @param int<0, max> $verificationDepth Specifies the maximum depth for certificate chain verification. + * @param null|non-empty-list $peerFingerprints Optional. Specifies peer fingerprints for certificate verification, allowing + * for additional security checks based on expected certificate fingerprints. + * @param null|non-empty-string $ciphers Specifies the cipher suite(s) to be used for the TLS connection, determining + * the encryption algorithms that will be available during the TLS handshake. + * @param null|non-empty-string $certificateAuthorityFile Optional. Specifies the path to a Certificate Authority (CA) file + * to be used for verifying the peer's certificate. + * @param null|non-empty-string $certificateAuthorityPath Optional. Specifies the path to a directory containing Certificate + * Authority (CA) certificates, which will be used for verifying the peer's certificate. + * @param bool $capturePeerCertificate Indicates whether the peer's certificate should be captured during + * the handshake process. This can be useful for inspection or logging purposes. + * @param bool $SNIEnabled Indicates whether Server Name Indication (SNI) should be used, + * which allows multiple domains to be served over HTTPS from the same IP address. + * @param SecurityLevel $securityLevel Specifies the security level for the TLS connection, influencing + * the choice of cryptographic algorithms. + * @param null|Certificate $certificate Optional. Specifies a client certificate to be used for the TLS connection, + * which may be required by servers expecting client authentication. + * @param list $ALPNProtocols Specifies the protocols to be used for Application Layer Protocol Negotiation (ALPN), + * enabling the selection of application-specific protocols within the TLS layer. + * + * @pure + */ + private function __construct( + public readonly Version $minimumVersion, + public readonly string $peerName, + public readonly bool $peerVerification, + public readonly int $verificationDepth, + public readonly ?array $peerFingerprints, + public readonly ?string $ciphers, + public readonly ?string $certificateAuthorityFile, + public readonly ?string $certificateAuthorityPath, + public readonly bool $capturePeerCertificate, + public readonly bool $SNIEnabled, + public readonly SecurityLevel $securityLevel, + public readonly ?Certificate $certificate, + public readonly array $ALPNProtocols, + ) { + } + + /** + * Creates a new instance of ConnectOptions with default settings. + * + * @return ConnectOptions The new instance with default values. + * + * @pure + */ + public static function create(): self + { + return new self( + minimumVersion: Version::default(), + peerName: '', + peerVerification: true, + verificationDepth: 10, + peerFingerprints: null, + ciphers: null, + certificateAuthorityFile: null, + certificateAuthorityPath: null, + capturePeerCertificate: false, + SNIEnabled: true, + securityLevel: SecurityLevel::default(), + certificate: null, + ALPNProtocols: [] + ); + } + + /** + * Creates and returns a default instance of {@see ConnectOptions}. + * + * @pure + */ + public static function default(): static + { + return static::create(); + } + + /** + * Specifies the minimum version of the TLS protocol that is acceptable. + * + * @param Version $version The minimum TLS version. + * + * @return ConnectOptions A new instance with the specified minimum TLS version. + * + * @mutation-free + */ + public function withMinimumVersion(Version $version): self + { + return new self( + $version, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->ALPNProtocols + ); + } + + /** + * Sets the expected name of the peer for certificate verification. + * + * @param string $peer_name The expected name of the peer. + * + * @return ConnectOptions A new instance with the specified peer name. + * + * @mutation-free + */ + public function withPeerName(string $peer_name): self + { + return new self( + $this->minimumVersion, + $peer_name, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->ALPNProtocols + ); + } + + /** + * Enables verification of the peer's SSL certificate. + * + * @return ConnectOptions A new instance with peer verification enabled. + * + * @mutation-free + */ + public function withPeerVerification(bool $peer_verification = true): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $peer_verification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->ALPNProtocols + ); + } + + /** + * Sets the maximum depth for certificate chain verification. + * + * @param int<0, max> $verification_depth The maximum verification depth. + * + * @return ConnectOptions A new instance with the specified verification depth. + * + * @mutation-free + */ + public function withVerificationDepth(int $verification_depth): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $verification_depth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->ALPNProtocols + ); + } + + + /** + * Adds a peer fingerprint for certificate verification. + * + * @param HashingAlgorithm $hashing_algorithm The hashing algorithm used for the fingerprint. + * @param string $fingerprint The fingerprint string. + * + * @return ConnectOptions A new instance with the added peer fingerprint. + * + * @mutation-free + */ + public function withPeerFingerprint(HashingAlgorithm $hashing_algorithm, string $fingerprint): self + { + return $this->withPeerFingerprints([ + [$hashing_algorithm, $fingerprint], + ]); + } + + /** + * Sets multiple peer fingerprints for certificate verification. + * + * @param null|non-empty-list $peer_fingerprints An array of peer fingerprints. + * + * @return ConnectOptions A new instance with the specified peer fingerprints. + * + * @mutation-free + */ + public function withPeerFingerprints(?array $peer_fingerprints): self + { + if (null !== $peer_fingerprints) { + foreach ($peer_fingerprints as [$algorithm, $fingerprint]) { + Psl\invariant( + Byte\length($fingerprint) === $algorithm->getExpectedLength(), + 'Fingerprint length does not match expected length for "%s" algorithm.', + $algorithm->value, + ); + } + } + + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $peer_fingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->ALPNProtocols + ); + } + + /** + * Removes all peer fingerprints from the certificate verification process. + * + * @return ConnectOptions A new instance without any peer fingerprints. + * + * @mutation-free + */ + public function withoutPeerFingerprints(): self + { + return $this->withPeerFingerprints(null); + } + + /** + * Specifies the cipher suite to be used for the TLS connection. + * + * @param non-empty-string $ciphers The cipher suite. + * + * @return ConnectOptions A new instance with the specified ciphers. + * + * @mutation-free + */ + public function withCiphers(string $ciphers): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->ALPNProtocols + ); + } + + /** + * Sets the path to the Certificate Authority (CA) file for verifying the peer certificate. + * + * @param null|non-empty-string $certificate_authority_file The path to the CA file. + * + * @return ConnectOptions A new instance with the specified CA file path. + * + * @mutation-free + */ + public function withCertificateAuthorityFile(?string $certificate_authority_file): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $certificate_authority_file, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->ALPNProtocols + ); + } + + /** + * Sets the path to the Certificate Authority (CA) directory for verifying the peer certificate. + * + * @param null|non-empty-string $certificate_authority_path The path to the CA directory. + * + * @return ConnectOptions A new instance with the specified CA directory path. + * + * @mutation-free + */ + public function withCertificateAuthorityPath(?string $certificate_authority_path): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $certificate_authority_path, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->ALPNProtocols + ); + } + + /** + * Enables or disables capturing of the peer's certificate. + * + * @param bool $capture_peer_certificate Whether to capture the peer's certificate. + * + * @return ConnectOptions A new instance with the specified peer certificate capturing setting. + * + * @mutation-free + */ + public function withCapturePeerCertificate(bool $capture_peer_certificate): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $capture_peer_certificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->ALPNProtocols + ); + } + + /** + * Enables or disables Server Name Indication (SNI). + * + * @param bool $sni_enabled Whether SNI is enabled. + * + * @return ConnectOptions A new instance with the specified SNI setting. + * + * @mutation-free + */ + public function withSNIEnabled(bool $sni_enabled = true): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $sni_enabled, + $this->securityLevel, + $this->certificate, + $this->ALPNProtocols + ); + } + + /** + * Sets the security level for the TLS connection. + * + * @param SecurityLevel $security_level The security level. + * + * @return ConnectOptions A new instance with the specified security level. + * + * @mutation-free + */ + public function withSecurityLevel(SecurityLevel $security_level): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $security_level, + $this->certificate, + $this->ALPNProtocols + ); + } + + /** + * Specifies a Certificate to be used for the TLS connection. + * + * @param null|Certificate $certificate The certificate. + * + * @return ConnectOptions A new instance with the specified certificate. + * + * @mutation-free + */ + public function withCertificate(?Certificate $certificate): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $certificate, + $this->ALPNProtocols + ); + } + + /** + * Sets the protocols to be used for Application Layer Protocol Negotiation (ALPN). + * + * @param list $alpn_protocols The ALPN protocols. + * + * @return ConnectOptions A new instance with the specified ALPN protocols. + * + * @mutation-free + */ + public function withALPNProtocols(array $alpn_protocols): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $alpn_protocols + ); + } +} diff --git a/src/Psl/TCP/TLS/Exception/ExceptionInterface.php b/src/Psl/TCP/TLS/Exception/ExceptionInterface.php new file mode 100644 index 00000000..65940c60 --- /dev/null +++ b/src/Psl/TCP/TLS/Exception/ExceptionInterface.php @@ -0,0 +1,11 @@ + 40, + self::Sha256 => 64, + }; + } +} diff --git a/src/Psl/TCP/TLS/SecurityLevel.php b/src/Psl/TCP/TLS/SecurityLevel.php new file mode 100644 index 00000000..a04306b5 --- /dev/null +++ b/src/Psl/TCP/TLS/SecurityLevel.php @@ -0,0 +1,78 @@ + [ 'tcp_nodelay' => $options->noDelay, - ] + ], ]; - $socket = Network\Internal\socket_connect("tcp://{$host}:{$port}", $context, $timeout); + if (null !== $options->bindTo) { + $context['socket']['bindto'] = $options->bindTo[0] . ':' . ((string) ($options->bindTo[1] ?? 0)); + } + + $socket = Network\Internal\socket_connect("tcp://$host:$port", $context, $optional_timeout->getRemaining()); + $tls_options = $options->TLSConnectOptions; + if (null !== $tls_options) { + if ($tls_options->peerName === '') { + $tls_options = $tls_options->withPeerName($host); + } + + $context = [ + 'crypto_method' => match ($tls_options->minimumVersion) { + TLS\Version::Tls10 => STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT, + TLS\Version::Tls11 => STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT, + TLS\Version::Tls12 => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT, + TLS\Version::Tls13 => STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT, + }, + 'peer_name' => $tls_options->peerName, + 'verify_peer' => $tls_options->peerVerification, + 'verify_peer_name' => $tls_options->peerVerification, + 'verify_depth' => $tls_options->verificationDepth, + 'ciphers' => $tls_options->ciphers ?? OPENSSL_DEFAULT_STREAM_CIPHERS, + 'capture_peer_cert' => $tls_options->capturePeerCertificate, + 'capture_peer_cert_chain' => $tls_options->capturePeerCertificate, + 'SNI_enabled' => $tls_options->SNIEnabled, + 'security_level' => $tls_options->securityLevel->value, + ]; + + if (null !== $tls_options->certificate) { + $context['local_cert'] = $tls_options->certificate->certificateFile; + if ($tls_options->certificate->certificateFile !== $tls_options->certificate->keyFile) { + $context['local_pk'] = $tls_options->certificate->keyFile; + } + + if ($tls_options->certificate->passphrase !== null) { + $context['passphrase'] = $tls_options->certificate->passphrase; + } + } + + if ($tls_options->certificateAuthorityFile !== null) { + $context['cafile'] = $tls_options->certificateAuthorityFile; + } + + if ($tls_options->certificateAuthorityPath !== null) { + $context['capath'] = $tls_options->certificateAuthorityPath; + } + + if ([] !== $tls_options->ALPNProtocols) { + $context['alpn_protocols'] = Str\join($tls_options->ALPNProtocols, ','); + } + + if ($tls_options->peerFingerprints !== null) { + $peer_fingerprints = []; + foreach ($tls_options->peerFingerprints as $peer_fingerprint) { + $peer_fingerprints[$peer_fingerprint[0]->value] = $peer_fingerprint[1]; + } + + $context['peer_fingerprint'] = $peer_fingerprints; + } + + if (PHP_VERSION_ID >= 80300) { + /** @psalm-suppress UnusedFunctionCall */ + stream_context_set_options($socket, ['ssl' => $context]); + } else { + stream_context_set_option($socket, ['ssl' => $context]); + } + + + $error_handler = static function (int $code, string $message) use ($socket): never { + if (feof($socket)) { + $message = 'Connection reset by peer'; + } + + throw new NegotiationException('TLS negotiation failed: ' . $message); + }; + + try { + set_error_handler($error_handler); + $result = stream_socket_enable_crypto($socket, enable: true); + + if ($result === false) { + throw new NegotiationException('TLS negotiation failed: Unknown error'); + } + } finally { + restore_error_handler(); + } + + if (true !== $result) { + while (true) { + $suspension = EventLoop::getSuspension(); + + $read_watcher = ''; + $timeout_watcher = ''; + $timeout = $optional_timeout->getRemaining(); + if (null !== $timeout) { + $timeout_watcher = EventLoop::delay($timeout, static function () use ($suspension, &$read_watcher, $socket) { + EventLoop::cancel($read_watcher); + + /** @psalm-suppress RedundantCondition - it can be resource|closed-resource */ + if (is_resource($socket)) { + fclose($socket); + } + + $suspension->throw(new Exception\TimeoutException('Connection to socket timed out.')); + }); + } + + $read_watcher = EventLoop::onReadable($socket, static function () use ($suspension, $timeout_watcher) { + EventLoop::cancel($timeout_watcher); + + $suspension->resume(); + }); + + try { + $suspension->suspend(); + } finally { + EventLoop::cancel($read_watcher); + EventLoop::cancel($timeout_watcher); + } + + try { + set_error_handler($error_handler); + $result = stream_socket_enable_crypto($socket, enable: true); + if ($result === false) { + $message = feof($socket) ? 'Connection reset by peer' : 'Unknown error'; + throw new NegotiationException('TLS negotiation failed: ' . $message); + } + } finally { + restore_error_handler(); + } + + if ($result === true) { + break; + } + } + } + } /** @psalm-suppress MissingThrowsDocblock */ return new Network\Internal\Socket($socket);