symplely/coroutine

View on GitHub
Coroutine/Misc/Network/SSLContext.php

Summary

Maintainability
C
7 hrs
Test Coverage
<?php

declare(strict_types=1);

namespace Async\Network;

use Async\Kernel;
use Async\Network\AbstractOptions;

use function Async\Worker\awaitable_future;

/**
 * `SSLContext` holds various SSL-related configuration options and data, such as certificates and possibly a private key,
 * This class represent all `SSL context` _options_ as **methods**.
 *
 * - _Invoking_ a `$sslContext();` __instance__ returns a `stream_context` **resource**.
 *
 * @see  https://www.php.net/manual/en/context.ssl.php
 */
final class SSLContext extends AbstractOptions
{
  /**
   *  Setup encryption on the stream. Valid methods are:
   *
   * -  STREAM_CRYPTO_METHOD_SSLv2_CLIENT
   * -  STREAM_CRYPTO_METHOD_SSLv3_CLIENT
   * -  STREAM_CRYPTO_METHOD_SSLv23_CLIENT
   * -  STREAM_CRYPTO_METHOD_ANY_CLIENT
   * -  STREAM_CRYPTO_METHOD_TLS_CLIENT
   * -  STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT
   * -  STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT
   * -  STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT
   * -  STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT
   * -  STREAM_CRYPTO_METHOD_SSLv2_SERVER
   * -  STREAM_CRYPTO_METHOD_SSLv3_SERVER
   * -  STREAM_CRYPTO_METHOD_SSLv23_SERVER
   * -  STREAM_CRYPTO_METHOD_ANY_SERVER
   * -  STREAM_CRYPTO_METHOD_TLS_SERVER
   * -  STREAM_CRYPTO_METHOD_TLSv1_0_SERVER
   * -  STREAM_CRYPTO_METHOD_TLSv1_1_SERVER
   * -  STREAM_CRYPTO_METHOD_TLSv1_2_SERVER
   * -  STREAM_CRYPTO_METHOD_TLSv1_3_SERVER
   *
   * @var int[] \CLIENT_AUTH => int or \SERVER_AUTH => int]
   */
  protected $crypto_method;

  /**
   * Purpose `CLIENT_AUTH` or `SERVER_AUTH`
   *
   * @var string
   */
  protected $purpose;

  protected $type = 'ssl';

  /**
   * @var string
   */
  protected static $caPath = __DIR__ . \DS;

  /**
   * @var string
   */
  protected static $privatekey = 'privatekey.pem';

  /**
   * @var string
   */
  protected static $certificate = 'certificate.crt';

  /**
   * @var bool
   */
  protected static $secured = false;

  /**
   * @var string
   */
  protected static $hostname = null;

  /**
   * An initial _resource_ `context` or setup one from _associate array_.
   *
   * @param resource|array|null $options
   */
  public function __construct($options = null)
  {
    if (!\is_resource($options) && \is_array($options))
      $options = \stream_context_create($options);
    elseif ($options instanceof OptionsInterface)
      $options = $options();
    elseif (!\is_resource($options))
      $options = \stream_context_create();

    if (\is_resource($options) && \get_resource_type($options) === 'stream-context')
      $this->options = $options;
  }

