diff --git a/composer.json b/composer.json index 9844849..d6da292 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "license": "BSD-3-Clause", "require": { "php": "~8.1.0 || ~8.2.0 || ~8.3.0", - "ext-redis": "^5.0.2 || ^6.0", + "ext-redis": "^5.3.2 || ^6.0", "laminas/laminas-cache": "^3.10" }, "provide": { diff --git a/composer.lock b/composer.lock index dcfae77..c3afaab 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "18eabbd1db8bc1ba904fad53c3ef3071", + "content-hash": "2bad8f0b0911d3df5ac703cd0701dd45", "packages": [ { "name": "laminas/laminas-cache", @@ -5554,7 +5554,7 @@ "prefer-lowest": false, "platform": { "php": "~8.1.0 || ~8.2.0 || ~8.3.0", - "ext-redis": "^5.0.2 || ^6.0" + "ext-redis": "^5.3.2 || ^6.0" }, "platform-dev": [], "platform-overrides": { diff --git a/psalm-baseline.xml b/psalm-baseline.xml index e9fb47a..346dd6c 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -139,6 +139,7 @@ setReadTimeout setSeeds setTimeout + setSslContext diff --git a/src/RedisClusterOptions.php b/src/RedisClusterOptions.php index d403cc4..79bbaee 100644 --- a/src/RedisClusterOptions.php +++ b/src/RedisClusterOptions.php @@ -6,6 +6,11 @@ use Laminas\Cache\Exception\RuntimeException; use Laminas\Cache\Storage\Adapter\Exception\InvalidRedisClusterConfigurationException; +use Laminas\Stdlib\AbstractOptions; +use Traversable; + +use function is_array; +use function iterator_to_array; final class RedisClusterOptions extends AdapterOptions { @@ -53,6 +58,8 @@ final class RedisClusterOptions extends AdapterOptions private string $password = ''; + private ?SslContext $sslContext = null; + /** * @param iterable|null|AdapterOptions $options * @psalm-param iterable|null|AdapterOptions $options @@ -76,6 +83,31 @@ public function __construct($options = null) } } + /** + * {@inheritDoc} + */ + public function setFromArray($options) + { + if ($options instanceof AbstractOptions) { + $options = $options->toArray(); + } elseif ($options instanceof Traversable) { + $options = iterator_to_array($options); + } + + $sslContext = $options['sslContext'] ?? $options['ssl_context'] ?? null; + unset($options['sslContext'], $options['ssl_context']); + if (is_array($sslContext)) { + /** @psalm-suppress MixedArgumentTypeCoercion Trust upstream that they verify the array beforehand. */ + $sslContext = SslContext::fromSslContextArray($sslContext); + } + + if ($sslContext instanceof SslContext) { + $options['ssl_context'] = $sslContext; + } + + return parent::setFromArray($options); + } + public function setTimeout(float $timeout): void { $this->timeout = $timeout; @@ -222,4 +254,14 @@ public function setPassword(string $password): void { $this->password = $password; } + + public function getSslContext(): ?SslContext + { + return $this->sslContext; + } + + public function setSslContext(SslContext|null $sslContext): void + { + $this->sslContext = $sslContext; + } } diff --git a/src/RedisClusterResourceManager.php b/src/RedisClusterResourceManager.php index 7ae5fd2..a9609f4 100644 --- a/src/RedisClusterResourceManager.php +++ b/src/RedisClusterResourceManager.php @@ -81,7 +81,8 @@ private function createRedisResource(RedisClusterOptions $options): RedisCluster $options->getTimeout(), $options->getReadTimeout(), $options->isPersistent(), - $options->getPassword() + $options->getPassword(), + $options->getSslContext() ); } @@ -90,13 +91,20 @@ private function createRedisResource(RedisClusterOptions $options): RedisCluster $password = null; } + /** + * Psalm currently (<= 5.23.1) uses an outdated (phpredis < 5.3.2) constructor signature for the RedisCluster + * class in the phpredis extension. + * + * @psalm-suppress TooManyArguments https://github.com/vimeo/psalm/pull/10862 + */ return new RedisClusterFromExtension( null, $options->getSeeds(), $options->getTimeout(), $options->getReadTimeout(), $options->isPersistent(), - $password + $password, + $options->getSslContext()?->toSslContextArray() ); } @@ -108,7 +116,8 @@ private function createRedisResourceFromName( float $fallbackTimeout, float $fallbackReadTimeout, bool $persistent, - string $fallbackPassword + string $fallbackPassword, + ?SslContext $sslContext ): RedisClusterFromExtension { $options = new RedisClusterOptionsFromIni(); $seeds = $options->getSeeds($name); @@ -116,13 +125,20 @@ private function createRedisResourceFromName( $readTimeout = $options->getReadTimeout($name, $fallbackReadTimeout); $password = $options->getPasswordByName($name, $fallbackPassword); + /** + * Psalm currently (<= 5.23.1) uses an outdated (phpredis < 5.3.2) constructor signature for the RedisCluster + * class in the phpredis extension. + * + * @psalm-suppress TooManyArguments https://github.com/vimeo/psalm/pull/10862 + */ return new RedisClusterFromExtension( null, $seeds, $timeout, $readTimeout, $persistent, - $password + $password, + $sslContext?->toSslContextArray() ); } diff --git a/src/SslContext.php b/src/SslContext.php new file mode 100644 index 0000000..d9209a2 --- /dev/null +++ b/src/SslContext.php @@ -0,0 +1,220 @@ +, + * security_level?: non-negative-int + * } + */ +final class SslContext +{ + /** + * @param non-empty-string|null $expectedPeerName + * @param non-empty-string|null $certificateAuthorityFile + * @param non-empty-string|null $certificateAuthorityPath + * @param non-empty-string|null $localCertificatePath + * @param non-empty-string|null $localPrivateKeyPath + * @param non-empty-string|null $passphrase + * @param non-negative-int|null $verifyDepth + * @param non-empty-string|null $ciphers + * @param non-empty-string|array|null $peerFingerprint + * @param non-negative-int|null $securityLevel + */ + public function __construct( + /** + * 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. + */ + public readonly ?string $expectedPeerName = null, + /** + * Require verification of SSL certificate used. + */ + public readonly ?bool $verifyPeer = null, + /** + * Require verification of peer name. + */ + public readonly ?bool $verifyPeerName = null, + /** + * Allow self-signed certificates. Requires verifyPeer. + */ + public readonly ?bool $allowSelfSignedCertificates = null, + /** + * Location of Certificate Authority file on local filesystem which should be used with the verifyPeer + * context option to authenticate the identity of the remote peer. + */ + public readonly ?string $certificateAuthorityFile = null, + /** + * 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. + */ + public readonly ?string $certificateAuthorityPath = null, + /** + * 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 localPk. + */ + public readonly ?string $localCertificatePath = null, + /** + * Path to local private key file on filesystem in case of separate files for certificate (localCert) + * and private key. + */ + public readonly ?string $localPrivateKeyPath = null, + /** + * Passphrase with which your localCert file was encoded. + */ + #[SensitiveParameter] + public readonly ?string $passphrase = null, + /** + * Abort if the certificate chain is too deep. + * If not set, defaults to no verification. + */ + public readonly ?int $verifyDepth = null, + /** + * Sets the list of available ciphers. The format of the string is described in + * https://www.openssl.org/docs/manmaster/man1/ciphers.html#CIPHER-LIST-FORMAT + */ + public readonly ?string $ciphers = null, + /** + * If set to true server name indication will be enabled. Enabling SNI allows multiple certificates on the same + * IP address. + * If not set, will automatically be enabled if SNI support is available. + */ + public readonly ?bool $serverNameIndicationEnabled = null, + /** + * If set, disable TLS compression. This can help mitigate the CRIME attack vector. + */ + public readonly ?bool $disableCompression = null, + /** + * Aborts when the remote certificate digest doesn't match the specified 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. + */ + public readonly array|string|null $peerFingerprint = null, + /** + * Sets the security level. If not specified the library default security level is used. The security levels are + * described in https://www.openssl.org/docs/man1.1.1/man3/SSL_CTX_get_security_level.html. + */ + public readonly ?int $securityLevel = null, + ) { + } + + /** + * @param SSLContextArrayShape $context + */ + public static function fromSslContextArray(array $context): self + { + return new self( + $context['peer_name'] ?? null, + $context['verify_peer'] ?? null, + $context['verify_peer_name'] ?? null, + $context['allow_self_signed'] ?? null, + $context['cafile'] ?? null, + $context['capath'] ?? null, + $context['local_cert'] ?? null, + $context['local_pk'] ?? null, + $context['passphrase'] ?? null, + $context['verify_depth'] ?? null, + $context['ciphers'] ?? null, + $context['SNI_enabled'] ?? null, + $context['disable_compression'] ?? null, + $context['peer_fingerprint'] ?? null, + $context['security_level'] ?? null, + ); + } + + /** + * @return SSLContextArrayShape + */ + public function toSslContextArray(): array + { + $context = []; + if ($this->expectedPeerName !== null) { + $context['peer_name'] = $this->expectedPeerName; + } + + if ($this->verifyPeer !== null) { + $context['verify_peer'] = $this->verifyPeer; + } + + if ($this->verifyPeerName !== null) { + $context['verify_peer_name'] = $this->verifyPeerName; + } + + if ($this->allowSelfSignedCertificates !== null) { + $context['allow_self_signed'] = $this->allowSelfSignedCertificates; + } + + if ($this->certificateAuthorityFile !== null) { + $context['cafile'] = $this->certificateAuthorityFile; + } + + if ($this->certificateAuthorityPath !== null) { + $context['capath'] = $this->certificateAuthorityPath; + } + + if ($this->localCertificatePath !== null) { + $context['local_cert'] = $this->localCertificatePath; + } + + if ($this->localPrivateKeyPath !== null) { + $context['local_pk'] = $this->localPrivateKeyPath; + } + + if ($this->passphrase !== null) { + $context['passphrase'] = $this->passphrase; + } + + if ($this->verifyDepth !== null) { + $context['verify_depth'] = $this->verifyDepth; + } + + if ($this->ciphers !== null) { + $context['ciphers'] = $this->ciphers; + } + + if ($this->serverNameIndicationEnabled !== null) { + $context['SNI_enabled'] = $this->serverNameIndicationEnabled; + } + + if ($this->disableCompression !== null) { + $context['disable_compression'] = $this->disableCompression; + } + + if ($this->peerFingerprint !== null) { + $context['peer_fingerprint'] = $this->peerFingerprint; + } + + if ($this->securityLevel !== null) { + $context['security_level'] = $this->securityLevel; + } + + return $context; + } +} diff --git a/test/unit/RedisClusterOptionsTest.php b/test/unit/RedisClusterOptionsTest.php index 18f7d3b..93ae56e 100644 --- a/test/unit/RedisClusterOptionsTest.php +++ b/test/unit/RedisClusterOptionsTest.php @@ -9,6 +9,7 @@ use Laminas\Cache\Storage\Adapter\AdapterOptions; use Laminas\Cache\Storage\Adapter\Exception\InvalidRedisClusterConfigurationException; use Laminas\Cache\Storage\Adapter\RedisClusterOptions; +use Laminas\Cache\Storage\Adapter\SslContext; use Redis as RedisFromExtension; use ReflectionClass; @@ -29,6 +30,32 @@ protected function createAdapterOptions(): AdapterOptions return new RedisClusterOptions(['seeds' => ['localhost']]); } + public function testCanHandleOptionsWithSslContextObject(): void + { + $options = new RedisClusterOptions([ + 'name' => 'foo', + 'ssl_context' => new SslContext(localCertificatePath: '/path/to/localcert'), + ]); + + self::assertEquals('foo', $options->getName()); + $sslContext = $options->getSslContext(); + self::assertNotNull($sslContext); + self::assertSame('/path/to/localcert', $sslContext->localCertificatePath); + } + + public function testCanHandleOptionsWithSslContextArray(): void + { + $options = new RedisClusterOptions([ + 'name' => 'foo', + 'ssl_context' => ['local_cert' => '/path/to/localcert'], + ]); + + self::assertEquals('foo', $options->getName()); + $sslContext = $options->getSslContext(); + self::assertNotNull($sslContext); + self::assertSame('/path/to/localcert', $sslContext->localCertificatePath); + } + public function testCanHandleOptionsWithNodename(): void { $options = new RedisClusterOptions([ diff --git a/test/unit/SslContextTest.php b/test/unit/SslContextTest.php new file mode 100644 index 0000000..bd6ed42 --- /dev/null +++ b/test/unit/SslContextTest.php @@ -0,0 +1,72 @@ + 'some peer name', + 'verify_peer' => true, + 'verify_peer_name' => true, + 'allow_self_signed' => true, + 'cafile' => '/some/path/to/cafile.pem', + 'capath' => '/some/path/to/ca', + 'local_cert' => '/some/path/to/local.certificate.pem', + 'local_pk' => '/some/path/to/local.key', + 'passphrase' => 'secret', + 'verify_depth' => 10, + 'ciphers' => 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:" . +"ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:" . +"DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:" . +"ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:" . +"ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:" . +"DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:" . +"AES256-GCM-SHA384:AES128:AES256:HIGH:!SSLv2:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!RC4:!ADH', + 'SNI_enabled' => true, + 'disable_compression' => true, + 'peer_fingerprint' => ['md5' => 'some fingerprint'], + 'security_level' => 5, + ]; + + public function testWillNotGenerateContextIfNoneProvided(): void + { + $context = new SslContext(); + self::assertSame([], $context->toSslContextArray()); + } + + public function testSetFromArraySetsPropertiesCorrectly(): void + { + $context = SslContext::fromSslContextArray(self::SSL_CONTEXT); + + self::assertSame(self::SSL_CONTEXT['peer_name'], $context->expectedPeerName); + self::assertSame(self::SSL_CONTEXT['verify_peer'], $context->verifyPeer); + self::assertSame(self::SSL_CONTEXT['verify_peer_name'], $context->verifyPeerName); + self::assertSame(self::SSL_CONTEXT['allow_self_signed'], $context->allowSelfSignedCertificates); + self::assertSame(self::SSL_CONTEXT['cafile'], $context->certificateAuthorityFile); + self::assertSame(self::SSL_CONTEXT['capath'], $context->certificateAuthorityPath); + self::assertSame(self::SSL_CONTEXT['local_cert'], $context->localCertificatePath); + self::assertSame(self::SSL_CONTEXT['local_pk'], $context->localPrivateKeyPath); + self::assertSame(self::SSL_CONTEXT['passphrase'], $context->passphrase); + self::assertSame(self::SSL_CONTEXT['verify_depth'], $context->verifyDepth); + self::assertSame(self::SSL_CONTEXT['ciphers'], $context->ciphers); + self::assertSame(self::SSL_CONTEXT['SNI_enabled'], $context->serverNameIndicationEnabled); + self::assertSame(self::SSL_CONTEXT['disable_compression'], $context->disableCompression); + self::assertSame(self::SSL_CONTEXT['peer_fingerprint'], $context->peerFingerprint); + self::assertSame(self::SSL_CONTEXT['security_level'], $context->securityLevel); + } + + public function testSetFromArrayThrowsTypeErrorWhenProvidingInvalidValueType(): void + { + $this->expectException(TypeError::class); + $this->expectExceptionMessageMatches('/\(\$verifyPeer\) must be of type \?bool, string given/'); + + /** @psalm-suppress InvalidArgument We do want to verify what happens when invalid types are passed. */ + SslContext::fromSslContextArray(['verify_peer' => 'invalid type']); + } +}