  /**
   * Create a `SSLContext` object with default settings.
   * - This function needs to be prefixed with `yield`
   *
   * @param string $purpose `CLIENT_AUTH` or `SERVER_AUTH`
   * @param string $name server `hostname`
   * @param resource|array|OptionsInterface $context A _resource_ `context` or create from _associate array_.
   * @param string $capath `directory` to hostname certificate
   * @param string $cafile hostname `certificate`
   * @param array $caSelfDetails - for a self signed certificate, will be created automatically if no hostname certificate available.
   *
   *```md
   *  [
   *      "countryName" =>  '',
   *      "stateOrProvinceName" => '',
   *      "localityName" => '',
   *      "organizationName" => '',
   *      "organizationalUnitName" => '',
   *      "commonName" => '',
   *      "emailAddress" => ''
   *  ];
   *```
   * @return SSLContext
   */
  public static function create_default_context(
    string $purpose,
    ?string $name = None,
    $context = [],
    ?string $capath = None,
    ?string $cafile = None,
    array $caSelfDetails = []
  ) {
    $options = new self($context);

    if ($purpose === \CLIENT_AUTH) {
      $method = \STREAM_CRYPTO_METHOD_SSLv23_CLIENT
        | \STREAM_CRYPTO_METHOD_TLS_CLIENT
        | \STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT
        | \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT;

      if ((float) \phpversion() >= 7.4)
        $method |= \STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT;

      $options->ciphers('HIGH:!SSLv2:!SSLv3')
        ->allow_self_signed(true)
        ->disable_compression(true)
        ->capture_peer_cert(true)
        ->verify_peer(true)
        ->set_crypto(\CLIENT_AUTH, $method);
    } elseif ($purpose === \SERVER_AUTH) {
      if (!self::$secured) {
        yield self::certificate($name, $capath, $cafile, $caSelfDetails);
      }

      $selfSigned = yield await(exist, self::$caPath . self::$hostname . '.local');
      $method = \STREAM_CRYPTO_METHOD_SSLv23_SERVER
        | \STREAM_CRYPTO_METHOD_TLS_SERVER
        | \STREAM_CRYPTO_METHOD_TLSv1_1_SERVER
        | \STREAM_CRYPTO_METHOD_TLSv1_2_SERVER;

      if ((float) \phpversion() >= 7.4)
        $method |= \STREAM_CRYPTO_METHOD_TLSv1_3_SERVER;

      $options->local_cert(self::$certificate)
        ->local_pk(self::$privatekey)
        ->capath(self::$caPath)
        ->passphrase()
        ->allow_self_signed($selfSigned)
        ->verify_peer(true)
        ->verify_peer_name(!$selfSigned)
        ->SNI_enabled(true)
        ->disable_compression(true)
        ->capture_peer_cert(true)
        ->set_crypto(\SERVER_AUTH, $method);
    }

    return $options;
  }

  /**
   * Check for `certificate`, setup static class variables, will create _self signed certificate_ if no `certificate` present.
   * - This function needs to be prefixed with `yield`
   *
   * @param string $name defaults to system hostname if `blank`.
   * @param string $ssl_path defaults to current working `directory`.
   * @param string $ssl_file current `certificate`.
   * @param array $details - self certificate details.
   *
   *```md
   *  [
   *      "countryName" =>  '',
   *      "stateOrProvinceName" => '',
   *      "localityName" => '',
   *      "organizationName" => '',
   *      "organizationalUnitName" => '',
   *      "commonName" => '',
   *      "emailAddress" => ''
   *  ];
   *```
   */
  public static function certificate(
    string $name = null,
    string $ssl_path = null,
    string $ssl_file = null,
    array $details = []
  ) {
    if (empty($ssl_path)) {
      $ssl_path = \IS_UV ? \uv_cwd() : \getcwd();
      $ssl_path = \preg_replace('/\\\/', \DS, $ssl_path) . \DS;
    } elseif (\strpos($ssl_path, \DS, -1) === false) {
      $ssl_path = $ssl_path . \DS;
    }

    $hostname = self::$hostname = empty($name) ? yield \await('gethostname') : $name;
    $privatekeyFile = self::$privatekey = $hostname . '.pem';
    $certificateFile = self::$certificate = !empty($ssl_file) ? $ssl_file : $hostname . '.crt';
    $signingFile = $hostname . '.csr';
    self::$caPath = $ssl_path;
    self::$secured = true;

    $isSignedReady = yield await(exist, $ssl_path . $certificateFile);
    if (!$isSignedReady) {
      // @codeCoverageIgnoreStart
      $make = function () use ($ssl_path, $details, $signingFile, $privatekeyFile, $certificateFile, $hostname) {
        $opensslConfig = array("config" => $ssl_path . 'openssl.cnf');
        // Generate a new private (and public) key pair
        $privatekey = \openssl_pkey_new($opensslConfig);
        if (empty($details))
          $details = ["commonName" => $hostname];

        // Generate a certificate signing request
        $csr = \openssl_csr_new($details, $privatekey, $opensslConfig);
        // Create a self-signed certificate valid for 365 days
        $sslcert = \openssl_csr_sign($csr, null, $privatekey, 365, $opensslConfig);
        // Create key file. Note no passphrase
        \openssl_pkey_export_to_file($privatekey, $ssl_path . $privatekeyFile, null, $opensslConfig);
        // Create server certificate
        \openssl_x509_export_to_file($sslcert, $ssl_path . $certificateFile, false);
        // Create a signing request file
        \openssl_csr_export_to_file($csr, $ssl_path . $signingFile);
        \touch($ssl_path . $hostname . '.local');
      };
      // @codeCoverageIgnoreEnd

      yield awaitable_future(function () use ($make) {
        return yield Kernel::addFuture($make);
      });
    }
  }

  /**
   * @param resource $stream a `stream`
   * @param \Socket|resource $socket a `socket` object
   */
  public function wrap_socket($stream, $socket): SSLSockets
  {
    return SSLSockets::wrap($stream, $socket, $this);
  }

  /**
   *  Setup encryption on the stream.
   *
   * @param string $purpose `CLIENT_AUTH` or `SERVER_AUTH`
   * @param integer $crypto Valid methods are:
   * - STREAM_CRYPTO_METHOD_SSLv2_CLIENT
   * - STREAM_CRYPTO_METHOD_SSLv3_CLIENT
   * - STREAM_CRYPTO_METHOD_SSLv23_CLIENT
   * - STREAM_CRYPTO_METHOD_ANY_CLIENT
   * - STREAM_CRYPTO_METHOD_TLS_CLIENT
   * - STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT
   * - STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT
   * - STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT
   * - If `PHP 7.4+` STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT
   * - STREAM_CRYPTO_METHOD_SSLv2_SERVER
   * - STREAM_CRYPTO_METHOD_SSLv3_SERVER
   * - STREAM_CRYPTO_METHOD_SSLv23_SERVER
   * - STREAM_CRYPTO_METHOD_ANY_SERVER
   * - STREAM_CRYPTO_METHOD_TLS_SERVER
   * - STREAM_CRYPTO_METHOD_TLSv1_0_SERVER
   * - STREAM_CRYPTO_METHOD_TLSv1_1_SERVER
   * - STREAM_CRYPTO_METHOD_TLSv1_2_SERVER
   * - If `PHP 7.4+` STREAM_CRYPTO_METHOD_TLSv1_3_SERVER
   *
   * @return self
   */
  public function set_crypto(string $purpose, int $crypto): self
  {
    $this->purpose = $purpose;
    $this->crypto_method[$purpose] = $crypto;
    return $this;
  }

  /**
   *  Return the setup encryptions on the stream.
   *
   * @return integer
   */
  public function get_crypto(): int
  {
    return $this->crypto_method[$this->purpose];
  }

  /**
   * Peer `name` to be used. If this value is not set, then the name is guessed based on the hostname used when opening the stream.
   *
   * @param string $name
   * @return self
   */
  public function peer_name(string $name)
  {
    return $this->set_option('peer_name', $name);
  }

  /**
   * Require `verification` of SSL certificate used.
   *
   * @param boolean $verification Defaults to `true`.
   * @return self
   */
  public function verify_peer(bool $verification = true)
  {
    return $this->set_option('verify_peer', $verification);
  }

  /**
   * Require `verification` of peer name.
   *
   * @param boolean $verification Defaults to `true`.
   * @return self
   */
  public function verify_peer_name(bool $verification = true)
  {
    return $this->set_option('verify_peer_name', $verification);
  }

  /**
   * Allow self-`signed` certificates. Requires `verify_peer`.
   *
   * @param boolean $signed Defaults to `false`.
   * @return self
   */
  public function allow_self_signed(bool $signed = false)
  {
    return $this->set_option('allow_self_signed', $signed);
  }

  /**
   * Location of Certificate `Authority` file on local filesystem which should be used with the `verify_peer` context option to authenticate the identity of the remote peer.
   *
   * @param string $authority
   * @return self
   */
  public function cafile(string $authority)
  {
    return $this->set_option('cafile', $authority);
  }

  /**
   * If cafile is not specified or if the certificate is not found there, the `directory` pointed to by capath is searched for a suitable certificate. capath must be a correctly hashed certificate directory.
   *
   * @param string $directory
   * @return self
   */
  public function capath(string $directory)
  {
    return $this->set_option('capath', $directory);
  }

  /**
   * Path to local `certificate` file on filesystem. It must be a PEM encoded file which contains your certificate and private key.
   * It can optionally contain the certificate chain of issuers. The private key also may be contained in a separate file specified by local_pk.
   *
   * @param string $certificate SSL Cert in PEM format
   * @return self
   */
  public function local_cert(string $certificate)
  {
    return $this->set_option('local_cert', $certificate);
  }

  /**
   * Path to local `private` key file on filesystem in case of separate files for certificate (local_cert) and private key.
   *
   * @param string $privatekey RSA key in PEM format
   * @return self
   */
  public function local_pk(string $privatekey)
  {
    return $this->set_option('local_pk', $privatekey);
  }

  /**
   * Pass`phrase` with which your local_cert file was encoded.
   *
   * @param string $phrase Private key Password
   * @return self
   */
  public function passphrase(string $phrase = null)
  {
    return $this->set_option('passphrase', $phrase);
  }

  /**
   * Abort if the certificate chain is too deep.
   *
   * @param integer|null $depth Defaults to no verification.
   * @return self
   */
  public function verify_depth(int $depth = null)
  {
    return $this->set_option('verify_depth', $depth);
  }

  /**
   * Sets the list of available ciphers. The format of the string is described in » ciphers(1).
   *
   * @param string $ciphers_list Defaults to DEFAULT.
   * @return self
   * @see https://www.openssl.org/docs/manmaster/man1/openssl-ciphers.html
   */
  public function ciphers(string $ciphers_list)
  {
    return $this->set_option('ciphers', $ciphers_list);
  }

  /**
   * If set to `true` a peer_certificate context option will be created containing the peer certificate.
   *
   * @param boolean $create_cert
   * @return self
   */
  public function capture_peer_cert(bool $create_cert)
  {
    return $this->set_option('capture_peer_cert', $create_cert);
  }

  /**
   * If set to `true` a peer_certificate_chain context option will be created containing the certificate chain.
   *
   * @param boolean $create_chain
   * @return self
   */
  public function capture_peer_cert_chain(bool $create_chain)
  {
    return $this->set_option('capture_peer_cert_chain', $create_chain);
  }

  /**
   * If set to `true` server name indication will be enabled. Enabling SNI allows multiple certificates on the same IP address.
   *
   * @param boolean $sni_enabled
   * @return self
   */
  public function SNI_enabled(bool $enable_sni)
  {
    return $this->set_option('SNI_enabled', $enable_sni);
  }

  /**
   * If set, disable TLS compression. This can help mitigate the CRIME attack vector.
   *
   * @param boolean $compression
   * @return self
   */
  public function disable_compression(bool $compression)
  {
    return $this->set_option('disable_compression', $compression);
  }

  /**
   * Aborts when the remote certificate digest doesn't match the specified `hash`.
   *
   * @param string|array $hash
   * - When a string is used, the length will determine which hashing algorithm is applied, either "md5" (32) or "sha1" (40).
   * - When an array is used, the keys indicate the hashing algorithm name and each corresponding value is the expected digest.
   * @return self
   */
  public function peer_fingerprint($hash)
  {
    return $this->set_option('peer_fingerprint', $hash);
  }

  /**
   * Sets the security `level`. If not specified the library default security level is used.
   * The security levels are described in » SSL_CTX_get_security_level(3).
   *
   * Available as of PHP 7.2.0 and OpenSSL 1.1.0.
   *
   * @param integer $level
   * @return self
   * @see https://www.openssl.org/docs/man1.1.1/man3/SSL_CTX_get_security_level.html
   */
  public function security_level(int $level)
  {
    return $this->set_option('security_level', $level);
  }
}